feat(edit): 编辑页上报ozon 开发
This commit is contained in:
@@ -25,6 +25,8 @@ const environmentSchema = z.object({
|
||||
.enum(['development', 'test', 'production'])
|
||||
.default('development'),
|
||||
PRICING_ALLOW_DRAFT_RATES: z.enum(['true', 'false', 'auto']).default('auto'),
|
||||
OZON_PUBLISH_ENABLED: z.enum(['true', 'false', 'auto']).default('auto'),
|
||||
OBJECT_STORAGE_PROVIDER: z.enum(['local_minio', 'qiniu_kodo']).default('local_minio'),
|
||||
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
|
||||
API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'),
|
||||
WEB_ORIGIN: z.url().default('http://localhost:8100'),
|
||||
|
||||
@@ -91,7 +91,7 @@ export class MediaService {
|
||||
`INSERT INTO atlas.media_upload_intents
|
||||
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, 'local_minio',
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, $10,
|
||||
$3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
id,
|
||||
@@ -103,6 +103,7 @@ export class MediaService {
|
||||
input.checksum_sha256,
|
||||
expiresAt,
|
||||
userId,
|
||||
this.config.OBJECT_STORAGE_PROVIDER,
|
||||
],
|
||||
);
|
||||
const url = await getSignedUrl(
|
||||
@@ -219,7 +220,7 @@ export class MediaService {
|
||||
`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type, byte_size,
|
||||
checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio', $2, $3, $4, $5, $6, $7, $8)`,
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $9, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[
|
||||
assetId,
|
||||
intent.bucket,
|
||||
@@ -229,6 +230,7 @@ export class MediaService {
|
||||
inspected.checksum,
|
||||
inspected.width,
|
||||
inspected.height,
|
||||
intent.storage_provider,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -364,12 +366,12 @@ export class MediaService {
|
||||
`INSERT INTO atlas.media_assets
|
||||
(id, workspace_id, storage_provider, bucket, object_key, content_type,
|
||||
byte_size, checksum_sha256, width_px, height_px)
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio',
|
||||
VALUES ($1, current_setting('app.workspace_id')::uuid, $9,
|
||||
$2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING
|
||||
RETURNING id`,
|
||||
[assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType,
|
||||
image.bytes.length, checksum, image.width, image.height],
|
||||
image.bytes.length, checksum, image.width, image.height, this.config.OBJECT_STORAGE_PROVIDER],
|
||||
);
|
||||
assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productPublishPreflightRequestSchema,
|
||||
createProductPublishJobRequestSchema,
|
||||
requestProductAiTaskSchema,
|
||||
saveProductEditorDraftRequestSchema,
|
||||
saveProductSourceDescriptionRequestSchema,
|
||||
@@ -242,6 +243,30 @@ export class ProductEditorController {
|
||||
return result.body;
|
||||
}
|
||||
|
||||
/** 同一事务重新校验并创建首次发布批次。 */
|
||||
@Post('product-platform-profiles/:profileId/publish-jobs')
|
||||
async createPublishJob(@Param('profileId') profileId: string, @Body() body: unknown,
|
||||
@Headers('x-atlas-platform-id') platformId: string | undefined, @Headers('idempotency-key') key: string | undefined,
|
||||
@Headers('if-match') revision: string | undefined, @Req() request: AuthenticatedRequest,
|
||||
@CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
||||
const parsed = createProductPublishJobRequestSchema.safeParse(body);
|
||||
if (!parsed.success) throw new ApiError(422, 'VALIDATION_FAILED', '请选择 1–50 个不同的待上架 SKU');
|
||||
const result = await this.editor.createPublishJob(workspace.id, request.auth.userId, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId), requiredUuid('Idempotency-Key', key), parseEditorRevision(revision), parsed.data.variant_ids);
|
||||
response.status(result.status); return result.body;
|
||||
}
|
||||
|
||||
/** 恢复编辑页最近发布任务,刷新页面后也能查看进度。 */
|
||||
@Get('product-platform-profiles/:profileId/publish-jobs')
|
||||
async publishJobs(@Param('profileId') profileId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
|
||||
return {data: await this.editor.listPublishJobs(workspace.id, requiredUuid('profile_id', profileId), requiredUuid('X-Atlas-Platform-Id', platformId))};
|
||||
}
|
||||
|
||||
/** 查询当前租户及平台下的逐 SKU 发布结果。 */
|
||||
@Get('product-publish-jobs/:jobId')
|
||||
async publishJob(@Param('jobId') jobId: string, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
|
||||
return {data: await this.editor.readPublishJob(workspace.id, requiredUuid('publish_job_id', jobId), requiredUuid('X-Atlas-Platform-Id', platformId))};
|
||||
}
|
||||
|
||||
/** 幂等地创建所选类目属性定义的刷新任务。 */
|
||||
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||
async attributeSchemaSync(
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ProfileRow = {
|
||||
market_code: string | null;
|
||||
fulfillment: string;
|
||||
title: string | null;
|
||||
title_mode: 'common' | 'variant';
|
||||
description: string | null;
|
||||
tags: unknown;
|
||||
platform_product_category_id: string | null;
|
||||
@@ -62,7 +63,7 @@ export type OperationRow = {
|
||||
|
||||
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
|
||||
export const profileSelect = `SELECT p.id, p.product_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.title_mode, p.description, p.tags, p.platform_product_category_id,
|
||||
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,
|
||||
|
||||
@@ -382,6 +382,10 @@ export class ProductEditorPublishPreflightOperations {
|
||||
[profileId, variantIds],
|
||||
);
|
||||
const used = new Map<string, UsedDictionary>();
|
||||
// 内建品牌等字典字段也需要平台候选值,不能绕过发布目录有效期检查。
|
||||
for (const definition of schema.filter(item => item.editor_field && item.value_type === 'dictionary' && item.dictionary_id)) {
|
||||
used.set(definition.attribute_id, {attribute_id: definition.attribute_id, platform_attribute_id: definition.platform_attribute_id, name: definition.name});
|
||||
}
|
||||
for (const attribute of attributes.rows) {
|
||||
if (!hasStringValue(attribute.values)) continue;
|
||||
const definition = definitions.get(
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import { Decimal } from 'decimal.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
OzonPublishItemSnapshot,
|
||||
ProductAttributeSchema,
|
||||
ProductPublishJob,
|
||||
ProductEditorValidation,
|
||||
} from '@atlas/contracts';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import type { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import type { ProfileRow, ProductRow } from './product-editor.models.js';
|
||||
|
||||
type Access = {
|
||||
profile: (
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
platformId: string,
|
||||
) => Promise<ProfileRow>;
|
||||
product: (
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
lock?: boolean,
|
||||
) => Promise<ProductRow>;
|
||||
schema: (
|
||||
tx: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
) => Promise<ProductAttributeSchema[]>;
|
||||
validate: (
|
||||
tx: DatabaseTransaction,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
ids: string[],
|
||||
) => Promise<ProductEditorValidation>;
|
||||
};
|
||||
type Listing = {
|
||||
id: string;
|
||||
variant_id: string;
|
||||
display_name: string | null;
|
||||
offer_id: string;
|
||||
store_id: string;
|
||||
status: string;
|
||||
title: string | null;
|
||||
tags: string[];
|
||||
description: string | null;
|
||||
selling_price: string;
|
||||
list_price: string | null;
|
||||
currency_code: string | null;
|
||||
depth: string;
|
||||
width: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
};
|
||||
type Attribute = {
|
||||
scope: string;
|
||||
product_variant_id: string | null;
|
||||
platform_attribute_id: string;
|
||||
complex_id: string;
|
||||
values: string[];
|
||||
};
|
||||
|
||||
/** 发布创建只读取本地事实,所有远端访问由 Worker 执行。 */
|
||||
export class ProductEditorPublishOperations {
|
||||
constructor(
|
||||
private readonly database: Database,
|
||||
private readonly idempotency: IdempotencyService,
|
||||
private readonly access: Access,
|
||||
private readonly enabled: boolean,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
key: string,
|
||||
expected: { product: number; profile: number },
|
||||
ids: string[],
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
method: 'POST',
|
||||
path: `/api/product-platform-profiles/${profileId}/publish-jobs`,
|
||||
key,
|
||||
request: { platformId, expected, variant_ids: ids },
|
||||
},
|
||||
async (tx) => {
|
||||
if (!this.enabled)
|
||||
throw new ApiError(
|
||||
503,
|
||||
'OZON_PUBLISH_DISABLED',
|
||||
'生产上架尚未开启,请先完成店铺 VAT 与图片交付验收',
|
||||
);
|
||||
if (
|
||||
!ids.length ||
|
||||
ids.length > 50 ||
|
||||
new Set(ids).size !== ids.length
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'本次最多上架 50 个 SKU,请使用部分上架分批提交',
|
||||
);
|
||||
const initial = await this.access.profile(
|
||||
tx,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const product = await this.access.product(
|
||||
tx,
|
||||
initial.product_id,
|
||||
true,
|
||||
);
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.product_platform_profiles WHERE id = $1 FOR UPDATE',
|
||||
[profileId],
|
||||
);
|
||||
const profile = await this.access.profile(
|
||||
tx,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
if (
|
||||
Number(product.revision) !== expected.product ||
|
||||
Number(profile.revision) !== expected.profile
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'VERSION_CONFLICT',
|
||||
'商品资料已更新,请重新加载后上架',
|
||||
);
|
||||
const store = await tx.query<{ code: string }>(
|
||||
`SELECT p.code FROM atlas.stores s JOIN atlas.platforms p ON p.id=s.platform_id WHERE s.id=$1 AND s.platform_id=$2 AND s.deleted_at IS NULL AND s.status='active' AND s.credential_status='valid' FOR SHARE OF s`,
|
||||
[profile.target_store_id, platformId],
|
||||
);
|
||||
if (store.rows[0]?.code.toUpperCase() !== 'OZON')
|
||||
throw new ApiError(
|
||||
422,
|
||||
'STORE_UNAVAILABLE',
|
||||
'当前 Ozon 店铺不可用,请检查授权',
|
||||
);
|
||||
const category = await tx.query<{
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
}>(
|
||||
`SELECT external_category_id, external_type_id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active FOR SHARE`,
|
||||
[profile.platform_product_category_id, platformId],
|
||||
);
|
||||
if (!category.rows[0])
|
||||
throw new ApiError(
|
||||
422,
|
||||
'CATEGORY_REQUIRED',
|
||||
'请选择当前有效的 Ozon 产品类目',
|
||||
);
|
||||
const listings = await tx.query<Listing>(
|
||||
`SELECT l.id, v.id AS variant_id, v.display_name, l.offer_id, l.store_id, l.status, l.title, l.tags, l.description, l.selling_price::text, l.list_price::text, l.currency_code,
|
||||
coalesce(v.package_length_mm,p.default_package_length_mm)::text AS depth,
|
||||
coalesce(v.package_width_mm,p.default_package_width_mm)::text AS width,
|
||||
coalesce(v.package_height_mm,p.default_package_height_mm)::text AS height,
|
||||
coalesce(v.shipping_weight_g,p.default_shipping_weight_g)::text AS weight
|
||||
FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id JOIN atlas.products p ON p.id=v.product_id
|
||||
WHERE l.profile_id=$1 AND v.product_id=$2 AND v.deleted_at IS NULL AND v.id=ANY($3::uuid[]) ORDER BY v.sort_order,v.id FOR UPDATE OF l,v`,
|
||||
[profileId, product.id, ids],
|
||||
);
|
||||
if (listings.rows.length !== ids.length)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'所选 SKU 不属于当前商品或已经删除',
|
||||
);
|
||||
if (listings.rows.some((l) => l.status === 'imported'))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'SKU_ALREADY_IMPORTED',
|
||||
'所选 SKU 已在当前店铺导入,请前往在线商品查看',
|
||||
);
|
||||
if (listings.rows.some((l) => l.status === 'submitting'))
|
||||
throw new ApiError(
|
||||
409,
|
||||
'SKU_IMPORT_IN_PROGRESS',
|
||||
'所选 SKU 正在提交或等待平台对账,请等待任务完成',
|
||||
);
|
||||
if (
|
||||
listings.rows.some(
|
||||
(l) => l.store_id !== profile.target_store_id,
|
||||
)
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'PUBLISH_STORE_CHANGED',
|
||||
'SKU 店铺与目标店铺不一致,请保存草稿后重试',
|
||||
);
|
||||
const validation = await this.access.validate(
|
||||
tx,
|
||||
profileId,
|
||||
platformId,
|
||||
ids,
|
||||
);
|
||||
if (!validation.ready)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
'请完善待上架商品资料',
|
||||
[
|
||||
...validation.profile.issues,
|
||||
...validation.variants.flatMap((v) => v.issues),
|
||||
],
|
||||
);
|
||||
const schema = await this.access.schema(
|
||||
tx,
|
||||
platformId,
|
||||
profile.platform_product_category_id!,
|
||||
);
|
||||
const attributes = await tx.query<Attribute>(
|
||||
`SELECT scope, product_variant_id, platform_attribute_id, complex_id, values FROM atlas.product_attributes WHERE profile_id=$1 AND confirmation_status='confirmed' AND (scope='common' OR product_variant_id=ANY($2::uuid[]))`,
|
||||
[profileId, ids],
|
||||
);
|
||||
const dictionaries = await tx.query<{
|
||||
attribute_id: string;
|
||||
external_value_id: string;
|
||||
value: string;
|
||||
}>(
|
||||
`SELECT attribute_id, external_value_id, value FROM atlas.platform_catalog_dictionary_values WHERE attribute_id=ANY($1::uuid[]) AND is_active`,
|
||||
[
|
||||
schema
|
||||
.filter((d) => d.dictionary_id)
|
||||
.map((d) => d.attribute_id),
|
||||
],
|
||||
);
|
||||
const builtinDictionaryIds = schema
|
||||
.filter(
|
||||
(d) =>
|
||||
d.editor_field &&
|
||||
d.value_type === 'dictionary' &&
|
||||
d.dictionary_id,
|
||||
)
|
||||
.map((d) => d.attribute_id);
|
||||
if (builtinDictionaryIds.length) {
|
||||
const states = await tx.query<{ usable: boolean }>(
|
||||
`SELECT state.last_success_at IS NOT NULL AND state.last_success_at >= now()-make_interval(days=>policy.dictionary_expire_days) AS usable FROM unnest($2::uuid[]) used(id) JOIN atlas.platform_catalog_refresh_policies policy ON policy.platform_id=$1 LEFT JOIN atlas.platform_catalog_resource_states state ON state.platform_id=$1 AND state.scope_key='dictionary:' || used.id::text`,
|
||||
[platformId, builtinDictionaryIds],
|
||||
);
|
||||
if (
|
||||
states.rows.length !== builtinDictionaryIds.length ||
|
||||
states.rows.some((s) => !s.usable)
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
'品牌等内建字段的 Ozon 候选值需要更新,请重新预检',
|
||||
);
|
||||
}
|
||||
const snapshots: OzonPublishItemSnapshot[] = [];
|
||||
for (const listing of listings.rows) {
|
||||
const media = await tx.query<
|
||||
OzonPublishItemSnapshot['media'][number]
|
||||
>(
|
||||
`SELECT a.id AS asset_id,a.checksum_sha256,a.bucket,a.object_key,a.storage_provider,a.content_type FROM atlas.product_variant_listing_media m JOIN atlas.media_assets a ON a.id=m.asset_id WHERE m.listing_id=$1 ORDER BY m.sort_order`,
|
||||
[listing.id],
|
||||
);
|
||||
if (!['not_listed', 'failed'].includes(listing.status))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'SKU_NOT_PUBLISHABLE',
|
||||
'所选 SKU 当前状态不允许首次上架',
|
||||
);
|
||||
if (
|
||||
!media.rows.length ||
|
||||
media.rows.some(
|
||||
(a) =>
|
||||
![
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
].includes(a.content_type),
|
||||
)
|
||||
)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_IMAGE_INVALID',
|
||||
'SKU 图片必须为 JPG、PNG 或 WebP',
|
||||
);
|
||||
const mapped: OzonPublishItemSnapshot['attributes'] = [];
|
||||
for (const definition of schema) {
|
||||
if (
|
||||
definition.editor_field === 'category' ||
|
||||
definition.editor_field === 'rich_content'
|
||||
)
|
||||
continue;
|
||||
let values: string[];
|
||||
if (definition.editor_field) {
|
||||
const fields = {
|
||||
brand: product.brand,
|
||||
model: product.model,
|
||||
tags: (listing.tags?.length
|
||||
? listing.tags
|
||||
: profile.tags) as string[],
|
||||
description:
|
||||
listing.description ?? profile.description,
|
||||
};
|
||||
const field = fields[definition.editor_field];
|
||||
values = Array.isArray(field)
|
||||
? field
|
||||
: field
|
||||
? [field]
|
||||
: [];
|
||||
} else
|
||||
values =
|
||||
attributes.rows.find(
|
||||
(a) =>
|
||||
a.platform_attribute_id ===
|
||||
definition.platform_attribute_id &&
|
||||
a.complex_id ===
|
||||
definition.complex_id &&
|
||||
(definition.is_aspect
|
||||
? a.product_variant_id ===
|
||||
listing.variant_id
|
||||
: a.scope === 'common'),
|
||||
)?.values ?? [];
|
||||
values = values
|
||||
.filter((v) => typeof v === 'string' && v.trim())
|
||||
.map((v) => v.trim());
|
||||
if (values.length > definition.max_value_count)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
`${definition.name}最多 ${definition.max_value_count} 个值`,
|
||||
[
|
||||
{
|
||||
field: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
|
||||
code: 'ATTRIBUTE_VALUE_LIMIT',
|
||||
message: `${definition.name}值数量超限`,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (!values.length) continue;
|
||||
mapped.push({
|
||||
id: numberId(definition.platform_attribute_id),
|
||||
complex_id: Number(definition.complex_id),
|
||||
values: values.map((value) => {
|
||||
if (definition.value_type !== 'dictionary')
|
||||
return { value };
|
||||
const entry = dictionaries.rows.find(
|
||||
(d) =>
|
||||
d.attribute_id ===
|
||||
definition.attribute_id &&
|
||||
(definition.editor_field
|
||||
? d.value === value ||
|
||||
d.external_value_id === value
|
||||
: d.external_value_id === value),
|
||||
);
|
||||
if (!entry)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VALIDATION_FAILED',
|
||||
`${definition.name}未匹配有效 Ozon 候选值`,
|
||||
[
|
||||
{
|
||||
field: definition.editor_field
|
||||
? `common.${definition.editor_field}`
|
||||
: `variants.${listing.variant_id}.attributes.${definition.platform_attribute_id}`,
|
||||
code: 'ATTRIBUTE_DICTIONARY_VALUE_INVALID',
|
||||
message: `${definition.name}未匹配有效 Ozon 候选值`,
|
||||
},
|
||||
],
|
||||
);
|
||||
return {
|
||||
dictionary_value_id: numberId(
|
||||
entry.external_value_id,
|
||||
),
|
||||
value: entry.value,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
snapshots.push({
|
||||
offer_id: listing.offer_id,
|
||||
name: (profile.title_mode === 'variant'
|
||||
? listing.title
|
||||
: profile.title)!.trim(),
|
||||
description_category_id: numberId(
|
||||
category.rows[0].external_category_id,
|
||||
),
|
||||
type_id: numberId(category.rows[0].external_type_id),
|
||||
price: listing.selling_price,
|
||||
...(listing.list_price &&
|
||||
new Decimal(listing.list_price).greaterThan(
|
||||
listing.selling_price,
|
||||
)
|
||||
? { old_price: listing.list_price! }
|
||||
: {}),
|
||||
currency_code: profile.currency_code,
|
||||
depth: Number(listing.depth),
|
||||
width: Number(listing.width),
|
||||
height: Number(listing.height),
|
||||
weight: Number(listing.weight),
|
||||
dimension_unit: 'mm',
|
||||
weight_unit: 'g',
|
||||
attributes: mapped,
|
||||
media: media.rows,
|
||||
});
|
||||
}
|
||||
const jobId = randomUUID();
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_publish_jobs(id,workspace_id,product_id,profile_id,platform_id,store_id,product_revision,profile_revision,item_count) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[
|
||||
jobId,
|
||||
workspaceId,
|
||||
product.id,
|
||||
profileId,
|
||||
platformId,
|
||||
profile.target_store_id,
|
||||
expected.product,
|
||||
expected.profile,
|
||||
ids.length,
|
||||
],
|
||||
);
|
||||
for (let i = 0; i < listings.rows.length; i++) {
|
||||
const listing = listings.rows[i];
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_publish_job_items(workspace_id,job_id,product_id,profile_id,platform_id,store_id,listing_id,variant_id,display_name,offer_id,snapshot) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
|
||||
[
|
||||
workspaceId,
|
||||
jobId,
|
||||
product.id,
|
||||
profileId,
|
||||
platformId,
|
||||
profile.target_store_id,
|
||||
listing.id,
|
||||
listing.variant_id,
|
||||
listing.display_name,
|
||||
listing.offer_id,
|
||||
JSON.stringify(snapshots[i]),
|
||||
],
|
||||
);
|
||||
}
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET status='submitting',last_error=NULL,revision=revision+1,updated_at=now() WHERE id=ANY($1::uuid[])`,
|
||||
[listings.rows.map((l) => l.id)],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.outbox_events(workspace_id,aggregate_type,aggregate_id,event_type,event_payload) VALUES($1,'product_publish_job',$2,'product_publish.requested',$3::jsonb)`,
|
||||
[
|
||||
workspaceId,
|
||||
jobId,
|
||||
JSON.stringify({ publish_job_id: jobId }),
|
||||
],
|
||||
);
|
||||
return {
|
||||
status: 202,
|
||||
body: {
|
||||
data: {
|
||||
publish_job: await readJob(tx, jobId, platformId),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
async read(workspaceId: string, id: string, platformId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, (tx) =>
|
||||
readJob(tx, id, platformId),
|
||||
);
|
||||
}
|
||||
async list(workspaceId: string, profileId: string, platformId: string) {
|
||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await this.access.profile(tx, profileId, platformId);
|
||||
const rows = await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.product_publish_jobs WHERE profile_id=$1 AND platform_id=$2 ORDER BY created_at DESC,id DESC LIMIT 10',
|
||||
[profileId, platformId],
|
||||
);
|
||||
const jobs = [];
|
||||
for (const row of rows.rows)
|
||||
jobs.push(await readJob(tx, row.id, platformId));
|
||||
return jobs;
|
||||
});
|
||||
}
|
||||
}
|
||||
async function readJob(
|
||||
tx: DatabaseTransaction,
|
||||
id: string,
|
||||
platformId: string,
|
||||
): Promise<ProductPublishJob> {
|
||||
const rows = await tx.query<ProductPublishJob>(
|
||||
`SELECT id,profile_id,store_id,status,item_count,created_at,completed_at FROM atlas.product_publish_jobs WHERE id=$1 AND platform_id=$2`,
|
||||
[id, platformId],
|
||||
);
|
||||
const job = rows.rows[0];
|
||||
if (!job)
|
||||
throw new ApiError(404, 'PUBLISH_JOB_NOT_FOUND', '发布任务不存在');
|
||||
const items = await tx.query<ProductPublishJob['items'][number]>(
|
||||
`SELECT id,variant_id,display_name,offer_id,status,platform_product_id,errors FROM atlas.product_publish_job_items WHERE job_id=$1 ORDER BY created_at,id`,
|
||||
[id],
|
||||
);
|
||||
return {
|
||||
...job,
|
||||
created_at: new Date(job.created_at).toISOString(),
|
||||
completed_at: job.completed_at
|
||||
? new Date(job.completed_at).toISOString()
|
||||
: null,
|
||||
items: items.rows,
|
||||
};
|
||||
}
|
||||
function numberId(value: string) {
|
||||
const n = Number(value);
|
||||
if (!Number.isSafeInteger(n) || n <= 0)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'OZON_ID_INVALID',
|
||||
'平台目录标识无效,请刷新目录',
|
||||
);
|
||||
return n;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
|
||||
import { bindEditorFields } from './product-editor.attribute-bindings.js';
|
||||
import { ProductEditorValidationOperations } from './product-editor.validation.js';
|
||||
import { ProductEditorPublishOperations } from './product-editor.publish.js';
|
||||
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
|
||||
import {
|
||||
capturedOzonCategorySignals,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class ProductEditorService {
|
||||
private readonly publishing: ProductEditorPublishOperations;
|
||||
private readonly pricing: PricingService;
|
||||
private readonly catalog: ProductEditorCatalogOperations;
|
||||
private readonly validationOperations: ProductEditorValidationOperations;
|
||||
@@ -71,6 +73,12 @@ export class ProductEditorService {
|
||||
this.attributeSchemaForCategory(...args),
|
||||
},
|
||||
);
|
||||
this.publishing = new ProductEditorPublishOperations(this.database, this.idempotency, {
|
||||
profile: (...args) => this.requireProfileForPlatform(...args),
|
||||
product: (...args) => this.requireProduct(...args),
|
||||
schema: (...args) => this.attributeSchemaForCategory(...args),
|
||||
validate: (...args) => this.validationOperations.validateTransaction(...args),
|
||||
}, config.OZON_PUBLISH_ENABLED === 'true' || ((config.OZON_PUBLISH_ENABLED ?? 'auto') === 'auto' && config.NODE_ENV !== 'production'));
|
||||
this.publishPreflightOperations =
|
||||
new ProductEditorPublishPreflightOperations(this.database, {
|
||||
requireProfileForPlatform: (...args) =>
|
||||
@@ -451,6 +459,7 @@ export class ProductEditorService {
|
||||
},
|
||||
available_target_stores: stores.rows,
|
||||
title: profile.title,
|
||||
title_mode: profile.title_mode,
|
||||
description: profile.description,
|
||||
tags: Array.isArray(profile.tags)
|
||||
? profile.tags.filter(
|
||||
@@ -586,6 +595,12 @@ export class ProductEditorService {
|
||||
);
|
||||
}
|
||||
|
||||
createPublishJob(workspaceId: string, userId: string, profileId: string, platformId: string, key: string, expected: { product: number; profile: number }, ids: string[]) {
|
||||
return this.publishing.create(workspaceId, userId, profileId, platformId, key, expected, ids);
|
||||
}
|
||||
readPublishJob(workspaceId: string, id: string, platformId: string) { return this.publishing.read(workspaceId, id, platformId); }
|
||||
listPublishJobs(workspaceId: string, profileId: string, platformId: string) { return this.publishing.list(workspaceId, profileId, platformId); }
|
||||
|
||||
/** 从平台级缓存查询当前类型属性的字典值。 */
|
||||
async attributeDictionary(
|
||||
workspaceId: string,
|
||||
@@ -757,6 +772,12 @@ export class ProductEditorService {
|
||||
],
|
||||
);
|
||||
}
|
||||
const lockedListings = await transaction.query<{ product_variant_id: string; status: string }>(
|
||||
`SELECT product_variant_id,status FROM atlas.product_variant_platform_listings WHERE product_id=$1 AND status IN ('imported','submitting')`, [productId]);
|
||||
if (lockedListings.rows.length && input.common.model !== product.model) throw new ApiError(409, 'PRODUCT_MODEL_LOCKED', '商品已有 SKU 导入或正在导入,型号不可修改');
|
||||
const profileLocked = await transaction.query(`SELECT id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND status='submitting' LIMIT 1`, [profile.id]);
|
||||
if (profileLocked.rows.length && input.profile.target_store_id !== profile.target_store_id) throw new ApiError(409, 'PUBLISH_STORE_LOCKED', '当前商品有 SKU 正在导入或等待对账,暂不能更换目标店铺');
|
||||
if (lockedListings.rows.some(row => !input.variants.some(v => v.id === row.product_variant_id))) throw new ApiError(409, 'PUBLISH_VARIANT_LOCKED', '已导入或正在提交的 SKU 不可删除');
|
||||
this.assertConfirmedSpecsComplete(input);
|
||||
await this.ensureVariantIdsBelongToProduct(
|
||||
transaction,
|
||||
@@ -788,14 +809,14 @@ export class ProductEditorService {
|
||||
SET target_store_id = $1, fulfillment = $2, title = $3, description = $4,
|
||||
tags = $5::jsonb, platform_product_category_id = $6,
|
||||
store_product_category_id = $7, platform_type_id = $8,
|
||||
category_source = $9,
|
||||
category_source = $9, title_mode = $14,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE id = $10 AND product_id = $11 AND platform_id = $12 AND revision = $13
|
||||
RETURNING revision`,
|
||||
[
|
||||
input.profile.target_store_id,
|
||||
input.profile.fulfillment,
|
||||
input.profile.title,
|
||||
input.profile.title_mode === 'variant' ? null : input.profile.title,
|
||||
input.profile.description,
|
||||
JSON.stringify(input.profile.tags),
|
||||
selectedCategory?.id ?? null,
|
||||
@@ -806,6 +827,7 @@ export class ProductEditorService {
|
||||
productId,
|
||||
platformId,
|
||||
expected.profile,
|
||||
input.profile.title_mode ?? profile.title_mode,
|
||||
],
|
||||
);
|
||||
if (!updatedProfile.rows[0])
|
||||
@@ -919,14 +941,14 @@ export class ProductEditorService {
|
||||
SET offer_id = $1, store_id = $2, tags = $3::jsonb, description = $4,
|
||||
title = CASE WHEN $5::boolean THEN $6 ELSE title END,
|
||||
revision = revision + 1, updated_at = now()
|
||||
WHERE profile_id = $7 AND product_variant_id = $8 AND status <> 'imported'`,
|
||||
WHERE profile_id = $7 AND product_variant_id = $8 AND status NOT IN ('imported','submitting')`,
|
||||
[
|
||||
variant.offer_id,
|
||||
input.profile.target_store_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
variant.title !== undefined,
|
||||
variant.title ?? null,
|
||||
variant.title !== undefined || input.profile.title_mode === 'common',
|
||||
input.profile.title_mode === 'common' ? null : (variant.title ?? null),
|
||||
profile.id,
|
||||
variant.id,
|
||||
],
|
||||
@@ -1024,7 +1046,7 @@ export class ProductEditorService {
|
||||
variant.offer_id,
|
||||
JSON.stringify(variant.tags),
|
||||
variant.description,
|
||||
variant.title ?? null,
|
||||
input.profile.title_mode === 'common' ? null : (variant.title ?? null),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type ValidationAccess = {
|
||||
type VariantValidationRow = {
|
||||
id: string;
|
||||
display_name: string | null;
|
||||
title: string | null;
|
||||
listing_status: string;
|
||||
offer_id: string | null;
|
||||
selling_price: string | null;
|
||||
@@ -79,101 +80,117 @@ export class ProductEditorValidationOperations {
|
||||
return this.database.workspaceTransaction(
|
||||
workspaceId,
|
||||
async (transaction) => {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
return this.validateTransaction(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const product = await this.access.requireProduct(
|
||||
transaction,
|
||||
profile.product_id,
|
||||
);
|
||||
const variants = await this.readVariants(
|
||||
transaction,
|
||||
profile.id,
|
||||
profile.product_id,
|
||||
variantIds,
|
||||
);
|
||||
if (variantIds && variants.length !== new Set(variantIds).size)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'待发布 SKU 不属于当前商品,或已经被删除',
|
||||
);
|
||||
const attributes = await this.readAttributes(
|
||||
transaction,
|
||||
profile.id,
|
||||
variantIds,
|
||||
);
|
||||
const schemaUsable = profile.platform_product_category_id
|
||||
? await this.schemaUsable(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
profile.platform_product_category_id,
|
||||
)
|
||||
: false;
|
||||
const schema =
|
||||
profile.platform_product_category_id && schemaUsable
|
||||
? await this.access.attributeSchemaForCategory(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
profile.platform_product_category_id,
|
||||
)
|
||||
: [];
|
||||
|
||||
const dictionaryIssues = schema.length
|
||||
? await this.dictionaryIssues(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
schema,
|
||||
attributes,
|
||||
)
|
||||
: [];
|
||||
|
||||
const profileIssues = this.profileIssues(
|
||||
product,
|
||||
profile,
|
||||
schema,
|
||||
attributes,
|
||||
);
|
||||
profileIssues.push(
|
||||
...dictionaryIssues
|
||||
.filter((item) => item.scope === 'common')
|
||||
.map((item) => item.issue),
|
||||
);
|
||||
const variantResults = variants.map((variant) =>
|
||||
this.variantResult(
|
||||
variant,
|
||||
schema,
|
||||
attributes,
|
||||
dictionaryIssues
|
||||
.filter(
|
||||
(item) =>
|
||||
item.scope === 'variant' &&
|
||||
item.variant_id === variant.id,
|
||||
)
|
||||
.map((item) => item.issue),
|
||||
),
|
||||
);
|
||||
const publishableVariants = variantResults.filter(
|
||||
(variant) => variant.status !== 'not_publishable_again',
|
||||
);
|
||||
|
||||
return {
|
||||
ready:
|
||||
profileIssues.length === 0 &&
|
||||
publishableVariants.length > 0 &&
|
||||
publishableVariants.every((variant) => variant.ready),
|
||||
profile: {
|
||||
ready: profileIssues.length === 0,
|
||||
issues: profileIssues,
|
||||
},
|
||||
variants: variantResults,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建发布任务必须在持有商品锁的同一事务内重算,防止校验与快照读到不同版本。 */
|
||||
async validateTransaction(
|
||||
transaction: DatabaseTransaction,
|
||||
profileId: string,
|
||||
platformId: string,
|
||||
variantIds?: string[],
|
||||
): Promise<ProductEditorValidation> {
|
||||
const profile = await this.access.requireProfileForPlatform(
|
||||
transaction,
|
||||
profileId,
|
||||
platformId,
|
||||
);
|
||||
const product = await this.access.requireProduct(
|
||||
transaction,
|
||||
profile.product_id,
|
||||
);
|
||||
const variants = await this.readVariants(
|
||||
transaction,
|
||||
profile.id,
|
||||
profile.product_id,
|
||||
variantIds,
|
||||
);
|
||||
if (variantIds && variants.length !== new Set(variantIds).size)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PUBLISH_VARIANTS_INVALID',
|
||||
'待发布 SKU 不属于当前商品,或已经被删除',
|
||||
);
|
||||
const attributes = await this.readAttributes(
|
||||
transaction,
|
||||
profile.id,
|
||||
variantIds,
|
||||
);
|
||||
const schemaUsable = profile.platform_product_category_id
|
||||
? await this.schemaUsable(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
profile.platform_product_category_id,
|
||||
)
|
||||
: false;
|
||||
const schema =
|
||||
profile.platform_product_category_id && schemaUsable
|
||||
? await this.access.attributeSchemaForCategory(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
profile.platform_product_category_id,
|
||||
)
|
||||
: [];
|
||||
|
||||
const dictionaryIssues = schema.length
|
||||
? await this.dictionaryIssues(
|
||||
transaction,
|
||||
profile.platform_id,
|
||||
schema,
|
||||
attributes,
|
||||
)
|
||||
: [];
|
||||
|
||||
const profileIssues = this.profileIssues(
|
||||
product,
|
||||
profile,
|
||||
schema,
|
||||
attributes,
|
||||
);
|
||||
profileIssues.push(
|
||||
...dictionaryIssues
|
||||
.filter((item) => item.scope === 'common')
|
||||
.map((item) => item.issue),
|
||||
);
|
||||
const variantResults = variants.map((variant) =>
|
||||
this.variantResult(
|
||||
variant,
|
||||
profile,
|
||||
schema,
|
||||
attributes,
|
||||
dictionaryIssues
|
||||
.filter(
|
||||
(item) =>
|
||||
item.scope === 'variant' &&
|
||||
item.variant_id === variant.id,
|
||||
)
|
||||
.map((item) => item.issue),
|
||||
),
|
||||
);
|
||||
const publishableVariants = variantResults.filter(
|
||||
(variant) => variant.status !== 'not_publishable_again',
|
||||
);
|
||||
|
||||
return {
|
||||
ready:
|
||||
profileIssues.length === 0 &&
|
||||
publishableVariants.length > 0 &&
|
||||
publishableVariants.every((variant) => variant.ready),
|
||||
profile: {
|
||||
ready: profileIssues.length === 0,
|
||||
issues: profileIssues,
|
||||
},
|
||||
variants: variantResults,
|
||||
};
|
||||
}
|
||||
|
||||
/** 草稿就绪校验不得把超过过期阈值的旧 schema 当成当前 Ozon 规则。 */
|
||||
private async schemaUsable(
|
||||
transaction: DatabaseTransaction,
|
||||
@@ -181,7 +198,7 @@ export class ProductEditorValidationOperations {
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await transaction.query<{ usable: boolean }>(
|
||||
`SELECT state.last_success_at IS NOT NULL
|
||||
`SELECT state.last_success_at IS NOT NULL AND state.is_complete
|
||||
AND state.last_success_at >= now() -
|
||||
make_interval(days => policy.attribute_expire_days) AS usable
|
||||
FROM atlas.platform_catalog_refresh_policies policy
|
||||
@@ -208,11 +225,23 @@ export class ProductEditorValidationOperations {
|
||||
field: 'common.source_title',
|
||||
message: '请填写中文商品名',
|
||||
});
|
||||
if (!profile.title?.trim())
|
||||
if (profile.title_mode === 'common' && !profile.title?.trim())
|
||||
issues.push({
|
||||
code: 'PUBLISH_TITLE_REQUIRED',
|
||||
field: 'profile.title',
|
||||
message: '请填写俄文发布标题',
|
||||
message: '请填写俄文标题',
|
||||
});
|
||||
if (profile.title && [...profile.title].length > 500)
|
||||
issues.push({
|
||||
code: 'TITLE_TOO_LONG',
|
||||
field: 'profile.title',
|
||||
message: '俄文标题最多 500 字符',
|
||||
});
|
||||
if (profile.fulfillment !== 'rfbs')
|
||||
issues.push({
|
||||
code: 'FULFILLMENT_UNSUPPORTED',
|
||||
field: 'profile.fulfillment',
|
||||
message: '首次上架当前仅支持跨境 rFBS',
|
||||
});
|
||||
if (!product.model?.trim())
|
||||
issues.push({
|
||||
@@ -256,6 +285,7 @@ export class ProductEditorValidationOperations {
|
||||
/** 计算单个 SKU 的发布资格与缺失项。 */
|
||||
private variantResult(
|
||||
variant: VariantValidationRow,
|
||||
profile: ProfileRow,
|
||||
schema: ProductAttributeSchema[],
|
||||
attributes: AttributeValidationRow[],
|
||||
dictionaryIssues: ProductEditorValidation['profile']['issues'] = [],
|
||||
@@ -272,12 +302,36 @@ export class ProductEditorValidationOperations {
|
||||
const issues: ProductEditorValidation['variants'][number]['issues'] = [
|
||||
...dictionaryIssues,
|
||||
];
|
||||
if (profile.title_mode === 'variant' && !variant.title?.trim())
|
||||
issues.push({
|
||||
code: 'PUBLISH_TITLE_REQUIRED',
|
||||
field: `variants.${variant.id}.title`,
|
||||
message: '请填写该 SKU 的俄文标题',
|
||||
});
|
||||
if (variant.listing_status === 'submitting')
|
||||
issues.push({
|
||||
code: 'SKU_IMPORT_IN_PROGRESS',
|
||||
field: `variants.${variant.id}.listing`,
|
||||
message: '该 SKU 正在首次上架,请等待当前任务完成',
|
||||
});
|
||||
if (variant.title && [...variant.title].length > 500)
|
||||
issues.push({
|
||||
code: 'TITLE_TOO_LONG',
|
||||
field: `variants.${variant.id}.title`,
|
||||
message: '俄文标题最多 500 字符',
|
||||
});
|
||||
if (variant.offer_id && [...variant.offer_id].length > 50)
|
||||
issues.push({
|
||||
code: 'OFFER_ID_TOO_LONG',
|
||||
field: `variants.${variant.id}.offer_id`,
|
||||
message: '卖家货号最多 50 字符',
|
||||
});
|
||||
if (Number(variant.media_count) > 30)
|
||||
issues.push({
|
||||
code: 'IMAGE_LIMIT_EXCEEDED',
|
||||
field: `variants.${variant.id}.media_asset_ids`,
|
||||
message: '每个 SKU 最多 30 张图片',
|
||||
});
|
||||
if (!variant.offer_id?.trim())
|
||||
issues.push({
|
||||
code: 'OFFER_ID_REQUIRED',
|
||||
@@ -290,7 +344,7 @@ export class ProductEditorValidationOperations {
|
||||
field: `variants.${variant.id}.pricing_selection`,
|
||||
message: '请按当前采购价和包装确认报价',
|
||||
});
|
||||
if (!variant.selling_price)
|
||||
if (!variant.selling_price || Number(variant.selling_price) <= 0)
|
||||
issues.push({
|
||||
code: 'PRICE_REQUIRED',
|
||||
field: `variants.${variant.id}.selling_price`,
|
||||
@@ -301,7 +355,15 @@ export class ProductEditorValidationOperations {
|
||||
!variant.package_length_mm ||
|
||||
!variant.package_width_mm ||
|
||||
!variant.package_height_mm ||
|
||||
!variant.shipping_weight_g
|
||||
!variant.shipping_weight_g ||
|
||||
![
|
||||
variant.package_length_mm,
|
||||
variant.package_width_mm,
|
||||
variant.package_height_mm,
|
||||
variant.shipping_weight_g,
|
||||
].every(
|
||||
(value) => Number.isInteger(Number(value)) && Number(value) > 0,
|
||||
)
|
||||
)
|
||||
issues.push({
|
||||
code: 'PACKAGE_SPEC_REQUIRED',
|
||||
@@ -354,7 +416,7 @@ export class ProductEditorValidationOperations {
|
||||
variantIds?: string[],
|
||||
) {
|
||||
const variants = await transaction.query<VariantValidationRow>(
|
||||
`SELECT v.id, v.display_name, listing.status AS listing_status,
|
||||
`SELECT v.id, v.display_name, listing.title, listing.status AS listing_status,
|
||||
listing.offer_id, listing.selling_price::text, listing.pricing_valid,
|
||||
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
|
||||
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
|
||||
@@ -369,7 +431,7 @@ export class ProductEditorValidationOperations {
|
||||
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
|
||||
WHERE v.product_id = $2 AND v.deleted_at IS NULL
|
||||
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
|
||||
GROUP BY v.id, v.display_name, listing.status, listing.offer_id,
|
||||
GROUP BY v.id, v.display_name, listing.title, listing.status, listing.offer_id,
|
||||
listing.selling_price, listing.pricing_valid, product.default_package_length_mm,
|
||||
product.default_package_width_mm, product.default_package_height_mm,
|
||||
product.default_shipping_weight_g, product.package_spec_status
|
||||
|
||||
@@ -2129,7 +2129,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('俄文发布标题为空时发布就绪校验提示必填,填写后移除提示', async () => {
|
||||
it('俄文标题按通用或逐 SKU 模式校验,并持久保存切换结果', async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const store = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
@@ -2148,7 +2148,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
[platformId, `editor-title-${randomUUID()}`],
|
||||
);
|
||||
});
|
||||
const created = await createCollectionItem('俄文发布标题校验商品');
|
||||
const created = await createCollectionItem('俄文标题校验商品');
|
||||
const entered = await request(
|
||||
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||||
{
|
||||
@@ -2189,6 +2189,119 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||||
),
|
||||
);
|
||||
|
||||
const beforeModeSwitch = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const snapshot = beforeModeSwitch.body.data;
|
||||
assert.equal(snapshot.profile.title_mode, 'common');
|
||||
const draft = {
|
||||
ai_source_material: snapshot.source_material,
|
||||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||||
common: {
|
||||
...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec,
|
||||
},
|
||||
profile: {
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
fulfillment: snapshot.profile.fulfillment,
|
||||
title: null as string | null,
|
||||
title_mode: 'variant',
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
platform_product_category_id: snapshot.profile.category.id,
|
||||
category_source: snapshot.profile.category.source,
|
||||
},
|
||||
variants: snapshot.variants.map((variant: any) => ({
|
||||
id: variant.id,
|
||||
client_key: null,
|
||||
display_name: variant.display_name,
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
title: null,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
package_spec: variant.package_spec,
|
||||
offer_id: variant.listing.offer_id,
|
||||
media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id),
|
||||
})),
|
||||
};
|
||||
const saveTitleMode = async (body: typeof draft, productRevision: number, profileRevision: number) =>
|
||||
request(`/api/products/${created.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${productRevision}:${profileRevision}"`,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const variantMode = await saveTitleMode(
|
||||
draft,
|
||||
snapshot.product.revision,
|
||||
snapshot.profile.revision,
|
||||
);
|
||||
assert.equal(variantMode.response.status, 200, JSON.stringify(variantMode.body));
|
||||
const blankVariant = await request(
|
||||
`/api/product-platform-profiles/${profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.ok(
|
||||
!blankVariant.body.data.profile.issues.some(
|
||||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
blankVariant.body.data.variants[0].issues.some(
|
||||
(issue: { code: string; field: string }) =>
|
||||
issue.code === 'PUBLISH_TITLE_REQUIRED' &&
|
||||
issue.field === `variants.${snapshot.variants[0].id}.title`,
|
||||
),
|
||||
);
|
||||
const afterVariantMode = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(afterVariantMode.body.data.profile.title_mode, 'variant');
|
||||
assert.equal(afterVariantMode.body.data.profile.title, null);
|
||||
const filledVariant = await saveTitleMode(
|
||||
{
|
||||
...draft,
|
||||
variants: [{ ...draft.variants[0], title: 'Название SKU' }],
|
||||
},
|
||||
afterVariantMode.body.data.product.revision,
|
||||
afterVariantMode.body.data.profile.revision,
|
||||
);
|
||||
assert.equal(filledVariant.response.status, 200, JSON.stringify(filledVariant.body));
|
||||
const readyTitle = await request(
|
||||
`/api/product-platform-profiles/${profileId}/validation`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.ok(
|
||||
!readyTitle.body.data.variants[0].issues.some(
|
||||
(issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED',
|
||||
),
|
||||
);
|
||||
const afterFilledVariant = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
const commonMode = await saveTitleMode(
|
||||
{
|
||||
...draft,
|
||||
profile: { ...draft.profile, title: '通用俄文标题', title_mode: 'common' },
|
||||
variants: [{ ...draft.variants[0], title: null }],
|
||||
},
|
||||
afterFilledVariant.body.data.product.revision,
|
||||
afterFilledVariant.body.data.profile.revision,
|
||||
);
|
||||
assert.equal(commonMode.response.status, 200, JSON.stringify(commonMode.body));
|
||||
const afterCommonMode = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(afterCommonMode.body.data.profile.title_mode, 'common');
|
||||
assert.equal(afterCommonMode.body.data.variants[0].title, null);
|
||||
});
|
||||
|
||||
it('包装重量模式独立保存,并允许 SKU 已确认重量继承通用尺寸', async () => {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type { ProductPublishJob } from '@atlas/contracts';
|
||||
import type { S3Client } from '@aws-sdk/client-s3';
|
||||
import { beforeAll, afterAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
import {
|
||||
runProductPublish,
|
||||
type PublishDependencies,
|
||||
} from '../../worker/src/product-publish.js';
|
||||
import {
|
||||
OzonRateLimitError,
|
||||
OzonRetryableError,
|
||||
} from '../../worker/src/ozon-client.js';
|
||||
|
||||
let app: NestExpressApplication,
|
||||
db: Database,
|
||||
base = '',
|
||||
workspace = '',
|
||||
token = '',
|
||||
platform = '',
|
||||
store = '',
|
||||
category = '';
|
||||
const storage = {} as S3Client;
|
||||
async function request(
|
||||
path: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
extra: Record<string, string> = {},
|
||||
) {
|
||||
const res = await fetch(`${base}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Atlas-Platform-Id': platform,
|
||||
...extra,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as any };
|
||||
}
|
||||
async function fixture(count = 2) {
|
||||
const created = await request(
|
||||
'/collection-items',
|
||||
'POST',
|
||||
{ title: '发布测试商品' },
|
||||
{ 'Idempotency-Key': randomUUID() },
|
||||
);
|
||||
assert.equal(created.status, 201, JSON.stringify(created));
|
||||
const product = created.body.data.product_id;
|
||||
const entered = await request(
|
||||
`/collection-items/${created.body.data.collection_item.id}/editor-contexts`,
|
||||
'POST',
|
||||
{},
|
||||
{ 'Idempotency-Key': randomUUID() },
|
||||
);
|
||||
assert.equal(entered.status, 201, JSON.stringify(entered));
|
||||
const profile = entered.body.data.profile_id;
|
||||
const ids: string[] = [];
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.products SET brand='Без бренда',model='TEST-MODEL',default_package_length_mm=240,default_package_width_mm=120,default_package_height_mm=60,default_shipping_weight_g=350,package_spec_status='confirmed' WHERE id=$1`,
|
||||
[product],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_platform_profiles SET title='Тестовый товар',fulfillment='rfbs',platform_product_category_id=$2,platform_type_id='1002' WHERE id=$1`,
|
||||
[profile, category],
|
||||
);
|
||||
const first = (
|
||||
await tx.query<{ id: string }>(
|
||||
'SELECT id FROM atlas.product_variants WHERE product_id=$1',
|
||||
[product],
|
||||
)
|
||||
).rows[0].id;
|
||||
ids.push(first);
|
||||
for (let i = 1; i < count; i++)
|
||||
ids.push(
|
||||
(
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_variants(workspace_id,product_id,display_name,sort_order) VALUES($1,$2,$3,$4) RETURNING id`,
|
||||
[workspace, product, `SKU ${i}`, i],
|
||||
)
|
||||
).rows[0].id,
|
||||
);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const variant = ids[i];
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_variant_platform_listings(workspace_id,product_id,profile_id,platform_id,product_variant_id,store_id) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING`,
|
||||
[workspace, product, profile, platform, variant, store],
|
||||
);
|
||||
const quote = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.pricing_quotes(workspace_id,store_id,platform_id,product_id,profile_id,product_variant_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,input_snapshot,status,confirmed_at) VALUES($1,$2,$3,$4,$5,$6,'rfbs',20,0.350,24,12,6,12,'{}','confirmed',now()) RETURNING id`,
|
||||
[workspace, store, platform, product, profile, variant],
|
||||
)
|
||||
).rows[0].id;
|
||||
const listing = (
|
||||
await tx.query<{ id: string }>(
|
||||
`UPDATE atlas.product_variant_platform_listings SET offer_id=$2,selling_price=1999.1234,list_price=2999.1234,currency_code='RUB',pricing_quote_id=$3,pricing_valid=true WHERE profile_id=$1 AND product_variant_id=$4 RETURNING id`,
|
||||
[profile, `TEST-${randomUUID()}`, quote, variant],
|
||||
)
|
||||
).rows[0].id;
|
||||
const asset = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.media_assets(workspace_id,storage_provider,bucket,object_key,content_type,byte_size,checksum_sha256) VALUES($1,'qiniu_kodo','test-bucket',$2,'image/png',100,$3) RETURNING id`,
|
||||
[workspace, randomUUID(), randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.product_variant_listing_media(workspace_id,product_id,profile_id,listing_id,product_variant_id,asset_id,sort_order) VALUES($1,$2,$3,$4,$5,$6,0)`,
|
||||
[workspace, product, profile, listing, variant, asset],
|
||||
);
|
||||
}
|
||||
});
|
||||
return { profile, product, ids };
|
||||
}
|
||||
async function publish(
|
||||
f: Awaited<ReturnType<typeof fixture>>,
|
||||
ids = f.ids,
|
||||
key = randomUUID(),
|
||||
) {
|
||||
return request(
|
||||
`/product-platform-profiles/${f.profile}/publish-jobs`,
|
||||
'POST',
|
||||
{ variant_ids: ids },
|
||||
{ 'Idempotency-Key': key, 'If-Match': '"1:1"' },
|
||||
);
|
||||
}
|
||||
async function run(
|
||||
job: ProductPublishJob,
|
||||
requestFn: PublishDependencies['request'],
|
||||
images: PublishDependencies['images'] = async () => [
|
||||
'https://images.example.test/fixture.png',
|
||||
],
|
||||
) {
|
||||
await runProductPublish(
|
||||
db,
|
||||
storage,
|
||||
{
|
||||
eventId: randomUUID(),
|
||||
workspaceId: workspace,
|
||||
eventType: 'product_publish.requested',
|
||||
payload: { publish_job_id: job.id },
|
||||
},
|
||||
{
|
||||
request: requestFn,
|
||||
images,
|
||||
decrypt: () => 'fixture-key',
|
||||
},
|
||||
);
|
||||
const response = await request(`/product-publish-jobs/${job.id}`);
|
||||
assert.equal(response.status, 200);
|
||||
return response.body.data as ProductPublishJob;
|
||||
}
|
||||
|
||||
describe('首次发布 HTTP / 数据库 / Worker 闭环', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
LOG_LEVEL: 'error',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
OZON_PUBLISH_ENABLED: 'true',
|
||||
});
|
||||
({ app } = await createApplication());
|
||||
db = app.get<Database>(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const registration = await fetch(`${base}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: `publish-${randomUUID()}@example.test`,
|
||||
password: 'correct-horse-battery-staple-123',
|
||||
password_confirmation: 'correct-horse-battery-staple-123',
|
||||
}),
|
||||
});
|
||||
const data = (await registration.json()) as any;
|
||||
assert.equal(registration.status, 201, JSON.stringify(data));
|
||||
workspace = data.data.workspace.id;
|
||||
token = data.data.access_token;
|
||||
platform = (await request('/platforms')).body.data[0].id;
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
store = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'发布测试店铺',$3,'RUB',true,'active','valid') RETURNING id`,
|
||||
[workspace, platform, randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.store_secrets(workspace_id,store_id,kind,secret_ciphertext,secret_nonce,encryption_key_version,fingerprint,status) VALUES($1,$2,'seller_api_key',$3,$4,'v1','fixture','valid')`,
|
||||
[workspace, store, Buffer.from('fixture'), Buffer.alloc(12)],
|
||||
);
|
||||
category = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'1002','发布测试类目','发布测试类目') RETURNING id`,
|
||||
[platform, `${Date.now()}`],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_product_category_attributes(platform_id,platform_product_category_id,platform_attribute_id,name,value_type,is_required,is_aspect,max_value_count) VALUES($1,$2,'9048','型号','text',true,false,1)`,
|
||||
[platform, category],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope_key,scope,category_id,last_success_at,is_complete) VALUES($1,$2,'attributes',$3,now(),true)`,
|
||||
[platform, `attributes:${category}`, category],
|
||||
);
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it('预检、并发幂等、精度快照、Outbox 与跨租户隔离', async () => {
|
||||
const f = await fixture();
|
||||
const key = randomUUID();
|
||||
const [a, b] = await Promise.all([
|
||||
publish(f, undefined, key),
|
||||
publish(f, undefined, key),
|
||||
]);
|
||||
assert.equal(a.status, 202, JSON.stringify(a));
|
||||
assert.deepEqual(a.body, b.body);
|
||||
const job = a.body.data.publish_job as ProductPublishJob;
|
||||
assert.equal(job.items.length, 2);
|
||||
const facts = await db.workspaceTransaction(workspace, async (tx) => ({
|
||||
items: (
|
||||
await tx.query(
|
||||
'SELECT snapshot FROM atlas.product_publish_job_items WHERE job_id=$1',
|
||||
[job.id],
|
||||
)
|
||||
).rows,
|
||||
events: (
|
||||
await tx.query(
|
||||
'SELECT event_payload FROM atlas.outbox_events WHERE aggregate_id=$1',
|
||||
[job.id],
|
||||
)
|
||||
).rows,
|
||||
}));
|
||||
assert.equal(facts.events.length, 1);
|
||||
assert.deepEqual(facts.events[0].event_payload, {
|
||||
publish_job_id: job.id,
|
||||
});
|
||||
assert.equal(facts.items[0].snapshot.price, '1999.1234');
|
||||
assert.equal(facts.items[0].snapshot.depth, 240);
|
||||
assert.ok(!('vat' in facts.items[0].snapshot));
|
||||
assert.ok(!('images' in facts.items[0].snapshot));
|
||||
const other = randomUUID();
|
||||
assert.equal(
|
||||
(
|
||||
await db.workspaceTransaction(other, (tx) =>
|
||||
tx.query(
|
||||
'SELECT id FROM atlas.product_publish_jobs WHERE id=$1',
|
||||
[job.id],
|
||||
),
|
||||
)
|
||||
).rows.length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/product-publish-jobs/${job.id}`,
|
||||
'GET',
|
||||
undefined,
|
||||
{ 'X-Atlas-Platform-Id': randomUUID() },
|
||||
)
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(await publish(f)).body.error.code,
|
||||
'SKU_IMPORT_IN_PROGRESS',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await publish(f, [
|
||||
...Array.from({ length: 51 }, () => randomUUID()),
|
||||
])
|
||||
).status,
|
||||
422,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/product-platform-profiles/${f.profile}/publish-jobs`,
|
||||
'POST',
|
||||
{ variant_ids: f.ids },
|
||||
{ 'Idempotency-Key': randomUUID(), 'If-Match': '"2:1"' },
|
||||
)
|
||||
).body.error.code,
|
||||
'VERSION_CONFLICT',
|
||||
);
|
||||
});
|
||||
it('只校验所选 SKU;不就绪的未选 SKU 不阻断部分上架', async () => {
|
||||
const f = await fixture();
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET offer_id=NULL WHERE profile_id=$1 AND product_variant_id=$2`,
|
||||
[f.profile, f.ids[1]],
|
||||
),
|
||||
);
|
||||
const all = await publish(f);
|
||||
assert.equal(all.status, 422);
|
||||
assert.equal(all.body.error.code, 'PUBLISH_VALIDATION_FAILED');
|
||||
const partial = await publish(f, [f.ids[0]]);
|
||||
assert.equal(partial.status, 202, JSON.stringify(partial));
|
||||
assert.equal(partial.body.data.publish_job.item_count, 1);
|
||||
});
|
||||
it('混合结果持久化,成功锁定型号/货号,重复消息不重复上报', async () => {
|
||||
const f = await fixture();
|
||||
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
|
||||
let imports = 0;
|
||||
job = await run(job, async (_client, _key, path, body) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
assert.equal(path, '/v3/product/import');
|
||||
imports++;
|
||||
const items = body.items as any[];
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].price, '1999.1234');
|
||||
assert.ok(!('vat' in items[0]));
|
||||
assert.ok(!('barcode' in items[0]));
|
||||
assert.ok(!('description' in items[0]));
|
||||
return { result: { task_id: 123456 } };
|
||||
});
|
||||
assert.equal(job.items[0].status, 'polling');
|
||||
const offers = job.items.map((i) => i.offer_id);
|
||||
job = await run(job, async () => ({
|
||||
result: {
|
||||
items: [
|
||||
{
|
||||
offer_id: offers[0],
|
||||
product_id: 101,
|
||||
status: 'imported',
|
||||
errors: [],
|
||||
},
|
||||
{
|
||||
offer_id: offers[1],
|
||||
status: 'failed',
|
||||
errors: [
|
||||
{
|
||||
code: 'BAD_IMAGE',
|
||||
message:
|
||||
'失败 https://private.example.test/a?token=hidden',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
assert.equal(job.status, 'partially_succeeded');
|
||||
assert.equal(
|
||||
job.items.find((i) => i.status === 'imported')?.platform_product_id,
|
||||
'101',
|
||||
);
|
||||
assert.ok(!JSON.stringify(job).includes('token='));
|
||||
await assert.rejects(
|
||||
db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.products SET model='CHANGED' WHERE id=$1`,
|
||||
[f.product],
|
||||
),
|
||||
),
|
||||
/PRODUCT_MODEL_LOCKED/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET offer_id='CHANGED' WHERE profile_id=$1 AND status='imported'`,
|
||||
[f.profile],
|
||||
),
|
||||
),
|
||||
/SKU_ALREADY_IMPORTED/,
|
||||
);
|
||||
await run(job, async () => {
|
||||
imports++;
|
||||
throw new Error('终态不应再访问平台');
|
||||
});
|
||||
assert.equal(imports, 1);
|
||||
});
|
||||
it('响应丢失后只按货号对账,绝不重复导入', async () => {
|
||||
const f = await fixture(1);
|
||||
let job = (await publish(f)).body.data.publish_job as ProductPublishJob;
|
||||
let imports = 0;
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
imports++;
|
||||
throw new OzonRetryableError('response lost');
|
||||
});
|
||||
assert.equal(job.status, 'reconciling');
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
assert.equal(path, '/v3/product/info/list');
|
||||
return { items: [] };
|
||||
});
|
||||
assert.equal(job.status, 'reconciling');
|
||||
assert.equal(imports, 1);
|
||||
job = await run(job, async () => ({
|
||||
items: [{ offer_id: job.items[0].offer_id, id: 999 }],
|
||||
}));
|
||||
assert.equal(job.status, 'succeeded');
|
||||
assert.equal(imports, 1);
|
||||
});
|
||||
it('任一图片准备失败时整批不提交,并隐藏存储路径', async () => {
|
||||
const f = await fixture(2);
|
||||
const created = (await publish(f)).body.data
|
||||
.publish_job as ProductPublishJob;
|
||||
let prepared = 0;
|
||||
const job = await run(
|
||||
created,
|
||||
async (_c, _k, path) => {
|
||||
assert.equal(path, '/v3/product/info/list');
|
||||
return { items: [] };
|
||||
},
|
||||
async () => {
|
||||
if (++prepared === 2)
|
||||
throw new Error('AccessDenied secret/object/key');
|
||||
return ['https://images.example.test/fixture.png'];
|
||||
},
|
||||
);
|
||||
assert.equal(job.status, 'failed');
|
||||
assert.ok(job.items.every((i) => i.status === 'failed'));
|
||||
assert.ok(!JSON.stringify(job).includes('secret/object'));
|
||||
const source = await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
|
||||
[job.id],
|
||||
),
|
||||
);
|
||||
assert.equal(source.rows[0].submit_started_at, null);
|
||||
});
|
||||
it('远端已有同货号时跳过,不调用更新接口;429 延迟后允许提交', async () => {
|
||||
const existing = await fixture(1);
|
||||
let job = (await publish(existing)).body.data
|
||||
.publish_job as ProductPublishJob;
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
assert.equal(path, '/v3/product/info/list');
|
||||
return { items: [{ offer_id: job.items[0].offer_id, id: 88 }] };
|
||||
});
|
||||
assert.equal(job.items[0].status, 'skipped');
|
||||
assert.equal(job.items[0].errors[0].code, 'OZON_OFFER_ALREADY_EXISTS');
|
||||
const f = await fixture(1);
|
||||
job = (await publish(f)).body.data.publish_job as ProductPublishJob;
|
||||
job = await run(job, async (_c, _k, path) => {
|
||||
if (path === '/v3/product/info/list') return { items: [] };
|
||||
throw new OzonRateLimitError(120);
|
||||
});
|
||||
assert.equal(job.items[0].status, 'queued');
|
||||
const source = await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
'SELECT submit_started_at FROM atlas.product_publish_jobs WHERE id=$1',
|
||||
[job.id],
|
||||
),
|
||||
);
|
||||
assert.equal(source.rows[0].submit_started_at, null);
|
||||
job = await run(job, async (_c, _k, path) =>
|
||||
path === '/v3/product/info/list'
|
||||
? { items: [] }
|
||||
: { result: { task_id: 987 } },
|
||||
);
|
||||
assert.equal(job.items[0].status, 'polling');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user