feat(edit): 商品编辑页优化
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import type { PackageSpec, VariantPackageSpec } from '@atlas/contracts';
|
||||
|
||||
/** Ozon 商品导入所需的平台属性已经在发布快照中转换为该结构。 */
|
||||
export type OzonProductImportAttribute = {
|
||||
id: number;
|
||||
complex_id: number;
|
||||
values: Array<{
|
||||
value?: string;
|
||||
dictionary_value_id?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
/** `/v3/product/import` 单个 SKU 的稳定请求结构。 */
|
||||
export type OzonProductImportItem = {
|
||||
offer_id: string;
|
||||
name: string;
|
||||
description_category_id: number;
|
||||
type_id: number;
|
||||
price: number;
|
||||
currency_code?: string;
|
||||
description?: string;
|
||||
barcode?: string;
|
||||
images?: string[];
|
||||
attributes: OzonProductImportAttribute[];
|
||||
depth: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dimension_unit: 'mm';
|
||||
weight: number;
|
||||
weight_unit: 'g';
|
||||
};
|
||||
|
||||
export type OzonProductImportPayload = { items: OzonProductImportItem[] };
|
||||
|
||||
export type OzonProductImportSource = {
|
||||
default_package_spec: PackageSpec;
|
||||
category: {
|
||||
description_category_id: string | number;
|
||||
type_id: string | number;
|
||||
};
|
||||
title: string;
|
||||
description: string | null;
|
||||
barcode: string | null;
|
||||
/** is_aspect=false 的商品级 Ozon 属性。 */
|
||||
common_attributes: OzonProductImportAttribute[];
|
||||
variants: Array<{
|
||||
variant_id: string;
|
||||
offer_id: string | null;
|
||||
price: string | null;
|
||||
currency_code: string | null;
|
||||
package_spec: VariantPackageSpec;
|
||||
images: string[];
|
||||
attributes: OzonProductImportAttribute[];
|
||||
}>;
|
||||
};
|
||||
|
||||
/** 发布快照不可转换为 Ozon payload 时抛出的确定性错误。 */
|
||||
export class OzonProductImportPayloadError extends Error {
|
||||
public constructor(
|
||||
public readonly variantId: string,
|
||||
public readonly field: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 Worker 创建发布请求时按 SKU 展开包装规格。草稿只保存 SPU 默认值与
|
||||
* SKU 覆盖值,禁止为发布便利而将 SPU 默认值回写、复制到全部 SKU。
|
||||
*/
|
||||
export function buildOzonProductImportPayload(
|
||||
source: OzonProductImportSource,
|
||||
): OzonProductImportPayload {
|
||||
const categoryId = positiveInteger(
|
||||
source.category.description_category_id,
|
||||
'category.description_category_id',
|
||||
);
|
||||
const typeId = positiveInteger(source.category.type_id, 'category.type_id');
|
||||
const name = requiredText(source.title, 'title');
|
||||
|
||||
return {
|
||||
items: source.variants.map((variant) => {
|
||||
const packageSpec = effectivePackageSpec(
|
||||
source.default_package_spec,
|
||||
variant.package_spec,
|
||||
);
|
||||
const currencyCode = optionalText(variant.currency_code);
|
||||
const description = optionalText(source.description);
|
||||
const barcode = optionalText(source.barcode);
|
||||
return {
|
||||
offer_id: requiredText(
|
||||
variant.offer_id,
|
||||
'offer_id',
|
||||
variant.variant_id,
|
||||
),
|
||||
name,
|
||||
description_category_id: categoryId,
|
||||
type_id: typeId,
|
||||
price: positiveDecimal(
|
||||
variant.price,
|
||||
'price',
|
||||
variant.variant_id,
|
||||
),
|
||||
...(currencyCode ? { currency_code: currencyCode } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(barcode ? { barcode } : {}),
|
||||
...(variant.images.length ? { images: variant.images } : {}),
|
||||
// 每个 Ozon items[] 同时携带商品级属性和本 SKU 的 aspect 属性。
|
||||
attributes: [
|
||||
...source.common_attributes,
|
||||
...variant.attributes,
|
||||
],
|
||||
depth: positiveDecimal(
|
||||
packageSpec.length_mm,
|
||||
'package_spec.length_mm',
|
||||
variant.variant_id,
|
||||
),
|
||||
width: positiveDecimal(
|
||||
packageSpec.width_mm,
|
||||
'package_spec.width_mm',
|
||||
variant.variant_id,
|
||||
),
|
||||
height: positiveDecimal(
|
||||
packageSpec.height_mm,
|
||||
'package_spec.height_mm',
|
||||
variant.variant_id,
|
||||
),
|
||||
dimension_unit: 'mm',
|
||||
weight: positiveDecimal(
|
||||
packageSpec.shipping_weight_g,
|
||||
'package_spec.shipping_weight_g',
|
||||
variant.variant_id,
|
||||
),
|
||||
weight_unit: 'g',
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** SKU 字段优先;空覆盖字段保留 SPU 默认包装规格作为最终物流值。 */
|
||||
function effectivePackageSpec(
|
||||
fallback: PackageSpec,
|
||||
override: VariantPackageSpec,
|
||||
): PackageSpec {
|
||||
return {
|
||||
length_mm: override.length_mm ?? fallback.length_mm,
|
||||
width_mm: override.width_mm ?? fallback.width_mm,
|
||||
height_mm: override.height_mm ?? fallback.height_mm,
|
||||
shipping_weight_g:
|
||||
override.shipping_weight_g ?? fallback.shipping_weight_g,
|
||||
status: override.status ?? fallback.status,
|
||||
};
|
||||
}
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
const text = value?.trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function requiredText(
|
||||
value: string | null,
|
||||
field: string,
|
||||
variantId = 'profile',
|
||||
) {
|
||||
const text = optionalText(value);
|
||||
if (text) return text;
|
||||
throw new OzonProductImportPayloadError(
|
||||
variantId,
|
||||
field,
|
||||
`${field} 不能为空`,
|
||||
);
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | number, field: string) {
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
if (Number.isInteger(numberValue) && numberValue > 0) return numberValue;
|
||||
throw new OzonProductImportPayloadError(
|
||||
'profile',
|
||||
field,
|
||||
`${field} 必须为正整数`,
|
||||
);
|
||||
}
|
||||
|
||||
function positiveDecimal(
|
||||
value: string | null,
|
||||
field: string,
|
||||
variantId: string,
|
||||
) {
|
||||
const numberValue = value == null ? Number.NaN : Number(value);
|
||||
if (Number.isFinite(numberValue) && numberValue > 0) return numberValue;
|
||||
throw new OzonProductImportPayloadError(
|
||||
variantId,
|
||||
field,
|
||||
`${field} 必须大于 0`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
buildOzonProductImportPayload,
|
||||
OzonProductImportPayloadError,
|
||||
} from '../src/ozon-product-import.ts';
|
||||
|
||||
const defaultPackageSpec = {
|
||||
length_mm: '240',
|
||||
width_mm: '120',
|
||||
height_mm: '60',
|
||||
shipping_weight_g: '850',
|
||||
status: 'confirmed',
|
||||
};
|
||||
|
||||
function source(overrides = {}) {
|
||||
return {
|
||||
default_package_spec: defaultPackageSpec,
|
||||
category: { description_category_id: '17028922', type_id: '91234' },
|
||||
title: '测试商品',
|
||||
description: '商品描述',
|
||||
barcode: '1234567890123',
|
||||
common_attributes: [
|
||||
{ id: 85, complex_id: 0, values: [{ value: '品牌' }] },
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
variant_id: 'sku-a',
|
||||
offer_id: 'offer-a',
|
||||
price: '199.90',
|
||||
currency_code: 'RUB',
|
||||
package_spec: {
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
height_mm: null,
|
||||
shipping_weight_g: null,
|
||||
status: null,
|
||||
},
|
||||
images: ['https://cdn.example.test/a.jpg'],
|
||||
attributes: [
|
||||
{ id: 8229, complex_id: 0, values: [{ value: '红色' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('按 SKU 将 SPU 默认包装规格展开为 Ozon items[]', () => {
|
||||
const payload = buildOzonProductImportPayload(source());
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
items: [
|
||||
{
|
||||
offer_id: 'offer-a',
|
||||
name: '测试商品',
|
||||
description_category_id: 17028922,
|
||||
type_id: 91234,
|
||||
price: 199.9,
|
||||
currency_code: 'RUB',
|
||||
description: '商品描述',
|
||||
barcode: '1234567890123',
|
||||
images: ['https://cdn.example.test/a.jpg'],
|
||||
attributes: [
|
||||
{ id: 85, complex_id: 0, values: [{ value: '品牌' }] },
|
||||
{ id: 8229, complex_id: 0, values: [{ value: '红色' }] },
|
||||
],
|
||||
depth: 240,
|
||||
width: 120,
|
||||
height: 60,
|
||||
dimension_unit: 'mm',
|
||||
weight: 850,
|
||||
weight_unit: 'g',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('SKU 包装覆盖值优先于 SPU 默认值', () => {
|
||||
const input = source();
|
||||
input.variants[0].package_spec = {
|
||||
length_mm: '300',
|
||||
width_mm: '160',
|
||||
height_mm: '70',
|
||||
shipping_weight_g: '1000',
|
||||
status: 'confirmed',
|
||||
};
|
||||
|
||||
const payload = buildOzonProductImportPayload(input);
|
||||
assert.deepEqual(
|
||||
[
|
||||
payload.items[0].depth,
|
||||
payload.items[0].width,
|
||||
payload.items[0].height,
|
||||
payload.items[0].weight,
|
||||
],
|
||||
[300, 160, 70, 1000],
|
||||
);
|
||||
});
|
||||
|
||||
test('重量与尺寸可分别覆盖,未覆盖字段继续继承 SPU', () => {
|
||||
const input = source();
|
||||
input.variants[0].package_spec.shipping_weight_g = '920';
|
||||
|
||||
const item = buildOzonProductImportPayload(input).items[0];
|
||||
assert.deepEqual(
|
||||
[item.depth, item.width, item.height, item.weight],
|
||||
[240, 120, 60, 920],
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少有效包装规格时拒绝生成对应 SKU 的导入项', () => {
|
||||
const input = source({
|
||||
default_package_spec: {
|
||||
...defaultPackageSpec,
|
||||
shipping_weight_g: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => buildOzonProductImportPayload(input),
|
||||
(error) =>
|
||||
error instanceof OzonProductImportPayloadError &&
|
||||
error.variantId === 'sku-a' &&
|
||||
error.field === 'package_spec.shipping_weight_g',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user