feat(edit): 编辑页类目树逻辑开发
This commit is contained in:
@@ -69,6 +69,7 @@ export class ProductEditorController {
|
||||
async categoryOptions(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Query('store_id') storeId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@@ -78,34 +79,16 @@ export class ProductEditorController {
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
query,
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('product-platform-profiles/:profileId/category-syncs')
|
||||
async categorySync(
|
||||
@Param('profileId') profileId: string,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const result = await this.editor.requestCategoryTreeSync(
|
||||
workspace.id,
|
||||
request.auth.userId,
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
requiredUuid('Idempotency-Key', key),
|
||||
);
|
||||
response.status(result.status);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
@Get('product-platform-profiles/:profileId/attribute-schema')
|
||||
async attributeSchema(
|
||||
@Param('profileId') profileId: string,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Query('store_id') storeId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@@ -115,6 +98,7 @@ export class ProductEditorController {
|
||||
requiredUuid('profile_id', profileId),
|
||||
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||
optionalUuid('category_id', categoryId),
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -149,6 +133,7 @@ export class ProductEditorController {
|
||||
@Param('dictionaryId') dictionaryId: string,
|
||||
@Query('q') query: string | undefined,
|
||||
@Query('category_id') categoryId: string | undefined,
|
||||
@Query('store_id') storeId: string | undefined,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||
@CurrentWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@@ -162,6 +147,7 @@ export class ProductEditorController {
|
||||
dictionaryId,
|
||||
query,
|
||||
optionalUuid('category_id', categoryId),
|
||||
optionalUuid('store_id', storeId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ type ProfileRow = {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
tags: unknown;
|
||||
platform_product_category_id: string | null;
|
||||
store_product_category_id: string | null;
|
||||
platform_type_id: string | null;
|
||||
category_source: string | null;
|
||||
category_name: string | null;
|
||||
@@ -110,13 +110,18 @@ export class ProductEditorService {
|
||||
product.id,
|
||||
existing,
|
||||
);
|
||||
const profile = await this.applyCapturedCategory(
|
||||
transaction,
|
||||
product,
|
||||
existing,
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
data: this.toContext(
|
||||
collectionItemId,
|
||||
product.id,
|
||||
existing,
|
||||
profile,
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -145,13 +150,18 @@ export class ProductEditorService {
|
||||
if (!profile)
|
||||
throw new Error('商品编辑上下文创建后无法读取发布配置');
|
||||
await this.ensureListings(transaction, product.id, profile);
|
||||
const resolvedProfile = await this.applyCapturedCategory(
|
||||
transaction,
|
||||
product,
|
||||
profile,
|
||||
);
|
||||
return {
|
||||
status: created.rows[0] ? 201 : 200,
|
||||
body: {
|
||||
data: this.toContext(
|
||||
collectionItemId,
|
||||
product.id,
|
||||
profile,
|
||||
resolvedProfile,
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -172,17 +182,22 @@ export class ProductEditorService {
|
||||
transaction,
|
||||
productId,
|
||||
);
|
||||
const profile = await this.findProfile(
|
||||
const initialProfile = await this.findProfile(
|
||||
transaction,
|
||||
productId,
|
||||
platformId,
|
||||
);
|
||||
if (!profile)
|
||||
if (!initialProfile)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'EDITOR_CONTEXT_REQUIRED',
|
||||
'请先从采集箱进入商品编辑器',
|
||||
);
|
||||
const profile = await this.applyCapturedCategory(
|
||||
transaction,
|
||||
product,
|
||||
initialProfile,
|
||||
);
|
||||
const variants = await transaction.query<{
|
||||
id: string;
|
||||
source_variant_key: string | null;
|
||||
@@ -359,7 +374,7 @@ export class ProductEditorService {
|
||||
)
|
||||
: [],
|
||||
category: {
|
||||
id: profile.platform_product_category_id,
|
||||
id: profile.store_product_category_id,
|
||||
platform_type_id: profile.platform_type_id,
|
||||
source: profile.category_source,
|
||||
name: profile.category_name,
|
||||
@@ -427,6 +442,7 @@ export class ProductEditorService {
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
search: string | undefined,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
@@ -437,6 +453,9 @@ export class ProductEditorService {
|
||||
platformId,
|
||||
);
|
||||
const needle = search?.trim() ?? '';
|
||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||
if (storeId)
|
||||
await this.requireEditableStore(transaction, storeId, platformId);
|
||||
const categories = await transaction.query<{
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
@@ -447,11 +466,11 @@ export class ProductEditorService {
|
||||
is_active: boolean;
|
||||
}>(
|
||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE platform_id = $1
|
||||
FROM atlas.store_product_categories
|
||||
WHERE store_id = $1
|
||||
ORDER BY path, name, id
|
||||
LIMIT 10000`,
|
||||
[profile.platform_id],
|
||||
[selectedStoreId],
|
||||
);
|
||||
return categories.rows;
|
||||
},
|
||||
@@ -463,6 +482,7 @@ export class ProductEditorService {
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
@@ -473,7 +493,10 @@ export class ProductEditorService {
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
categoryId ?? profile.platform_product_category_id;
|
||||
categoryId ?? profile.store_product_category_id;
|
||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||
if (storeId)
|
||||
await this.requireEditableStore(transaction, storeId, platformId);
|
||||
if (!selectedCategoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
@@ -483,12 +506,12 @@ export class ProductEditorService {
|
||||
if (categoryId)
|
||||
await this.requireActiveCategory(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
selectedStoreId,
|
||||
categoryId,
|
||||
);
|
||||
return this.attributeSchemaForCategory(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
selectedStoreId,
|
||||
selectedCategoryId,
|
||||
);
|
||||
},
|
||||
@@ -502,6 +525,7 @@ export class ProductEditorService {
|
||||
dictionaryId: string,
|
||||
search: string | undefined,
|
||||
categoryId?: string,
|
||||
storeId?: string,
|
||||
) {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
@@ -512,7 +536,10 @@ export class ProductEditorService {
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
categoryId ?? profile.platform_product_category_id;
|
||||
categoryId ?? profile.store_product_category_id;
|
||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||
if (storeId)
|
||||
await this.requireEditableStore(transaction, storeId, platformId);
|
||||
if (!selectedCategoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
@@ -522,18 +549,18 @@ export class ProductEditorService {
|
||||
if (categoryId)
|
||||
await this.requireActiveCategory(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
selectedStoreId,
|
||||
categoryId,
|
||||
);
|
||||
const allowed = await transaction.query<{
|
||||
dictionary_id: string;
|
||||
}>(
|
||||
`SELECT dictionary_id
|
||||
FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2
|
||||
FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3
|
||||
LIMIT 1`,
|
||||
[profile.platform_id, selectedCategoryId, dictionaryId],
|
||||
[selectedStoreId, selectedCategoryId, dictionaryId],
|
||||
);
|
||||
if (!allowed.rows[0])
|
||||
throw new ApiError(
|
||||
@@ -547,40 +574,19 @@ export class ProductEditorService {
|
||||
value: string;
|
||||
}>(
|
||||
`SELECT external_value_id AS value_id, value
|
||||
FROM atlas.platform_attribute_dictionary_values
|
||||
WHERE platform_id = $1 AND dictionary_id = $2 AND is_active
|
||||
AND ($3 = '' OR value ILIKE '%' || $3 || '%')
|
||||
FROM atlas.store_attribute_dictionary_values
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3 AND is_active
|
||||
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
|
||||
ORDER BY value, external_value_id
|
||||
LIMIT 100`,
|
||||
[profile.platform_id, dictionaryId, needle],
|
||||
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
|
||||
);
|
||||
return { items: values.rows, next_cursor: null };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues only a platform read operation. The browser never sees a store
|
||||
* credential and continues to read the cache through the GET endpoints.
|
||||
*/
|
||||
async requestCategoryTreeSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
key: string,
|
||||
) {
|
||||
return this.requestCatalogSync({
|
||||
workspaceId,
|
||||
userId,
|
||||
profileId,
|
||||
platformId,
|
||||
key,
|
||||
scope: 'tree',
|
||||
path: 'category-syncs',
|
||||
});
|
||||
}
|
||||
|
||||
async requestAttributeSchemaSync(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
@@ -682,18 +688,18 @@ export class ProductEditorService {
|
||||
platformId,
|
||||
);
|
||||
const selectedCategoryId =
|
||||
input.profile.platform_product_category_id === undefined
|
||||
? profile.platform_product_category_id
|
||||
: input.profile.platform_product_category_id;
|
||||
input.profile.store_product_category_id === undefined
|
||||
? profile.store_product_category_id
|
||||
: input.profile.store_product_category_id;
|
||||
const selectedCategory = selectedCategoryId
|
||||
? await this.requireActiveCategory(
|
||||
transaction,
|
||||
platformId,
|
||||
input.profile.target_store_id,
|
||||
selectedCategoryId,
|
||||
)
|
||||
: null;
|
||||
const categoryChanged =
|
||||
selectedCategoryId !== profile.platform_product_category_id;
|
||||
selectedCategoryId !== profile.store_product_category_id;
|
||||
const categorySource = selectedCategory
|
||||
? categoryChanged
|
||||
? 'manual'
|
||||
@@ -758,7 +764,7 @@ export class ProductEditorService {
|
||||
}>(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
|
||||
tags = $5::jsonb, platform_product_category_id = $6,
|
||||
tags = $5::jsonb, store_product_category_id = $6,
|
||||
platform_type_id = $7, category_source = $8,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $9 AND product_id = $10 AND platform_id = $11 AND revision = $12
|
||||
@@ -993,6 +999,7 @@ export class ProductEditorService {
|
||||
productId,
|
||||
profile,
|
||||
platformId,
|
||||
targetStoreId: input.profile.target_store_id,
|
||||
categoryId: selectedCategory?.id ?? null,
|
||||
attributes: input.attributes,
|
||||
retainedVariantIds: retainedIds,
|
||||
@@ -1044,6 +1051,7 @@ export class ProductEditorService {
|
||||
productId: string;
|
||||
profile: ProfileRow;
|
||||
platformId: string;
|
||||
targetStoreId: string;
|
||||
categoryId: string | null;
|
||||
attributes: SaveProductEditorDraftRequest['attributes'];
|
||||
retainedVariantIds: string[];
|
||||
@@ -1065,15 +1073,15 @@ export class ProductEditorService {
|
||||
a.confirmation_status,
|
||||
coalesce(schema.name, a.platform_attribute_id) AS original_name
|
||||
FROM atlas.product_attributes a
|
||||
LEFT JOIN atlas.platform_product_category_attributes schema
|
||||
ON schema.platform_id = $1
|
||||
AND schema.platform_product_category_id IS NOT DISTINCT FROM $2::uuid
|
||||
LEFT JOIN atlas.store_product_category_attributes schema
|
||||
ON schema.store_id = $1
|
||||
AND schema.store_product_category_id IS NOT DISTINCT FROM $2::uuid
|
||||
AND schema.platform_attribute_id = a.platform_attribute_id
|
||||
AND schema.complex_id = a.complex_id
|
||||
WHERE a.profile_id = $3`,
|
||||
[
|
||||
input.platformId,
|
||||
input.profile.platform_product_category_id,
|
||||
input.profile.target_store_id,
|
||||
input.profile.store_product_category_id,
|
||||
input.profile.id,
|
||||
],
|
||||
);
|
||||
@@ -1088,7 +1096,7 @@ export class ProductEditorService {
|
||||
}
|
||||
const schema = await this.attributeSchemaForCategory(
|
||||
transaction,
|
||||
input.platformId,
|
||||
input.profile.target_store_id,
|
||||
input.categoryId,
|
||||
);
|
||||
const schemaByKey = new Map(
|
||||
@@ -1183,7 +1191,8 @@ export class ProductEditorService {
|
||||
}
|
||||
await this.assertAttributeValues(
|
||||
transaction,
|
||||
input.platformId,
|
||||
input.targetStoreId,
|
||||
input.categoryId,
|
||||
definition,
|
||||
attribute.values,
|
||||
);
|
||||
@@ -1213,7 +1222,7 @@ export class ProductEditorService {
|
||||
await transaction.query(
|
||||
`INSERT INTO atlas.product_unmapped_attributes
|
||||
(workspace_id, product_id, profile_id, platform_id, product_variant_id,
|
||||
original_platform_category_id, original_platform_attribute_id,
|
||||
original_store_product_category_id, original_platform_attribute_id,
|
||||
original_complex_id, original_name, values, language_code, reason)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4,
|
||||
$5, $6, $7, $8, $9::jsonb, $10, 'category_changed')`,
|
||||
@@ -1222,7 +1231,7 @@ export class ProductEditorService {
|
||||
input.profile.id,
|
||||
input.platformId,
|
||||
attribute.product_variant_id,
|
||||
input.profile.platform_product_category_id,
|
||||
input.profile.store_product_category_id,
|
||||
attribute.platform_attribute_id,
|
||||
attribute.complex_id,
|
||||
attribute.original_name,
|
||||
@@ -1261,7 +1270,8 @@ export class ProductEditorService {
|
||||
|
||||
private async assertAttributeValues(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
storeId: string,
|
||||
categoryId: string,
|
||||
definition: Awaited<
|
||||
ReturnType<ProductEditorService['attributeSchemaForCategory']>
|
||||
>[number],
|
||||
@@ -1307,10 +1317,11 @@ export class ProductEditorService {
|
||||
return;
|
||||
const dictionary = await transaction.query<{ value_id: string }>(
|
||||
`SELECT external_value_id AS value_id
|
||||
FROM atlas.platform_attribute_dictionary_values
|
||||
WHERE platform_id = $1 AND dictionary_id = $2 AND is_active
|
||||
AND external_value_id = ANY($3::text[])`,
|
||||
[platformId, definition.dictionary_id, values],
|
||||
FROM atlas.store_attribute_dictionary_values
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3 AND is_active
|
||||
AND external_value_id = ANY($4::text[])`,
|
||||
[storeId, categoryId, definition.dictionary_id, values],
|
||||
);
|
||||
if (
|
||||
new Set(dictionary.rows.map((item) => item.value_id)).size !==
|
||||
@@ -1325,7 +1336,7 @@ export class ProductEditorService {
|
||||
|
||||
private async attributeSchemaForCategory(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
storeId: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{
|
||||
@@ -1342,10 +1353,10 @@ export class ProductEditorService {
|
||||
}>(
|
||||
`SELECT platform_attribute_id, complex_id, name, value_type,
|
||||
is_required, is_collection, max_value_count, dictionary_id, is_aspect
|
||||
FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2
|
||||
FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
ORDER BY is_required DESC, is_aspect, name, platform_attribute_id, complex_id`,
|
||||
[platformId, categoryId],
|
||||
[storeId, categoryId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
@@ -1356,7 +1367,7 @@ export class ProductEditorService {
|
||||
profileId: string;
|
||||
platformId: string;
|
||||
key: string;
|
||||
scope: 'tree' | 'attributes' | 'dictionary';
|
||||
scope: 'attributes' | 'dictionary';
|
||||
path: string;
|
||||
categoryId?: string;
|
||||
dictionaryId?: string;
|
||||
@@ -1382,26 +1393,24 @@ export class ProductEditorService {
|
||||
input.profileId,
|
||||
input.platformId,
|
||||
);
|
||||
if (input.scope !== 'tree') {
|
||||
if (!input.categoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请先选择产品类目',
|
||||
);
|
||||
await this.requireActiveCategory(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
input.categoryId,
|
||||
if (!input.categoryId)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请先选择产品类目',
|
||||
);
|
||||
}
|
||||
await this.requireActiveCategory(
|
||||
transaction,
|
||||
profile.target_store_id,
|
||||
input.categoryId,
|
||||
);
|
||||
if (input.scope === 'dictionary') {
|
||||
const allowed = await transaction.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2
|
||||
`SELECT id FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND store_product_category_id = $2
|
||||
AND dictionary_id = $3 LIMIT 1`,
|
||||
[
|
||||
profile.platform_id,
|
||||
profile.target_store_id,
|
||||
input.categoryId,
|
||||
input.dictionaryId,
|
||||
],
|
||||
@@ -1443,14 +1452,12 @@ export class ProductEditorService {
|
||||
'当前平台暂不支持类目缓存同步',
|
||||
);
|
||||
const kinds = {
|
||||
tree: 'platform_category_tree_sync',
|
||||
attributes: 'platform_category_attribute_sync',
|
||||
dictionary: 'platform_attribute_dictionary_sync',
|
||||
attributes: 'store_category_attribute_sync',
|
||||
dictionary: 'store_attribute_dictionary_sync',
|
||||
} as const;
|
||||
const eventTypes = {
|
||||
tree: 'platform_category_tree_sync.requested',
|
||||
attributes: 'platform_category_attribute_sync.requested',
|
||||
dictionary: 'platform_attribute_dictionary_sync.requested',
|
||||
attributes: 'store_category_attribute_sync.requested',
|
||||
dictionary: 'store_attribute_dictionary_sync.requested',
|
||||
} as const;
|
||||
const created = await transaction.query<OperationRow>(
|
||||
`INSERT INTO atlas.operations
|
||||
@@ -1489,9 +1496,62 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ozon capture records breadcrumb names rather than a cache UUID. Resolve
|
||||
* that evidence only while a profile has no saved category; a later
|
||||
* recapture must never replace a user or previously persisted selection.
|
||||
*/
|
||||
private async applyCapturedCategory(
|
||||
transaction: DatabaseTransaction,
|
||||
product: ProductRow,
|
||||
profile: ProfileRow,
|
||||
): Promise<ProfileRow> {
|
||||
if (profile.store_product_category_id) return profile;
|
||||
const categories = latestProductAiSourceMaterial(product).categories;
|
||||
const sourcePath = normalizedCategoryPath(categories);
|
||||
if (!sourcePath) return profile;
|
||||
|
||||
const sourcePlatform = await transaction.query<{ code: string }>(
|
||||
`SELECT platform.code
|
||||
FROM atlas.collection_items item
|
||||
JOIN atlas.platforms platform ON platform.id = item.source_platform_id
|
||||
WHERE item.id = $1 AND item.deleted_at IS NULL`,
|
||||
[product.collection_item_id],
|
||||
);
|
||||
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return profile;
|
||||
|
||||
const category = await transaction.query<{
|
||||
id: string;
|
||||
external_type_id: string | null;
|
||||
}>(
|
||||
`SELECT id, external_type_id
|
||||
FROM atlas.store_product_categories
|
||||
WHERE store_id = $1 AND is_active
|
||||
AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2
|
||||
ORDER BY id
|
||||
LIMIT 1`,
|
||||
[profile.target_store_id, sourcePath],
|
||||
);
|
||||
const matched = category.rows[0];
|
||||
if (!matched) return profile;
|
||||
|
||||
const updated = await transaction.query<{ id: string }>(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET store_product_category_id = $1, platform_type_id = $2,
|
||||
category_source = 'source_capture', revision = revision + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $3 AND store_product_category_id IS NULL
|
||||
RETURNING id`,
|
||||
[matched.id, matched.external_type_id, profile.id],
|
||||
);
|
||||
return updated.rows[0]
|
||||
? this.requireProfile(transaction, profile.id)
|
||||
: profile;
|
||||
}
|
||||
|
||||
private async requireActiveCategory(
|
||||
transaction: DatabaseTransaction,
|
||||
platformId: string,
|
||||
storeId: string,
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{
|
||||
@@ -1499,15 +1559,15 @@ export class ProductEditorService {
|
||||
external_type_id: string | null;
|
||||
}>(
|
||||
`SELECT id, external_type_id
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||
[categoryId, platformId],
|
||||
FROM atlas.store_product_categories
|
||||
WHERE id = $1 AND store_id = $2 AND is_active`,
|
||||
[categoryId, storeId],
|
||||
);
|
||||
if (!result.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_INVALID',
|
||||
'类目不存在、已停用或不属于当前平台',
|
||||
'类目不存在、已停用或不属于当前店铺',
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -1849,15 +1909,15 @@ export class ProductEditorService {
|
||||
}
|
||||
|
||||
const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code,
|
||||
p.fulfillment, p.title, p.description, p.tags, p.platform_product_category_id,
|
||||
p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id,
|
||||
p.platform_type_id, p.category_source, category.name AS category_name,
|
||||
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||
s.currency_code
|
||||
FROM atlas.product_platform_profiles p
|
||||
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||
LEFT JOIN atlas.platform_product_categories category
|
||||
ON category.id = p.platform_product_category_id
|
||||
AND category.platform_id = p.platform_id`;
|
||||
LEFT JOIN atlas.store_product_categories category
|
||||
ON category.id = p.store_product_category_id
|
||||
AND category.store_id = p.target_store_id`;
|
||||
|
||||
type VariantSpecRow = {
|
||||
specification_values: unknown;
|
||||
@@ -2077,6 +2137,21 @@ function latestProductAiSourceMaterial(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedCategoryPath(
|
||||
categories: Array<{ name: string }>,
|
||||
): string | null {
|
||||
const names = categories
|
||||
.map((category) =>
|
||||
category.name
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLocaleLowerCase(),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return names.length ? names.join(' › ') : null;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
||||
@@ -49,6 +49,31 @@ async function createCollectionItem(title: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function seedStoreCategory(
|
||||
externalCategoryId: string,
|
||||
externalTypeId: string,
|
||||
name: string,
|
||||
path: string,
|
||||
) {
|
||||
return database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const profile = await tx.query<{ target_store_id: string }>(
|
||||
`SELECT target_store_id FROM atlas.product_platform_profiles
|
||||
WHERE id = $1`,
|
||||
[first.profileId],
|
||||
);
|
||||
const storeId = profile.rows[0]?.target_store_id;
|
||||
if (!storeId) throw new Error('test profile store is missing');
|
||||
const category = await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.store_product_categories
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6)
|
||||
RETURNING id`,
|
||||
[storeId, platformId, externalCategoryId, externalTypeId, name, path],
|
||||
);
|
||||
return { id: category.rows[0].id, storeId };
|
||||
});
|
||||
}
|
||||
|
||||
describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
@@ -117,6 +142,10 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'DELETE FROM atlas.product_platform_profiles WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.store_product_categories WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
);
|
||||
await tx.query(
|
||||
'DELETE FROM atlas.products WHERE workspace_id = $1',
|
||||
[workspaceId],
|
||||
@@ -137,19 +166,6 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
workspaceId,
|
||||
]);
|
||||
await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]);
|
||||
if (seededDictionaryIds.length) {
|
||||
await database.query(
|
||||
`DELETE FROM atlas.platform_attribute_dictionary_values
|
||||
WHERE platform_id = $1 AND dictionary_id = ANY($2::text[])`,
|
||||
[platformId, seededDictionaryIds],
|
||||
);
|
||||
}
|
||||
if (seededCategoryIds.length) {
|
||||
await database.query(
|
||||
'DELETE FROM atlas.platform_product_categories WHERE id = ANY($1::uuid[])',
|
||||
[seededCategoryIds],
|
||||
);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -834,44 +850,33 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
const materialDictionaryId = `m34-material-${dictionarySuffix}`;
|
||||
const colorDictionaryId = `m34-color-${dictionarySuffix}`;
|
||||
seededDictionaryIds.push(materialDictionaryId, colorDictionaryId);
|
||||
const categoryA = await database.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_categories
|
||||
(platform_id, external_category_id, external_type_id, name, path)
|
||||
VALUES ($1, $2, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱')
|
||||
RETURNING id`,
|
||||
[platformId, `m34-a-${randomUUID()}`],
|
||||
);
|
||||
const categoryB = await database.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_categories
|
||||
(platform_id, external_category_id, external_type_id, name, path)
|
||||
VALUES ($1, $2, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包')
|
||||
RETURNING id`,
|
||||
[platformId, `m34-b-${randomUUID()}`],
|
||||
);
|
||||
seededCategoryIds.push(categoryA.rows[0].id, categoryB.rows[0].id);
|
||||
await database.query(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id, platform_product_category_id, platform_attribute_id, name,
|
||||
const categoryA = await seedStoreCategory(`m34-a-${randomUUID()}`, 'type-a', 'M3.4 收纳箱', '家居 › 收纳 › M3.4 收纳箱');
|
||||
const categoryB = await seedStoreCategory(`m34-b-${randomUUID()}`, 'type-b', 'M3.4 背包', '箱包 › 背包 › M3.4 背包');
|
||||
seededCategoryIds.push(categoryA.id, categoryB.id);
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
||||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES
|
||||
($1, $2, 'material', '材质', 'dictionary', true, false, 1, $4, false),
|
||||
($1, $2, 'color', '颜色', 'dictionary', true, false, 1, $5, true),
|
||||
($1, $3, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'material', '材质', 'dictionary', true, false, 1, $5, false),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, 'color', '颜色', 'dictionary', true, false, 1, $6, true),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $4, 'capacity', '容量', 'integer', false, false, 1, NULL, false)`,
|
||||
[
|
||||
categoryA.storeId,
|
||||
platformId,
|
||||
categoryA.rows[0].id,
|
||||
categoryB.rows[0].id,
|
||||
categoryA.id,
|
||||
categoryB.id,
|
||||
materialDictionaryId,
|
||||
colorDictionaryId,
|
||||
],
|
||||
);
|
||||
await database.query(
|
||||
`INSERT INTO atlas.platform_attribute_dictionary_values
|
||||
(platform_id, dictionary_id, external_value_id, value)
|
||||
VALUES ($1, $2, 'cotton', '棉'),
|
||||
($1, $3, 'black', '黑色')`,
|
||||
[platformId, materialDictionaryId, colorDictionaryId],
|
||||
);
|
||||
));
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, 'cotton', '棉'),
|
||||
(current_setting('app.workspace_id')::uuid, $1, $2, $3, $5, 'black', '黑色')`,
|
||||
[categoryA.storeId, platformId, categoryA.id, materialDictionaryId, colorDictionaryId],
|
||||
));
|
||||
const categories = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/category-options?q=M3.4`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
@@ -879,7 +884,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
assert.equal(categories.response.status, 200);
|
||||
assert.ok(
|
||||
categories.body.data.some(
|
||||
(item: { id: string }) => item.id === categoryA.rows[0].id,
|
||||
(item: { id: string }) => item.id === categoryA.id,
|
||||
),
|
||||
);
|
||||
const before = await request(
|
||||
@@ -917,7 +922,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
title: before.body.data.profile.title,
|
||||
description: before.body.data.profile.description,
|
||||
tags: before.body.data.profile.tags,
|
||||
platform_product_category_id: categoryA.rows[0].id,
|
||||
store_product_category_id: categoryA.id,
|
||||
category_source: 'manual',
|
||||
},
|
||||
attributes: [
|
||||
@@ -958,7 +963,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
assert.equal(saved.response.status, 200);
|
||||
const schema = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-schema?category_id=${categoryA.rows[0].id}`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-schema?category_id=${categoryA.id}`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(schema.response.status, 200);
|
||||
@@ -970,7 +975,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
true,
|
||||
);
|
||||
const dictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.rows[0].id}`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${materialDictionaryId}?category_id=${categoryA.id}`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.deepEqual(dictionary.body.data.items, [
|
||||
@@ -984,7 +989,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
assert.equal(
|
||||
afterSave.body.data.profile.category.id,
|
||||
categoryA.rows[0].id,
|
||||
categoryA.id,
|
||||
);
|
||||
assert.equal(afterSave.body.data.profile.category.name, 'M3.4 收纳箱');
|
||||
assert.equal(afterSave.body.data.attributes.length, 2);
|
||||
@@ -996,7 +1001,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
afterSave.body.data.source_material_state.overridden_fields,
|
||||
profile: {
|
||||
...draft.profile,
|
||||
platform_product_category_id: categoryB.rows[0].id,
|
||||
store_product_category_id: categoryB.id,
|
||||
},
|
||||
attributes: afterSave.body.data.attributes.map(
|
||||
(attribute: any) => ({
|
||||
@@ -1045,24 +1050,83 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('M3.5 为类目树、类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
|
||||
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
|
||||
const suffix = randomUUID();
|
||||
const captured = await seedStoreCategory(`m35-source-${suffix}`, 'source-type', 'M3.5 采集叶', 'M3.5 来源 › M3.5 采集叶');
|
||||
const manual = await seedStoreCategory(`m35-manual-${suffix}`, 'manual-type', 'M3.5 手动叶', 'M3.5 手动 › M3.5 手动叶');
|
||||
seededCategoryIds.push(captured.id, manual.id);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_items
|
||||
SET source_platform_id = $1,
|
||||
normalized_payload = $2::jsonb
|
||||
WHERE id = $3`,
|
||||
[
|
||||
platformId,
|
||||
JSON.stringify({
|
||||
common: {
|
||||
categories: [
|
||||
{ key: 'source-root', name: ' M3.5 来源 ' },
|
||||
{ key: 'source-leaf', name: 'M3.5 采集叶' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
first.collectionItemId,
|
||||
],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET store_product_category_id = NULL, platform_type_id = NULL,
|
||||
category_source = NULL
|
||||
WHERE id = $1`,
|
||||
[first.profileId],
|
||||
);
|
||||
});
|
||||
const resolved = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(resolved.response.status, 200);
|
||||
assert.equal(
|
||||
resolved.body.data.profile.category.id,
|
||||
captured.id,
|
||||
);
|
||||
assert.equal(
|
||||
resolved.body.data.profile.category.source,
|
||||
'source_capture',
|
||||
);
|
||||
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET store_product_category_id = $1, platform_type_id = 'manual-type',
|
||||
category_source = 'manual'
|
||||
WHERE id = $2`,
|
||||
[manual.id, first.profileId],
|
||||
),
|
||||
);
|
||||
const preserved = await request(`/api/products/${first.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(preserved.response.status, 200);
|
||||
assert.equal(
|
||||
preserved.body.data.profile.category.id,
|
||||
manual.id,
|
||||
);
|
||||
assert.equal(preserved.body.data.profile.category.source, 'manual');
|
||||
});
|
||||
|
||||
it('M3.5 为类目属性和关键词字典搜索创建无密钥 outbox 任务', async () => {
|
||||
const dictionaryId = `m35-dictionary-${randomUUID()}`;
|
||||
const category = await database.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_categories
|
||||
(platform_id, external_category_id, external_type_id, name, path)
|
||||
VALUES ($1, $2, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目')
|
||||
RETURNING id`,
|
||||
[platformId, `m35-category-${randomUUID()}`],
|
||||
);
|
||||
seededCategoryIds.push(category.rows[0].id);
|
||||
const category = await seedStoreCategory(`m35-category-${randomUUID()}`, 'm35-type', 'M3.5 测试类目', '测试 › M3.5 测试类目');
|
||||
seededCategoryIds.push(category.id);
|
||||
seededDictionaryIds.push(dictionaryId);
|
||||
await database.query(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id, platform_product_category_id, platform_attribute_id, name,
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, platform_attribute_id, name,
|
||||
value_type, is_required, is_collection, max_value_count, dictionary_id, is_aspect)
|
||||
VALUES ($1, $2, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $3, false)`,
|
||||
[platformId, category.rows[0].id, dictionaryId],
|
||||
);
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, 'm35-color', 'M3.5 颜色', 'dictionary', false, false, 1, $4, false)`,
|
||||
[category.storeId, platformId, category.id, dictionaryId],
|
||||
));
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const profile = await tx.query<{ target_store_id: string }>(
|
||||
'SELECT target_store_id FROM atlas.product_platform_profiles WHERE id = $1',
|
||||
@@ -1082,20 +1146,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
],
|
||||
);
|
||||
});
|
||||
const tree = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/category-syncs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(tree.response.status, 202);
|
||||
assert.equal(tree.body.data.operation.kind, 'platform_category_tree_sync');
|
||||
const schema = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-schema-syncs?category_id=${category.rows[0].id}`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-schema-syncs?category_id=${category.id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
@@ -1105,9 +1157,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(schema.response.status, 202);
|
||||
assert.equal(schema.body.data.operation.kind, 'platform_category_attribute_sync');
|
||||
assert.equal(schema.body.data.operation.kind, 'store_category_attribute_sync');
|
||||
const dictionary = await request(
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.rows[0].id}&q=%E9%BB%91%E8%89%B2`,
|
||||
`/api/product-platform-profiles/${first.profileId}/attribute-dictionaries/${dictionaryId}/syncs?category_id=${category.id}&q=%E9%BB%91%E8%89%B2`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
@@ -1117,21 +1169,20 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
},
|
||||
);
|
||||
assert.equal(dictionary.response.status, 202);
|
||||
assert.equal(dictionary.body.data.operation.kind, 'platform_attribute_dictionary_sync');
|
||||
assert.equal(dictionary.body.data.operation.kind, 'store_attribute_dictionary_sync');
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const events = await tx.query<{ event_type: string; event_payload: unknown }>(
|
||||
`SELECT event_type, event_payload FROM atlas.outbox_events
|
||||
WHERE aggregate_id = $1 AND event_type LIKE 'platform_%_sync.requested'
|
||||
WHERE aggregate_id = $1 AND event_type LIKE 'store_%_sync.requested'
|
||||
ORDER BY created_at`,
|
||||
[first.profileId],
|
||||
);
|
||||
assert.equal(events.rows.length, 3);
|
||||
assert.equal(events.rows.length, 2);
|
||||
assert.deepEqual(
|
||||
events.rows.map((event) => event.event_type),
|
||||
[
|
||||
'platform_category_tree_sync.requested',
|
||||
'platform_category_attribute_sync.requested',
|
||||
'platform_attribute_dictionary_sync.requested',
|
||||
'store_category_attribute_sync.requested',
|
||||
'store_attribute_dictionary_sync.requested',
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
|
||||
@@ -67,7 +67,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
||||
title: snapshot.profile.title,
|
||||
description: snapshot.profile.description,
|
||||
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
|
||||
'source_capture' | 'ai_suggestion' | 'manual' | null,
|
||||
},
|
||||
@@ -430,7 +430,8 @@ const ProductEditPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm<EditorFormValues>();
|
||||
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 [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
|
||||
const [collectionRecord, setCollectionRecord] = useState<CollectionItemInspection | null>(null);
|
||||
@@ -480,7 +481,7 @@ const ProductEditPage = () => {
|
||||
if (!snapshot || !currentPlatform) return;
|
||||
let active = true;
|
||||
void productEditorApi
|
||||
.categoryOptions(snapshot.profile.id, currentPlatform.id)
|
||||
.categoryOptions(snapshot.profile.id, currentPlatform.id, undefined, selectedStoreId)
|
||||
.then((options) => {
|
||||
if (active) setCategoryOptions(options);
|
||||
})
|
||||
@@ -490,7 +491,7 @@ const ProductEditPage = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [catalogRefresh, currentPlatform, snapshot]);
|
||||
}, [catalogRefresh, currentPlatform, selectedStoreId, snapshot]);
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) {
|
||||
setAttributeSchema([]);
|
||||
@@ -499,7 +500,7 @@ const ProductEditPage = () => {
|
||||
}
|
||||
let active = true;
|
||||
void productEditorApi
|
||||
.attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId)
|
||||
.attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId, selectedStoreId)
|
||||
.then((schema) => {
|
||||
if (active) setAttributeSchema(schema);
|
||||
})
|
||||
@@ -509,7 +510,7 @@ const ProductEditPage = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [catalogRefresh, currentPlatform, selectedCategoryId, snapshot]);
|
||||
}, [catalogRefresh, currentPlatform, selectedCategoryId, selectedStoreId, snapshot]);
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||
const dictionaryIds = [
|
||||
@@ -534,7 +535,8 @@ const ProductEditPage = () => {
|
||||
currentPlatform.id,
|
||||
dictionaryId,
|
||||
undefined,
|
||||
selectedCategoryId
|
||||
selectedCategoryId,
|
||||
selectedStoreId,
|
||||
),
|
||||
] as const
|
||||
)
|
||||
@@ -551,7 +553,7 @@ const ProductEditPage = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [attributeSchema, catalogRefresh, currentPlatform, selectedCategoryId, snapshot]);
|
||||
}, [attributeSchema, catalogRefresh, currentPlatform, selectedCategoryId, selectedStoreId, snapshot]);
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform) return;
|
||||
let active = true;
|
||||
@@ -784,7 +786,8 @@ const ProductEditPage = () => {
|
||||
currentPlatform.id,
|
||||
dictionaryId,
|
||||
query,
|
||||
selectedCategoryId
|
||||
selectedCategoryId,
|
||||
selectedStoreId,
|
||||
);
|
||||
setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items }));
|
||||
};
|
||||
@@ -888,18 +891,31 @@ const ProductEditPage = () => {
|
||||
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
|
||||
name={['profile', 'platform_product_category_id']}
|
||||
name={['profile', 'store_product_category_id']}
|
||||
label="产品类目"
|
||||
className="span-full"
|
||||
rules={[{ required: true, message: '请选择产品类目' }]}
|
||||
getValueProps={(categoryId: string | null) => ({
|
||||
value: categoryId
|
||||
? (categories.paths.get(categoryId) ?? [categoryId])
|
||||
: [],
|
||||
})}
|
||||
getValueFromEvent={(value: string[]) =>
|
||||
value.at(-1) ?? null
|
||||
}
|
||||
>
|
||||
<Cascader
|
||||
className="editor-standard-cascader"
|
||||
showSearch
|
||||
placeholder="选择当前平台的产品类目"
|
||||
placeholder="选择当前店铺可发布的产品类目"
|
||||
options={categories.options}
|
||||
notFoundContent="类目缓存正在准备中,请稍后重试"
|
||||
getPopupContainer={(node) => node.parentElement ?? document.body}
|
||||
|
||||
@@ -39,30 +39,19 @@ export const productEditorApi = {
|
||||
'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[]>(
|
||||
`/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 },
|
||||
}
|
||||
),
|
||||
syncCategoryTree: (profileId: string, platformId: string) =>
|
||||
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) =>
|
||||
attributeSchema: (profileId: string, platformId: string, categoryId?: string | null, storeId?: string | null) =>
|
||||
api.get<ProductAttributeSchema[]>(
|
||||
`/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 },
|
||||
}
|
||||
),
|
||||
@@ -83,13 +72,15 @@ export const productEditorApi = {
|
||||
platformId: string,
|
||||
dictionaryId: string,
|
||||
search?: string,
|
||||
categoryId?: string | null
|
||||
categoryId?: string | null,
|
||||
storeId?: string | null,
|
||||
) =>
|
||||
api.get<{ items: ProductAttributeDictionaryValue[]; next_cursor: string | null }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-dictionaries/${encodeURIComponent(dictionaryId)}`,
|
||||
{
|
||||
params: {
|
||||
...(categoryId ? { category_id: categoryId } : {}),
|
||||
...(storeId ? { store_id: storeId } : {}),
|
||||
...(search ? { q: search } : {}),
|
||||
},
|
||||
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') {
|
||||
await materializeCollectionAssets(database, objectStorage, workerConfig.objectStorageBucket, job.data);
|
||||
} else if (
|
||||
job.name === 'platform_category_tree_sync.requested' ||
|
||||
job.name === 'platform_category_attribute_sync.requested' ||
|
||||
job.name === 'platform_attribute_dictionary_sync.requested'
|
||||
job.name === 'store_category_tree_sync.requested' ||
|
||||
job.name === 'store_category_attribute_sync.requested' ||
|
||||
job.name === 'store_attribute_dictionary_sync.requested'
|
||||
) {
|
||||
await syncOzonCatalog(database, job.data as CatalogSyncJob);
|
||||
}
|
||||
@@ -86,9 +86,9 @@ worker.on('failed', (job, error) => {
|
||||
if (job.name === 'store_credential_verification.requested') {
|
||||
void markUnavailable(database, job.data as StoreVerificationJob, error);
|
||||
} else if (
|
||||
job.name === 'platform_category_tree_sync.requested' ||
|
||||
job.name === 'platform_category_attribute_sync.requested' ||
|
||||
job.name === 'platform_attribute_dictionary_sync.requested'
|
||||
job.name === 'store_category_tree_sync.requested' ||
|
||||
job.name === 'store_category_attribute_sync.requested' ||
|
||||
job.name === 'store_attribute_dictionary_sync.requested'
|
||||
) {
|
||||
void markCatalogSyncUnavailable(database, job.data as CatalogSyncJob, error);
|
||||
}
|
||||
@@ -215,6 +215,39 @@ async function completeVerification(
|
||||
: '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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cache is store-scoped because Ozon returns it under a seller credential. */
|
||||
export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||
const source = await loadSource(database, job);
|
||||
if (!source) return cancelStaleOperation(database, job);
|
||||
@@ -63,11 +59,22 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||
try {
|
||||
let result: Record<string, unknown>;
|
||||
if (job.payload.scope === 'tree') {
|
||||
const nodes = await fetchOzonCategoryTree(source.platform_client_id, apiKey);
|
||||
result = await persistCategoryTree(database, job.payload.platform_id, nodes);
|
||||
const nodes = await fetchOzonCategoryTree(
|
||||
source.platform_client_id,
|
||||
apiKey,
|
||||
);
|
||||
result = await persistCategoryTree(
|
||||
database,
|
||||
job.workspaceId,
|
||||
job.payload.store_id,
|
||||
job.payload.platform_id,
|
||||
nodes,
|
||||
);
|
||||
} else {
|
||||
const category = await requireCategory(
|
||||
database,
|
||||
job.workspaceId,
|
||||
job.payload.store_id,
|
||||
job.payload.platform_id,
|
||||
job.payload.category_id,
|
||||
);
|
||||
@@ -88,6 +95,8 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||
);
|
||||
result = await persistAttributes(
|
||||
database,
|
||||
job.workspaceId,
|
||||
job.payload.store_id,
|
||||
job.payload.platform_id,
|
||||
job.payload.category_id ?? '',
|
||||
attributes,
|
||||
@@ -95,6 +104,8 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||
} else {
|
||||
const dictionary = await requireDictionaryAttribute(
|
||||
database,
|
||||
job.workspaceId,
|
||||
job.payload.store_id,
|
||||
job.payload.platform_id,
|
||||
job.payload.category_id,
|
||||
job.payload.dictionary_id,
|
||||
@@ -111,7 +122,10 @@ export async function syncOzonCatalog(database: Database, job: CatalogSyncJob) {
|
||||
);
|
||||
result = await persistDictionaryValues(
|
||||
database,
|
||||
job.workspaceId,
|
||||
job.payload.store_id,
|
||||
job.payload.platform_id,
|
||||
job.payload.category_id ?? '',
|
||||
job.payload.dictionary_id ?? '',
|
||||
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.store_secrets secret ON secret.id = $4 AND secret.store_id = store.id
|
||||
WHERE operation.id = $1 AND operation.kind IN (
|
||||
'platform_category_tree_sync',
|
||||
'platform_category_attribute_sync',
|
||||
'platform_attribute_dictionary_sync'
|
||||
'store_category_tree_sync',
|
||||
'store_category_attribute_sync',
|
||||
'store_attribute_dictionary_sync'
|
||||
)
|
||||
AND store.credential_status = 'valid'
|
||||
AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL
|
||||
@@ -237,34 +251,38 @@ async function failOperation(
|
||||
|
||||
async function requireCategory(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string | undefined,
|
||||
) {
|
||||
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
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE id = $1 AND platform_id = $2 AND is_active`,
|
||||
[categoryId, platformId],
|
||||
);
|
||||
FROM atlas.store_product_categories
|
||||
WHERE id = $1 AND store_id = $2 AND platform_id = $3 AND is_active`,
|
||||
[categoryId, storeId, platformId],
|
||||
));
|
||||
if (!result.rows[0]) throw new Error('Catalog sync category no longer exists');
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function requireDictionaryAttribute(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string | undefined,
|
||||
dictionaryId: string | undefined,
|
||||
) {
|
||||
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
|
||||
FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2 AND dictionary_id = $3
|
||||
FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND platform_id = $2 AND store_product_category_id = $3 AND dictionary_id = $4
|
||||
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');
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -281,6 +299,8 @@ type FlatCategory = {
|
||||
|
||||
async function persistCategoryTree(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
roots: OzonCategoryNode[],
|
||||
) {
|
||||
@@ -295,25 +315,25 @@ async function persistCategoryTree(
|
||||
).values(),
|
||||
];
|
||||
const syncTime = new Date();
|
||||
await database.transaction(async (tx) => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`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,
|
||||
name text, path text, is_active boolean, source_payload jsonb
|
||||
)
|
||||
)
|
||||
INSERT INTO atlas.platform_product_categories
|
||||
(platform_id, external_category_id, external_type_id, name, path,
|
||||
INSERT INTO atlas.store_product_categories
|
||||
(workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path,
|
||||
is_active, source_payload, synced_at)
|
||||
SELECT $1, external_category_id, external_type_id, name, path,
|
||||
is_active, source_payload, $3::timestamptz
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, external_category_id, external_type_id, name, path,
|
||||
is_active, source_payload, $4::timestamptz
|
||||
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,
|
||||
source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at,
|
||||
updated_at = now()`,
|
||||
[platformId, JSON.stringify(records), syncTime],
|
||||
[storeId, platformId, JSON.stringify(records), syncTime],
|
||||
);
|
||||
await tx.query(
|
||||
`WITH incoming AS (
|
||||
@@ -324,23 +344,23 @@ async function persistCategoryTree(
|
||||
), resolved AS (
|
||||
SELECT child.id AS child_id, parent.id AS parent_id
|
||||
FROM incoming
|
||||
JOIN atlas.platform_product_categories child
|
||||
ON child.platform_id = $1
|
||||
JOIN atlas.store_product_categories child
|
||||
ON child.store_id = $1
|
||||
AND child.external_category_id = incoming.external_category_id
|
||||
AND child.external_type_id = incoming.external_type_id
|
||||
LEFT JOIN atlas.platform_product_categories parent
|
||||
ON parent.platform_id = $1
|
||||
LEFT JOIN atlas.store_product_categories parent
|
||||
ON parent.store_id = $1
|
||||
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()
|
||||
FROM resolved WHERE category.id = resolved.child_id`,
|
||||
[platformId, JSON.stringify(records)],
|
||||
[storeId, JSON.stringify(records)],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.platform_product_categories SET is_active = false, updated_at = now()
|
||||
WHERE platform_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||
[platformId, syncTime],
|
||||
`UPDATE atlas.store_product_categories SET is_active = false, updated_at = now()
|
||||
WHERE store_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`,
|
||||
[storeId, syncTime],
|
||||
);
|
||||
});
|
||||
return {
|
||||
@@ -396,6 +416,8 @@ type CachedAttribute = {
|
||||
|
||||
async function persistAttributes(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
attributes: OzonCategoryAttribute[],
|
||||
@@ -403,27 +425,27 @@ async function persistAttributes(
|
||||
const records = attributes
|
||||
.map(toCachedAttribute)
|
||||
.filter((attribute): attribute is CachedAttribute => attribute !== null);
|
||||
await database.transaction(async (tx) => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`DELETE FROM atlas.platform_product_category_attributes
|
||||
WHERE platform_id = $1 AND platform_product_category_id = $2`,
|
||||
[platformId, categoryId],
|
||||
`DELETE FROM atlas.store_product_category_attributes
|
||||
WHERE store_id = $1 AND store_product_category_id = $2`,
|
||||
[storeId, categoryId],
|
||||
);
|
||||
if (!records.length) return;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_product_category_attributes
|
||||
(platform_id, platform_product_category_id, platform_attribute_id, complex_id,
|
||||
`INSERT INTO atlas.store_product_category_attributes
|
||||
(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,
|
||||
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_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,
|
||||
is_required boolean, is_collection boolean, max_value_count integer,
|
||||
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 };
|
||||
@@ -469,7 +491,10 @@ function mapAttributeType(value: string | null | undefined): CachedAttribute['va
|
||||
|
||||
async function persistDictionaryValues(
|
||||
database: Database,
|
||||
workspaceId: string,
|
||||
storeId: string,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
dictionaryId: string,
|
||||
values: OzonDictionaryValue[],
|
||||
) {
|
||||
@@ -483,17 +508,17 @@ async function persistDictionaryValues(
|
||||
!!value.external_value_id && !!value.value,
|
||||
);
|
||||
if (!records.length) return { dictionary_value_count: 0 };
|
||||
await database.query(
|
||||
`INSERT INTO atlas.platform_attribute_dictionary_values
|
||||
(platform_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
|
||||
SELECT $1, $2, external_value_id, value, true, source_payload, now()
|
||||
FROM jsonb_to_recordset($3::jsonb) AS value(
|
||||
await database.workspaceTransaction(workspaceId, (tx) => tx.query(
|
||||
`INSERT INTO atlas.store_attribute_dictionary_values
|
||||
(workspace_id, store_id, platform_id, store_product_category_id, dictionary_id, external_value_id, value, is_active, source_payload, synced_at)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, external_value_id, value, true, source_payload, now()
|
||||
FROM jsonb_to_recordset($5::jsonb) AS value(
|
||||
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,
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user