feat(edit): 定价试算开发、SKU 样式功能修改
This commit is contained in:
@@ -101,11 +101,7 @@ export function draftPricingEnabled(
|
||||
nodeEnvironment: Environment['NODE_ENV'],
|
||||
setting: Environment['PRICING_ALLOW_DRAFT_RATES'],
|
||||
): boolean {
|
||||
return (
|
||||
nodeEnvironment !== 'production' &&
|
||||
(setting === 'true' ||
|
||||
(nodeEnvironment === 'development' && setting === 'auto'))
|
||||
);
|
||||
return setting === 'true' || (setting === 'auto' && nodeEnvironment !== 'test');
|
||||
}
|
||||
export type AppConfig = Omit<
|
||||
Environment,
|
||||
@@ -152,7 +148,7 @@ function createAppConfig(environment: Environment): AppConfig {
|
||||
objectStorageSecretKey,
|
||||
objectStorageForcePathStyle:
|
||||
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
|
||||
// 草稿费率仅用于本地商品编辑联调,生产配置无法开启。
|
||||
// 草稿费率默认可参与报价;仍可通过环境配置限制新试算。
|
||||
pricingAllowDraftRates: draftPricingEnabled(
|
||||
environment.NODE_ENV,
|
||||
environment.PRICING_ALLOW_DRAFT_RATES,
|
||||
|
||||
@@ -58,6 +58,11 @@ export async function createApplication() {
|
||||
});
|
||||
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
// 批量佣金文件可达 2 MiB,专用入口预留元数据空间,其他 API 保留原限制。
|
||||
app.useBodyParser('json', {
|
||||
limit: 3 * 1024 * 1024,
|
||||
type: (req) => (req as Request).path.startsWith('/api/system/pricing/commissions') && Boolean((req as Request).is('application/json')),
|
||||
});
|
||||
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
|
||||
app.useBodyParser('urlencoded', {
|
||||
limit: config.API_BODY_LIMIT_BYTES,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { z } from 'zod';
|
||||
import type { CommissionImportCategory } from '@atlas/contracts';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { D } from './pricing-engine.js';
|
||||
|
||||
const sourceId = z
|
||||
.union([
|
||||
z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
z.string().regex(/^[1-9]\d{0,17}$/),
|
||||
])
|
||||
.transform(String);
|
||||
const leaf = z
|
||||
.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
label: z.string().max(200),
|
||||
value: z.string().max(50),
|
||||
})
|
||||
.strict();
|
||||
const category = z
|
||||
.object({
|
||||
id: z.union([z.string(), z.number()]),
|
||||
label: z.string().trim().min(1).max(200),
|
||||
value: z.string().max(200),
|
||||
cate_id: sourceId,
|
||||
children: z.array(leaf).length(3),
|
||||
})
|
||||
.strict();
|
||||
const root = category
|
||||
.omit({ children: true })
|
||||
.extend({ children: z.array(category).min(1).max(2000) });
|
||||
const documentSchema = z
|
||||
.object({
|
||||
code: z.literal(1),
|
||||
msg: z.string(),
|
||||
data: z.array(root).min(1).max(100),
|
||||
})
|
||||
.strict();
|
||||
const fail = (message: string): never => {
|
||||
throw new ApiError(422, 'COMMISSION_IMPORT_INVALID', message);
|
||||
};
|
||||
|
||||
/** 仅在导入时解析来源文字;报价读取结构化区间,不解析展示标签。 */
|
||||
export function parseCommissionDocument(
|
||||
document: unknown,
|
||||
): CommissionImportCategory[] {
|
||||
if (Buffer.byteLength(JSON.stringify(document) ?? '') > 2 * 1024 * 1024)
|
||||
fail('JSON 不能超过 2 MiB');
|
||||
const parsed = documentSchema.safeParse(document);
|
||||
if (!parsed.success)
|
||||
return fail(
|
||||
'JSON 格式不正确:应包含一级类目、二级类目和每类完整的三个价格档位',
|
||||
);
|
||||
const ids = new Set<string>();
|
||||
const result: CommissionImportCategory[] = [];
|
||||
for (const group of parsed.data.data) {
|
||||
if (ids.has(group.cate_id)) fail(`类目 ID 重复:${group.cate_id}`);
|
||||
ids.add(group.cate_id);
|
||||
for (const item of group.children) {
|
||||
if (ids.has(item.cate_id)) fail(`类目 ID 重复:${item.cate_id}`);
|
||||
ids.add(item.cate_id);
|
||||
const bands = item.children.map((tier, index) => {
|
||||
const value = /^([123]),(\d{1,2}(?:\.\d{1,2})?)$/.exec(
|
||||
tier.value,
|
||||
);
|
||||
const prefix = [
|
||||
'售价 ≤ 1500₽',
|
||||
'1500₽ < 售价 ≤ 5000₽',
|
||||
'售价 > 5000₽',
|
||||
][index];
|
||||
const label = /^(.*?)\s*\((\d{1,2}(?:\.\d{1,2})?)%\)$/.exec(
|
||||
tier.label,
|
||||
);
|
||||
if (
|
||||
!value ||
|
||||
!label ||
|
||||
value[1] !== String(index + 1) ||
|
||||
label[1].trim() !== prefix ||
|
||||
!D(value[2]).eq(label[2])
|
||||
)
|
||||
return fail(
|
||||
`${item.label}:档位或标签百分比与 value 不一致`,
|
||||
);
|
||||
return {
|
||||
min_price: ['0', '1500', '5000'][index],
|
||||
max_price: ['1500', '5000', null][index],
|
||||
min_inclusive: false,
|
||||
max_inclusive: index < 2,
|
||||
commission_rate: D(value[2]).div(100).toFixed(6),
|
||||
};
|
||||
});
|
||||
result.push({
|
||||
source_category_id: item.cate_id,
|
||||
parent_source_id: group.cate_id,
|
||||
name: item.label,
|
||||
parent_name: group.label,
|
||||
bands,
|
||||
status: 'eligible',
|
||||
issues: [],
|
||||
change: 'added',
|
||||
mapped_types: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (result.length > 2000) fail('二级类目不能超过 2,000 个');
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
commissionImportRequestSchema,
|
||||
commissionImportPublishSchema,
|
||||
type Workspace,
|
||||
} from '@atlas/contracts';
|
||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
|
||||
import { CommissionImportService } from './commission-import.service.js';
|
||||
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
const r = schema.safeParse(value);
|
||||
if (!r.success)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'VALIDATION_ERROR',
|
||||
'请检查平台、版本名称、来源、采集日期及确认项',
|
||||
r.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
);
|
||||
return r.data;
|
||||
}
|
||||
@ApiTags('System commissions')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(SystemAdminGuard)
|
||||
@Controller('system/pricing/commissions')
|
||||
export class CommissionImportController {
|
||||
constructor(
|
||||
@Inject(CommissionImportService)
|
||||
private readonly service: CommissionImportService,
|
||||
) {}
|
||||
@Get() async list(@Query('platform_id') id: string) {
|
||||
return { data: await this.service.list(parse(z.uuid(), id)) };
|
||||
}
|
||||
@Get(':id') async detail(@Param('id') id: string) {
|
||||
return { data: await this.service.detail(parse(z.uuid(), id)) };
|
||||
}
|
||||
@Post('preview') async preview(@Body() body: unknown) {
|
||||
return {
|
||||
data: await this.service.preview(
|
||||
parse(commissionImportRequestSchema, body),
|
||||
),
|
||||
};
|
||||
}
|
||||
@Post('imports') async publish(
|
||||
@Body() body: unknown,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@CurrentWorkspace() w: Workspace,
|
||||
@Headers('idempotency-key') key: string | undefined,
|
||||
@Res({ passthrough: true }) res: { status: (n: number) => unknown },
|
||||
) {
|
||||
if (!key)
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IDEMPOTENCY_KEY_REQUIRED',
|
||||
'需要 Idempotency-Key',
|
||||
);
|
||||
const r = await this.service.publish(
|
||||
w.id,
|
||||
req.auth.userId,
|
||||
parse(z.uuid(), key),
|
||||
parse(commissionImportPublishSchema, body),
|
||||
);
|
||||
res.status(r.status);
|
||||
return r.body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import type {
|
||||
CommissionImportRequest,
|
||||
CommissionImportPublish,
|
||||
CommissionImportCategory,
|
||||
CommissionImportPreview,
|
||||
CommissionImportVersion,
|
||||
CommissionImportDetail,
|
||||
} from '@atlas/contracts';
|
||||
import { DATABASE } from '../database/database.module.js';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { ApiError } from '../common/api-error.js';
|
||||
import { fingerprint } from './pricing-engine.js';
|
||||
import { parseCommissionDocument } from './commission-import-parser.js';
|
||||
type Tx = DatabaseTransaction;
|
||||
type CatalogRow = {
|
||||
id: string;
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
parent_external_id: string | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
const columns =
|
||||
'id,platform_id,version,source_reference,captured_on::text,activated_at,is_current,summary';
|
||||
const inputFields = (v: CommissionImportRequest): CommissionImportRequest => ({
|
||||
platform_id: v.platform_id,
|
||||
version: v.version,
|
||||
source_reference: v.source_reference,
|
||||
captured_on: v.captured_on,
|
||||
document: v.document,
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class CommissionImportService {
|
||||
constructor(
|
||||
@Inject(DATABASE) private readonly db: Database,
|
||||
@Inject(IdempotencyService)
|
||||
private readonly idempotency: IdempotencyService,
|
||||
) {}
|
||||
|
||||
async list(platformId: string): Promise<CommissionImportVersion[]> {
|
||||
return (
|
||||
await this.db.query<CommissionImportVersion>(
|
||||
`SELECT ${columns} FROM atlas.commission_imports WHERE platform_id=$1 ORDER BY activated_at DESC,id`,
|
||||
[platformId],
|
||||
)
|
||||
).rows;
|
||||
}
|
||||
async detail(id: string): Promise<CommissionImportDetail> {
|
||||
const row = (
|
||||
await this.db.query<CommissionImportDetail>(
|
||||
`SELECT ${columns},categories FROM atlas.commission_imports WHERE id=$1`,
|
||||
[id],
|
||||
)
|
||||
).rows[0];
|
||||
if (!row)
|
||||
throw new ApiError(
|
||||
404,
|
||||
'COMMISSION_IMPORT_NOT_FOUND',
|
||||
'佣金版本不存在',
|
||||
);
|
||||
return row;
|
||||
}
|
||||
async preview(input: CommissionImportRequest) {
|
||||
return this.db.transaction(
|
||||
async (tx) => (await this.inspect(tx, input)).preview,
|
||||
);
|
||||
}
|
||||
private async inspect(
|
||||
tx: Tx,
|
||||
input: CommissionImportRequest,
|
||||
lock = false,
|
||||
) {
|
||||
const platform = (
|
||||
await tx.query<{ code: string }>(
|
||||
`SELECT code FROM atlas.platforms WHERE id=$1 ${lock ? 'FOR UPDATE' : ''}`,
|
||||
[input.platform_id],
|
||||
)
|
||||
).rows[0];
|
||||
if (platform?.code.toUpperCase() !== 'OZON')
|
||||
throw new ApiError(
|
||||
422,
|
||||
'COMMISSION_PLATFORM_UNSUPPORTED',
|
||||
'当前仅支持 Ozon 类目佣金',
|
||||
);
|
||||
if (input.captured_on > new Date().toISOString().slice(0, 10))
|
||||
throw new ApiError(
|
||||
422,
|
||||
'COMMISSION_IMPORT_INVALID',
|
||||
'采集日期不能晚于今天',
|
||||
);
|
||||
const categories = parseCommissionDocument(input.document);
|
||||
const rows = (
|
||||
await tx.query<CatalogRow>(
|
||||
`SELECT c.id,c.external_category_id,c.external_type_id,c.is_active,p.external_category_id AS parent_external_id
|
||||
FROM atlas.platform_product_categories c LEFT JOIN atlas.platform_product_categories p ON p.id=c.parent_id
|
||||
WHERE c.platform_id=$1 ORDER BY c.id ${lock ? 'FOR SHARE OF c' : ''}`,
|
||||
[input.platform_id],
|
||||
)
|
||||
).rows;
|
||||
const previous = (
|
||||
await tx.query<{
|
||||
id: string;
|
||||
categories: CommissionImportCategory[];
|
||||
}>(
|
||||
`SELECT id,categories FROM atlas.commission_imports WHERE platform_id=$1 AND is_current`,
|
||||
[input.platform_id],
|
||||
)
|
||||
).rows[0];
|
||||
const old = new Map(
|
||||
(previous?.categories ?? [])
|
||||
.filter((c) => c.status === 'eligible')
|
||||
.map((c) => [c.source_category_id, c]),
|
||||
);
|
||||
const mappings = new Map<string, string[]>();
|
||||
for (const c of categories) {
|
||||
const matching = rows.filter(
|
||||
(r) => r.external_category_id === c.source_category_id,
|
||||
);
|
||||
const parent = matching.filter(
|
||||
(r) => !r.external_type_id || r.external_type_id === '0',
|
||||
);
|
||||
const types = matching.filter(
|
||||
(r) =>
|
||||
r.is_active &&
|
||||
r.external_type_id &&
|
||||
r.external_type_id !== '0',
|
||||
);
|
||||
if (parent.length !== 1)
|
||||
c.issues.push('平台目录中没有唯一对应的类目');
|
||||
else if (parent[0].parent_external_id !== c.parent_source_id)
|
||||
c.issues.push('来源一级父类目与平台目录不一致');
|
||||
if (!types.length) c.issues.push('没有可映射的有效商品类型');
|
||||
c.status = c.issues.length ? 'excluded' : 'eligible';
|
||||
c.mapped_types = c.status === 'eligible' ? types.length : 0;
|
||||
const before = old.get(c.source_category_id);
|
||||
c.change = !before
|
||||
? 'added'
|
||||
: fingerprint(before.bands) === fingerprint(c.bands)
|
||||
? 'unchanged'
|
||||
: 'changed';
|
||||
if (c.status === 'eligible')
|
||||
mappings.set(
|
||||
c.source_category_id,
|
||||
types.map((t) => t.id),
|
||||
);
|
||||
}
|
||||
const eligible = categories.filter((c) => c.status === 'eligible');
|
||||
const summary = {
|
||||
categories: categories.length,
|
||||
rules: categories.reduce((n, c) => n + c.bands.length, 0),
|
||||
eligible: eligible.length,
|
||||
excluded: categories.length - eligible.length,
|
||||
mapped_types: eligible.reduce((n, c) => n + c.mapped_types, 0),
|
||||
removed: [...old.keys()].filter((id) => !mappings.has(id)).length,
|
||||
};
|
||||
const preview: CommissionImportPreview = {
|
||||
categories,
|
||||
summary,
|
||||
previous_import_id: previous?.id ?? null,
|
||||
preview_hash: fingerprint({
|
||||
input: inputFields(input),
|
||||
categories,
|
||||
mappings: [...mappings],
|
||||
previous: previous?.id ?? null,
|
||||
}),
|
||||
};
|
||||
return { preview, mappings };
|
||||
}
|
||||
async publish(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
input: CommissionImportPublish,
|
||||
) {
|
||||
return this.idempotency.run(
|
||||
{
|
||||
workspaceId,
|
||||
userId,
|
||||
key,
|
||||
method: 'POST',
|
||||
path: '/api/system/pricing/commissions/imports',
|
||||
request: input,
|
||||
},
|
||||
async (tx) => {
|
||||
// 发布时重新查询有效管理员授权,避免仅信任旧 JWT。
|
||||
const grant = await tx.query(
|
||||
`SELECT g.user_id FROM atlas.system_admin_grants g JOIN atlas.users u ON u.id=g.user_id WHERE g.user_id=$1 AND g.revoked_at IS NULL AND u.status='active' FOR SHARE OF u`,
|
||||
[userId],
|
||||
);
|
||||
if (!grant.rowCount)
|
||||
throw new ApiError(
|
||||
403,
|
||||
'SYSTEM_ADMIN_REQUIRED',
|
||||
'需要系统管理员权限',
|
||||
);
|
||||
const { preview, mappings } = await this.inspect(
|
||||
tx,
|
||||
input,
|
||||
true,
|
||||
);
|
||||
if (preview.preview_hash !== input.preview_hash)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'COMMISSION_PREVIEW_STALE',
|
||||
'目录、当前版本或输入已变化,请重新预览',
|
||||
);
|
||||
if (!preview.summary.eligible)
|
||||
throw new ApiError(
|
||||
422,
|
||||
'COMMISSION_IMPORT_INVALID',
|
||||
'没有可启用的类目,请核对异常项',
|
||||
);
|
||||
const contentHash = fingerprint(input.document);
|
||||
if (
|
||||
(
|
||||
await tx.query(
|
||||
`SELECT id FROM atlas.commission_imports WHERE platform_id=$1 AND (content_hash=$2 OR version=$3)`,
|
||||
[input.platform_id, contentHash, input.version],
|
||||
)
|
||||
).rowCount
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'COMMISSION_IMPORT_EXISTS',
|
||||
'此版本名称或 JSON 内容已导入',
|
||||
);
|
||||
const typeIds = [...mappings.values()].flat();
|
||||
if (
|
||||
(
|
||||
await tx.query(
|
||||
`SELECT id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND (effective_to IS NULL OR effective_to>now()) AND import_id IS NULL FOR SHARE`,
|
||||
[input.platform_id, typeIds],
|
||||
)
|
||||
).rowCount
|
||||
)
|
||||
throw new ApiError(
|
||||
409,
|
||||
'COMMISSION_MAPPING_CONFLICT',
|
||||
'部分类型已有其他来源的有效佣金映射,请先核对',
|
||||
);
|
||||
if (preview.previous_import_id) {
|
||||
await tx.query(
|
||||
`UPDATE atlas.commission_category_rules SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
|
||||
[preview.previous_import_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.platform_product_category_commission_mappings SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
|
||||
[preview.previous_import_id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.commission_imports SET is_current=false WHERE id=$1`,
|
||||
[preview.previous_import_id],
|
||||
);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const saved = (
|
||||
await tx.query<CommissionImportVersion>(
|
||||
`INSERT INTO atlas.commission_imports(id,platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING ${columns}`,
|
||||
[
|
||||
id,
|
||||
input.platform_id,
|
||||
input.version,
|
||||
input.source_reference,
|
||||
input.captured_on,
|
||||
JSON.stringify(input.document),
|
||||
contentHash,
|
||||
JSON.stringify(preview.categories),
|
||||
JSON.stringify(preview.summary),
|
||||
userId,
|
||||
],
|
||||
)
|
||||
).rows[0];
|
||||
const roots = new Map<string, string>();
|
||||
for (const c of preview.categories.filter(
|
||||
(c) => c.status === 'eligible',
|
||||
)) {
|
||||
let rootId = roots.get(c.parent_source_id);
|
||||
if (!rootId) {
|
||||
rootId = await this.category(
|
||||
tx,
|
||||
input.platform_id,
|
||||
c.parent_source_id,
|
||||
c.parent_name,
|
||||
`毛子ERP / ${c.parent_name} (${c.parent_source_id})`,
|
||||
null,
|
||||
);
|
||||
roots.set(c.parent_source_id, rootId);
|
||||
}
|
||||
const categoryId = await this.category(
|
||||
tx,
|
||||
input.platform_id,
|
||||
c.source_category_id,
|
||||
c.name,
|
||||
`毛子ERP / ${c.parent_name} / ${c.name} (${c.source_category_id})`,
|
||||
rootId,
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,min_price,max_price,min_inclusive,max_inclusive,commission_rate,source,version,status,effective_from,import_id)
|
||||
SELECT $1,'rfbs','RU','RUB',min_price::numeric,max_price::numeric,min_inclusive,max_inclusive,commission_rate::numeric,'competitor_capture',$2,'active',now(),$3 FROM jsonb_to_recordset($4::jsonb) AS b(min_price text,max_price text,min_inclusive boolean,max_inclusive boolean,commission_rate text)`,
|
||||
[
|
||||
categoryId,
|
||||
input.version,
|
||||
id,
|
||||
JSON.stringify(c.bands),
|
||||
],
|
||||
);
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,market_code,priority,source,status,effective_from,import_id)
|
||||
SELECT $1,unnest($2::uuid[]),$3,'rfbs','RU',100,'competitor_capture','active',now(),$4`,
|
||||
[
|
||||
input.platform_id,
|
||||
mappings.get(c.source_category_id),
|
||||
categoryId,
|
||||
id,
|
||||
],
|
||||
);
|
||||
}
|
||||
return { status: 201, body: { data: saved } };
|
||||
},
|
||||
);
|
||||
}
|
||||
private async category(
|
||||
tx: Tx,
|
||||
platform: string,
|
||||
key: string,
|
||||
name: string,
|
||||
path: string,
|
||||
parent: string | null,
|
||||
): Promise<string> {
|
||||
const existing = (
|
||||
await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.commission_categories WHERE platform_id=$1 AND source_category_key=$2`,
|
||||
[platform, `maozierp:${key}`],
|
||||
)
|
||||
).rows[0];
|
||||
if (existing) return existing.id;
|
||||
return (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.commission_categories(platform_id,parent_id,source_category_key,name,path,source,status) VALUES($1,$2,$3,$4,$5,'competitor_capture','active') RETURNING id`,
|
||||
[platform, parent, `maozierp:${key}`, name, path],
|
||||
)
|
||||
).rows[0].id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DatabaseTransaction } from '@atlas/database';
|
||||
import type { CommissionRule } from './pricing-engine.js';
|
||||
|
||||
/** 查询与正式报价共用同一组已启用规则,避免页面显示和实际算价使用不同费率。 */
|
||||
export async function readCommissionRules(
|
||||
tx: DatabaseTransaction,
|
||||
platformId: string,
|
||||
categoryId: string,
|
||||
fulfillment: string,
|
||||
) {
|
||||
const mapping = (
|
||||
await tx.query<{ commission_category_id: string }>(
|
||||
`SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now()) ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`,
|
||||
[platformId, categoryId, fulfillment],
|
||||
)
|
||||
).rows[0];
|
||||
const commissions = mapping
|
||||
? (
|
||||
await tx.query<CommissionRule>(
|
||||
`SELECT r.*,c.name AS category_name FROM atlas.commission_category_rules r JOIN atlas.commission_categories c ON c.id=r.commission_category_id
|
||||
WHERE r.commission_category_id=$1 AND r.fulfillment=$2 AND r.market_code='RU' AND r.currency_code IN ('CNY','RUB')
|
||||
AND r.status='active' AND r.effective_from<=now() AND (r.effective_to IS NULL OR r.effective_to>now()) ORDER BY r.min_price NULLS FIRST,r.id FOR SHARE OF r,c`,
|
||||
[mapping.commission_category_id, fulfillment],
|
||||
)
|
||||
).rows
|
||||
: [];
|
||||
return { mapping, commissions };
|
||||
}
|
||||
@@ -52,6 +52,9 @@ export type LogisticsRule = {
|
||||
volume_limit_round_scale: number | null;
|
||||
};
|
||||
export type CommissionRule = {
|
||||
category_name?: string;
|
||||
source?: string;
|
||||
effective_from?: string | Date;
|
||||
id: string | null;
|
||||
currency_code: string;
|
||||
min_price: string | null;
|
||||
@@ -254,6 +257,10 @@ export function calculatePricing(
|
||||
logistics_rate_rule_id: r.id,
|
||||
commission_rule_id: c.id,
|
||||
commission_rate: c.commission_rate,
|
||||
...(c.id ? {commission_details: {
|
||||
category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule',
|
||||
min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate,
|
||||
}} : {}),
|
||||
selling_price: rounded.toFixed(2),
|
||||
list_price: list.toFixed(2),
|
||||
currency_code: currency,
|
||||
|
||||
@@ -54,6 +54,15 @@ export class PricingController {
|
||||
constructor(
|
||||
@Inject(PricingService) private readonly service: PricingService,
|
||||
) {}
|
||||
@Get('pricing/commission-rules') async commissionRules(
|
||||
@CurrentWorkspace() w: Workspace,
|
||||
@Query('store_id') store: string,
|
||||
@Query('platform_product_category_id') category: string | undefined,
|
||||
@Query('description_category_id') external: string | undefined,
|
||||
) {
|
||||
if (Boolean(category) === Boolean(external)) throw new ApiError(422,'VALIDATION_ERROR','请指定一种类目 ID');
|
||||
return {data:await this.service.commissionRules(w.id,parse(z.uuid(),store),category ? parse(z.uuid(),category) : undefined,external ? parse(z.string().regex(/^[1-9]\d{0,17}$/),external) : undefined)};
|
||||
}
|
||||
@Get('pricing/categories') async categories(
|
||||
@CurrentWorkspace() w: Workspace,
|
||||
@Query('store_id') store: string,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CommissionImportController } from './commission-import.controller.js';
|
||||
import { CommissionImportService } from './commission-import.service.js';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||
import { PricingController } from './pricing.controller.js';
|
||||
@@ -6,9 +8,10 @@ import { PricingAdminController } from './pricing-admin.controller.js';
|
||||
import { PricingAdminService } from './pricing-admin.service.js';
|
||||
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
|
||||
@Module({
|
||||
controllers: [PricingController, PricingAdminController],
|
||||
controllers: [PricingController, PricingAdminController, CommissionImportController],
|
||||
providers: [
|
||||
PricingService,
|
||||
CommissionImportService,
|
||||
PricingAdminService,
|
||||
SystemAdminGuard,
|
||||
IdempotencyService,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readCommissionRules } from './commission-rules.js';
|
||||
import type { CommissionRulesResult } from '@atlas/contracts';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
@@ -121,6 +123,24 @@ export class PricingService {
|
||||
},
|
||||
}));
|
||||
}
|
||||
async commissionRules(workspaceId: string, storeId: string, categoryId?: string, externalId?: string): Promise<CommissionRulesResult> {
|
||||
return this.db.workspaceTransaction(workspaceId, async tx => {
|
||||
const store = await this.store(tx, storeId);
|
||||
const categories = (await tx.query<{id:string}>(`SELECT id FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND ($2::uuid IS NULL OR id=$2) AND ($3::text IS NULL OR external_category_id=$3) ORDER BY id`, [store.platform_id, categoryId ?? null, externalId ?? null])).rows;
|
||||
if (!categories.length) throw new ApiError(422, 'PRICING_CATEGORY_REQUIRED', '请选择当前平台的有效类目');
|
||||
let resolvedCategoryId = categories[0].id;
|
||||
// 外部类目下可以有多个类型,但不能将相互冲突的映射折叠成一个费率。
|
||||
if (externalId) {
|
||||
const mapped = await tx.query<{id:string; platform_product_category_id:string}>(`SELECT DISTINCT commission_category_id AS id,platform_product_category_id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`,[store.platform_id,categories.map(c=>c.id)]);
|
||||
if (new Set(mapped.rows.map(row => row.id)).size>1) throw new ApiError(422,'COMMISSION_MAPPING_CONFLICT','此类目下的商品类型使用不同规则,请指定具体商品类型');
|
||||
resolvedCategoryId = mapped.rows[0]?.platform_product_category_id ?? resolvedCategoryId;
|
||||
}
|
||||
const {commissions} = await readCommissionRules(tx,store.platform_id,resolvedCategoryId,'rfbs');
|
||||
if (!commissions.length) throw new ApiError(422,'PRICING_COMMISSION_UNAVAILABLE','该类目没有有效佣金规则');
|
||||
const first=commissions[0];
|
||||
return {category_name:first.category_name ?? '',currency_code:first.currency_code,version:first.version,source:first.source ?? 'manual_rule',effective_from:new Date(first.effective_from!).toISOString(),bands:commissions.map(c=>({id:c.id!,min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate}))};
|
||||
});
|
||||
}
|
||||
async categories(workspaceId: string, storeId: string, q: string) {
|
||||
return this.db.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const store = await this.store(tx, storeId);
|
||||
@@ -174,7 +194,7 @@ export class PricingService {
|
||||
};
|
||||
});
|
||||
}
|
||||
/** 开发模式可复算草稿;保存确认时复读规则,正式模式仍只接受已审核版本。 */
|
||||
/** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */
|
||||
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
|
||||
const card = (
|
||||
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
|
||||
@@ -190,7 +210,7 @@ export class PricingService {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PRICING_RULES_UNAVAILABLE',
|
||||
'没有已审核且有效的物流费率,请先配置正式费率',
|
||||
'没有可用的物流费率,请检查费率卡状态和草稿卡使用配置',
|
||||
);
|
||||
const category = await tx.query(
|
||||
`SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`,
|
||||
@@ -227,24 +247,9 @@ export class PricingService {
|
||||
[card.id, input.pricing.transport_method_ids, card.status],
|
||||
)
|
||||
).rows;
|
||||
const mapping = (
|
||||
await tx.query<{ commission_category_id: string }>(
|
||||
`SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${active} ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`,
|
||||
[
|
||||
store.platform_id,
|
||||
input.platform_product_category_id,
|
||||
input.fulfillment,
|
||||
],
|
||||
)
|
||||
).rows[0];
|
||||
let commissions: CommissionRule[] = mapping
|
||||
? (
|
||||
await tx.query<CommissionRule>(
|
||||
`SELECT * FROM atlas.commission_category_rules WHERE commission_category_id=$1 AND fulfillment=$2 AND market_code='RU' AND currency_code IN ('CNY','RUB') AND ${active} ORDER BY id FOR SHARE`,
|
||||
[mapping.commission_category_id, input.fulfillment],
|
||||
)
|
||||
).rows
|
||||
: [];
|
||||
const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment);
|
||||
const mapping = official.mapping;
|
||||
let commissions = official.commissions;
|
||||
if (
|
||||
!commissions.length &&
|
||||
input.pricing.temporary_commission_rate !== null &&
|
||||
@@ -266,7 +271,7 @@ export class PricingService {
|
||||
throw new ApiError(
|
||||
422,
|
||||
'PRICING_COMMISSION_UNAVAILABLE',
|
||||
'该类目尚无有效佣金规则;允许临时佣金的方案可在详情中补充',
|
||||
'该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数',
|
||||
);
|
||||
if (
|
||||
commissions.some((c) => c.id) &&
|
||||
|
||||
@@ -17,6 +17,8 @@ export type ProductRow = {
|
||||
default_package_width_mm: string | null;
|
||||
default_package_height_mm: string | null;
|
||||
default_shipping_weight_g: string | null;
|
||||
package_weight_mode: 'common' | 'variant';
|
||||
package_dimensions_mode: 'common' | 'variant';
|
||||
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||
ai_source_material_overrides: unknown;
|
||||
|
||||
@@ -140,9 +140,10 @@ export class ProductEditorService {
|
||||
);
|
||||
const created = await transaction.query<{ id: string }>(
|
||||
`INSERT INTO atlas.product_platform_profiles
|
||||
(workspace_id, product_id, platform_id, target_store_id, market_code)
|
||||
(workspace_id, product_id, platform_id, target_store_id, market_code, fulfillment)
|
||||
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3,
|
||||
nullif(p.capabilities->>'default_market_code', '')
|
||||
nullif(p.capabilities->>'default_market_code', ''),
|
||||
CASE WHEN upper(p.code) = 'OZON' THEN 'rfbs' ELSE 'unknown' END
|
||||
FROM atlas.platforms p WHERE p.id = $2
|
||||
ON CONFLICT (workspace_id, product_id, platform_id) DO NOTHING
|
||||
RETURNING id`,
|
||||
@@ -434,6 +435,8 @@ export class ProductEditorService {
|
||||
},
|
||||
default_physical_spec: defaultPhysicalSpec,
|
||||
default_package_spec: defaultPackageSpec,
|
||||
package_weight_mode: product.package_weight_mode,
|
||||
package_dimensions_mode: product.package_dimensions_mode,
|
||||
},
|
||||
profile: {
|
||||
id: profile.id,
|
||||
@@ -822,6 +825,7 @@ export class ProductEditorService {
|
||||
default_package_length_mm = $11, default_package_width_mm = $12,
|
||||
default_package_height_mm = $13, default_shipping_weight_g = $14,
|
||||
physical_spec_status = $15, package_spec_status = $16,
|
||||
package_weight_mode = $21, package_dimensions_mode = $22,
|
||||
ai_source_material_overrides = $17::jsonb,
|
||||
ai_source_material_base_revision = $18,
|
||||
revision = revision + 1, updated_at = now()
|
||||
@@ -847,6 +851,8 @@ export class ProductEditorService {
|
||||
input.ai_source_material.source_snapshot_revision,
|
||||
productId,
|
||||
expected.product,
|
||||
input.common.package_weight_mode ?? product.package_weight_mode,
|
||||
input.common.package_dimensions_mode ?? product.package_dimensions_mode,
|
||||
],
|
||||
);
|
||||
if (!updatedProduct.rows[0])
|
||||
@@ -2301,10 +2307,11 @@ export class ProductEditorService {
|
||||
check(
|
||||
variant.package_spec.status,
|
||||
[
|
||||
variant.package_spec.length_mm,
|
||||
variant.package_spec.width_mm,
|
||||
variant.package_spec.height_mm,
|
||||
variant.package_spec.shipping_weight_g,
|
||||
variant.package_spec.length_mm ?? input.common.default_package_spec.length_mm,
|
||||
variant.package_spec.width_mm ?? input.common.default_package_spec.width_mm,
|
||||
variant.package_spec.height_mm ?? input.common.default_package_spec.height_mm,
|
||||
variant.package_spec.shipping_weight_g ??
|
||||
input.common.default_package_spec.shipping_weight_g,
|
||||
],
|
||||
`variants.${index}.package_spec`,
|
||||
);
|
||||
@@ -2370,6 +2377,7 @@ export class ProductEditorService {
|
||||
p.default_package_length_mm::text, p.default_package_width_mm::text,
|
||||
p.default_package_height_mm::text, p.default_shipping_weight_g::text,
|
||||
p.physical_spec_status, p.package_spec_status,
|
||||
p.package_weight_mode, p.package_dimensions_mode,
|
||||
p.ai_source_material_overrides, p.ai_source_material_base_revision,
|
||||
c.normalized_payload, c.source_snapshot_revision
|
||||
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
|
||||
@@ -2395,6 +2403,7 @@ export class ProductEditorService {
|
||||
p.default_package_length_mm::text, p.default_package_width_mm::text,
|
||||
p.default_package_height_mm::text, p.default_shipping_weight_g::text,
|
||||
p.physical_spec_status, p.package_spec_status,
|
||||
p.package_weight_mode, p.package_dimensions_mode,
|
||||
p.ai_source_material_overrides, p.ai_source_material_base_revision,
|
||||
c.normalized_payload, c.source_snapshot_revision
|
||||
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
|
||||
|
||||
@@ -30,7 +30,6 @@ type VariantValidationRow = {
|
||||
offer_id: string | null;
|
||||
selling_price: string | null;
|
||||
pricing_valid: boolean;
|
||||
pricing_development_only: boolean;
|
||||
package_length_mm: string | null;
|
||||
package_width_mm: string | null;
|
||||
package_height_mm: string | null;
|
||||
@@ -291,12 +290,6 @@ export class ProductEditorValidationOperations {
|
||||
field: `variants.${variant.id}.pricing_selection`,
|
||||
message: '请按当前采购价和包装确认报价',
|
||||
});
|
||||
if (variant.pricing_development_only)
|
||||
issues.push({
|
||||
code: 'PRICING_RATE_REVIEW_REQUIRED',
|
||||
field: `variants.${variant.id}.pricing_selection`,
|
||||
message: '当前报价使用开发草稿费率;请在费率正式审核后重新试算确认',
|
||||
});
|
||||
if (!variant.selling_price)
|
||||
issues.push({
|
||||
code: 'PRICE_REQUIRED',
|
||||
@@ -363,7 +356,6 @@ export class ProductEditorValidationOperations {
|
||||
const variants = await transaction.query<VariantValidationRow>(
|
||||
`SELECT v.id, v.display_name, listing.status AS listing_status,
|
||||
listing.offer_id, listing.selling_price::text, listing.pricing_valid,
|
||||
bool_or(coalesce(quote.input_snapshot->'context'->>'development_only','false')='true') AS pricing_development_only,
|
||||
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,
|
||||
coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm,
|
||||
@@ -374,7 +366,6 @@ export class ProductEditorValidationOperations {
|
||||
JOIN atlas.products product ON product.id = v.product_id
|
||||
JOIN atlas.product_variant_platform_listings listing
|
||||
ON listing.product_variant_id = v.id AND listing.profile_id = $1
|
||||
LEFT JOIN atlas.pricing_quotes quote ON quote.id = listing.pricing_quote_id
|
||||
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[]))
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { randomUUID, randomInt } from 'node:crypto';
|
||||
import { loadEnvFile } from 'node:process';
|
||||
import { Client } from 'pg';
|
||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||
import type {
|
||||
CommissionImportRequest,
|
||||
CommissionImportPublish,
|
||||
} from '@atlas/contracts';
|
||||
import { CommissionImportService } from '../src/pricing/commission-import.service.js';
|
||||
import { parseCommissionDocument } from '../src/pricing/commission-import-parser.js';
|
||||
import { IdempotencyService } from '../src/common/idempotency.service.js';
|
||||
import { PricingService } from '../src/pricing/pricing.service.js';
|
||||
import { fingerprint } from '../src/pricing/pricing-engine.js';
|
||||
import type { AppConfig } from '../src/config/app-config.module.js';
|
||||
loadEnvFile(new URL('../../../.env', import.meta.url));
|
||||
const makeDocument = (
|
||||
parent = '900000001',
|
||||
child = '900000002',
|
||||
rates = ['12.00', '14.00', '20.50'],
|
||||
) => ({
|
||||
code: 1,
|
||||
msg: '获取成功',
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
label: '测试分组',
|
||||
value: '测试分组',
|
||||
cate_id: parent,
|
||||
children: [
|
||||
{
|
||||
id: '1.1',
|
||||
label: '测试类目',
|
||||
value: '测试类目',
|
||||
cate_id: child,
|
||||
children: rates.map((rate, i) => ({
|
||||
id: `1.1.${i + 1}`,
|
||||
label: `${['售价 ≤ 1500₽', '1500₽ < 售价 ≤ 5000₽', '售价 > 5000₽'][i]} (${rate}%)`,
|
||||
value: `${i + 1},${rate}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
describe('佣金来源解析', () => {
|
||||
it('保留三个精确区间并将百分数转换为比例', () => {
|
||||
const c = parseCommissionDocument(makeDocument())[0];
|
||||
expect(c.bands.map((b) => b.commission_rate)).toEqual([
|
||||
'0.120000',
|
||||
'0.140000',
|
||||
'0.205000',
|
||||
]);
|
||||
expect(
|
||||
c.bands.map((b) => [
|
||||
b.min_price,
|
||||
b.max_price,
|
||||
b.min_inclusive,
|
||||
b.max_inclusive,
|
||||
]),
|
||||
).toEqual([
|
||||
['0', '1500', false, true],
|
||||
['1500', '5000', false, true],
|
||||
['5000', null, false, false],
|
||||
]);
|
||||
});
|
||||
it('拒绝标签和值不一致、漏档、重复类目及超大文件', () => {
|
||||
const d = makeDocument();
|
||||
d.data[0].children[0].children[0].value = '1,15.00';
|
||||
expect(() => parseCommissionDocument(d)).toThrow();
|
||||
expect(() => parseCommissionDocument(makeDocument('1', '1'))).toThrow();
|
||||
const missing = makeDocument();
|
||||
missing.data[0].children[0].children.pop();
|
||||
expect(() => parseCommissionDocument(missing)).toThrow();
|
||||
expect(() =>
|
||||
parseCommissionDocument({ x: 'a'.repeat(2 * 1024 * 1024) }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// 所有业务数据在测试事务内,结束一律回滚;不会发布或替换用户的实际佣金版本。
|
||||
describe('佣金导入 PostgreSQL 集成', () => {
|
||||
let client: Client,
|
||||
db: Database,
|
||||
service: CommissionImportService,
|
||||
pricing: PricingService;
|
||||
let user: string,
|
||||
workspace: string,
|
||||
platform: string,
|
||||
store: string,
|
||||
leaf: string,
|
||||
oldId: string,
|
||||
oldRule: string;
|
||||
let request: CommissionImportRequest;
|
||||
let savepoint = 0;
|
||||
beforeEach(async () => {
|
||||
const connection = new URL(process.env.MIGRATOR_DATABASE_URL!);
|
||||
connection.username = process.env.POSTGRES_ADMIN_USER!;
|
||||
connection.password = process.env.POSTGRES_ADMIN_PASSWORD!;
|
||||
client = new Client({ connectionString: connection.toString() });
|
||||
await client.connect();
|
||||
await client.query('BEGIN');
|
||||
user = randomUUID();
|
||||
workspace = randomUUID();
|
||||
await client.query(
|
||||
`INSERT INTO atlas.users(id,email,password_hash) VALUES($1,$2,'test-only')`,
|
||||
[user, `${user}@example.test`],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO atlas.workspaces(id,name,slug) VALUES($1,'佣金回滚测试',$2)`,
|
||||
[workspace, workspace],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO atlas.workspace_memberships(workspace_id,user_id,role) VALUES($1,$2,'owner')`,
|
||||
[workspace, user],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
|
||||
[user],
|
||||
);
|
||||
await client.query('SET LOCAL ROLE atlas_app');
|
||||
await client.query(`SELECT set_config('app.workspace_id',$1,true)`, [
|
||||
workspace,
|
||||
]);
|
||||
platform = (
|
||||
await client.query(
|
||||
`SELECT id FROM atlas.platforms WHERE upper(code::text)='OZON'`,
|
||||
)
|
||||
).rows[0].id;
|
||||
store = (
|
||||
await client.query(
|
||||
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,status,credential_status) VALUES($1,$2,'佣金测试',$3,'RUB','active','valid') RETURNING id`,
|
||||
[workspace, platform, randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
const p = String(randomInt(100000000, 999999990)),
|
||||
c = String(Number(p) + 1);
|
||||
const parent = (
|
||||
await client.query(
|
||||
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,'0','测试分组','测试分组',false) RETURNING id`,
|
||||
[platform, p],
|
||||
)
|
||||
).rows[0].id;
|
||||
const cat = (
|
||||
await client.query(
|
||||
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path,is_active) VALUES($1,$2,$3,'0','测试类目','测试分组 / 测试类目',false) RETURNING id`,
|
||||
[platform, parent, c],
|
||||
)
|
||||
).rows[0].id;
|
||||
leaf = (
|
||||
await client.query(
|
||||
`INSERT INTO atlas.platform_product_categories(platform_id,parent_id,external_category_id,external_type_id,name,path) VALUES($1,$2,$3,'991','测试类型','测试类型') RETURNING id`,
|
||||
[platform, cat, c],
|
||||
)
|
||||
).rows[0].id;
|
||||
request = {
|
||||
platform_id: platform,
|
||||
version: `qa-${randomUUID()}`,
|
||||
source_reference: '自动化测试,事务回滚',
|
||||
captured_on: '2026-09-28',
|
||||
document: makeDocument(p, c),
|
||||
};
|
||||
const oldCategory = parseCommissionDocument(
|
||||
makeDocument(p, c, ['10.00', '11.00', '12.00']),
|
||||
)[0];
|
||||
oldCategory.mapped_types = 1;
|
||||
await client.query(
|
||||
`UPDATE atlas.commission_imports SET is_current=false WHERE platform_id=$1 AND is_current`,
|
||||
[platform],
|
||||
);
|
||||
oldId = (
|
||||
await client.query(
|
||||
`INSERT INTO atlas.commission_imports(platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by,activated_at) VALUES($1,$2,'旧测试版本','2026-09-27','{}',$3,$4,'{}',$5,now()-interval '1 day') RETURNING id`,
|
||||
[
|
||||
platform,
|
||||
`old-${randomUUID()}`,
|
||||
fingerprint(randomUUID()),
|
||||
JSON.stringify([oldCategory]),
|
||||
user,
|
||||
],
|
||||
)
|
||||
).rows[0].id;
|
||||
const cc = (
|
||||
await client.query(
|
||||
`INSERT INTO atlas.commission_categories(platform_id,source_category_key,name,path,source,status) VALUES($1,$2,'测试类目',$3,'competitor_capture','active') RETURNING id`,
|
||||
[platform, `maozierp:${c}`, `test-${c}`],
|
||||
)
|
||||
).rows[0].id;
|
||||
oldRule = (
|
||||
await client.query(
|
||||
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,commission_rate,source,version,status,effective_from,import_id) VALUES($1,'rfbs','RU','RUB',0.1,'competitor_capture','old','active',now()-interval '1 day',$2) RETURNING id`,
|
||||
[cc, oldId],
|
||||
)
|
||||
).rows[0].id;
|
||||
await client.query(
|
||||
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,source,status,effective_from,import_id) VALUES($1,$2,$3,'rfbs','competitor_capture','active',now()-interval '1 day',$4)`,
|
||||
[platform, leaf, cc, oldId],
|
||||
);
|
||||
const transaction = async <T>(
|
||||
work: (tx: DatabaseTransaction) => Promise<T>,
|
||||
) => {
|
||||
const sp = `operation_${++savepoint}`;
|
||||
await client.query(`SAVEPOINT ${sp}`);
|
||||
try {
|
||||
const result = await work(client);
|
||||
await client.query(`RELEASE SAVEPOINT ${sp}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
await client.query(`ROLLBACK TO SAVEPOINT ${sp}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
db = {
|
||||
transaction,
|
||||
query: (sql: string, args: unknown[]) =>
|
||||
transaction((tx) => tx.query(sql, args)),
|
||||
workspaceTransaction: (
|
||||
id: string,
|
||||
work: (tx: DatabaseTransaction) => Promise<unknown>,
|
||||
) =>
|
||||
transaction(async (tx) => {
|
||||
await tx.query(
|
||||
`SELECT set_config('app.workspace_id',$1,true)`,
|
||||
[id],
|
||||
);
|
||||
return work(tx);
|
||||
}),
|
||||
} as unknown as Database;
|
||||
const config = {
|
||||
IDEMPOTENCY_KEY_SECRET: 'commission-test-idempotency-secret',
|
||||
pricingAllowDraftRates: false,
|
||||
} as AppConfig;
|
||||
const idem = new IdempotencyService(db, config);
|
||||
service = new CommissionImportService(db, idem);
|
||||
pricing = new PricingService(db, idem, config);
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
await client.query('ROLLBACK');
|
||||
await client.end();
|
||||
}
|
||||
});
|
||||
const publishInput = async (
|
||||
s: CommissionImportService,
|
||||
r: CommissionImportRequest,
|
||||
): Promise<CommissionImportPublish> => ({
|
||||
...r,
|
||||
preview_hash: (await s.preview(r)).preview_hash,
|
||||
confirm_scope: true,
|
||||
});
|
||||
it('预览差异,启用三档规则,按内部及外部类目查询;旧规则保留', async () => {
|
||||
const p = await service.preview(request);
|
||||
expect(p.summary.eligible).toBe(1);
|
||||
expect(p.categories[0].change).toBe('changed');
|
||||
const r = await service.publish(
|
||||
workspace,
|
||||
user,
|
||||
randomUUID(),
|
||||
await publishInput(service, request),
|
||||
);
|
||||
expect(r.status).toBe(201);
|
||||
const rules = await pricing.commissionRules(workspace, store, leaf);
|
||||
expect(rules.bands.map((b) => b.commission_rate)).toEqual([
|
||||
'0.120000',
|
||||
'0.140000',
|
||||
'0.205000',
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await pricing.commissionRules(
|
||||
workspace,
|
||||
store,
|
||||
undefined,
|
||||
p.categories[0].source_category_id,
|
||||
)
|
||||
).bands,
|
||||
).toEqual(rules.bands);
|
||||
expect(
|
||||
(
|
||||
await client.query(
|
||||
`SELECT status,commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
|
||||
[oldRule],
|
||||
)
|
||||
).rows[0],
|
||||
).toEqual({ status: 'archived', commission_rate: '0.100000' });
|
||||
expect((await service.preview(request)).preview_hash).not.toBe(
|
||||
p.preview_hash,
|
||||
);
|
||||
});
|
||||
it('相同幂等请求只发布一次,旧预览不能再启用', async () => {
|
||||
const input = await publishInput(service, request),
|
||||
key = randomUUID();
|
||||
const a = await service.publish(workspace, user, key, input);
|
||||
expect(await service.publish(workspace, user, key, input)).toEqual(
|
||||
JSON.parse(JSON.stringify(a)),
|
||||
);
|
||||
await expect(
|
||||
service.publish(workspace, user, randomUUID(), input),
|
||||
).rejects.toMatchObject({
|
||||
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
|
||||
});
|
||||
const fresh = await publishInput(service, request);
|
||||
await expect(
|
||||
service.publish(workspace, user, randomUUID(), fresh),
|
||||
).rejects.toMatchObject({
|
||||
response: { error: { code: 'COMMISSION_IMPORT_EXISTS' } },
|
||||
});
|
||||
});
|
||||
it('0% 佣金可以启用并用于类目查询', async () => {
|
||||
const d = request.document as ReturnType<typeof makeDocument>;
|
||||
d.data[0].children[0].children = makeDocument('1', '2', [
|
||||
'0.00',
|
||||
'0.00',
|
||||
'0.00',
|
||||
]).data[0].children[0].children;
|
||||
const p = await service.preview(request);
|
||||
expect(p.summary).toMatchObject({
|
||||
eligible: 1,
|
||||
excluded: 0,
|
||||
mapped_types: 1,
|
||||
});
|
||||
await service.publish(
|
||||
workspace,
|
||||
user,
|
||||
randomUUID(),
|
||||
await publishInput(service, request),
|
||||
);
|
||||
expect(
|
||||
(await pricing.commissionRules(workspace, store, leaf)).bands.map(
|
||||
(b) => b.commission_rate,
|
||||
),
|
||||
).toEqual(['0.000000', '0.000000', '0.000000']);
|
||||
});
|
||||
it('错误父类目被提示;无可映射规则时拒绝空版本', async () => {
|
||||
const d = request.document as ReturnType<typeof makeDocument>;
|
||||
d.data[0].cate_id = '123';
|
||||
expect(
|
||||
(await service.preview(request)).categories[0].issues.join(),
|
||||
).toContain('父类目');
|
||||
await expect(
|
||||
service.publish(
|
||||
workspace,
|
||||
user,
|
||||
randomUUID(),
|
||||
await publishInput(service, request),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
response: { error: { code: 'COMMISSION_IMPORT_INVALID' } },
|
||||
});
|
||||
});
|
||||
it('目录变化后必须重新预览', async () => {
|
||||
const input = await publishInput(service, request);
|
||||
await client.query(
|
||||
`UPDATE atlas.platform_product_categories SET is_active=false WHERE id=$1`,
|
||||
[leaf],
|
||||
);
|
||||
await expect(
|
||||
service.publish(workspace, user, randomUUID(), input),
|
||||
).rejects.toMatchObject({
|
||||
response: { error: { code: 'COMMISSION_PREVIEW_STALE' } },
|
||||
});
|
||||
});
|
||||
it('撤销管理员授权阻止发布,其他工作空间不能读取门店规则', async () => {
|
||||
const input = await publishInput(service, request);
|
||||
await client.query('RESET ROLE');
|
||||
await client.query(
|
||||
`UPDATE atlas.system_admin_grants SET revoked_at=now() WHERE user_id=$1`,
|
||||
[user],
|
||||
);
|
||||
await client.query('SET LOCAL ROLE atlas_app');
|
||||
await expect(
|
||||
service.publish(workspace, user, randomUUID(), input),
|
||||
).rejects.toMatchObject({
|
||||
response: { error: { code: 'SYSTEM_ADMIN_REQUIRED' } },
|
||||
});
|
||||
await expect(
|
||||
pricing.commissionRules(randomUUID(), store, leaf),
|
||||
).rejects.toMatchObject({
|
||||
response: { error: { code: 'STORE_NOT_FOUND' } },
|
||||
});
|
||||
});
|
||||
it('有目录异常时无需额外确认,仍可启用其他可用类目', async () => {
|
||||
const d = request.document as ReturnType<typeof makeDocument>;
|
||||
d.data[0].children.push(
|
||||
makeDocument('1', '899999999').data[0].children[0],
|
||||
);
|
||||
const preview = await service.preview(request);
|
||||
expect(preview.summary).toMatchObject({ eligible: 1, excluded: 1 });
|
||||
const input = await publishInput(service, request);
|
||||
await service.publish(workspace, user, randomUUID(), input);
|
||||
expect(
|
||||
(await pricing.commissionRules(workspace, store, leaf)).bands,
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
(
|
||||
await service.detail((await service.list(platform))[0].id)
|
||||
).categories.filter((c) => c.status === 'excluded'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
it('后续写入失败时旧版本保持有效', async () => {
|
||||
const input = await publishInput(service, request);
|
||||
const original = client.query.bind(client);
|
||||
const spy = vi.spyOn(client, 'query').mockImplementation(((
|
||||
sql: string,
|
||||
args: unknown[],
|
||||
) => {
|
||||
if (
|
||||
typeof sql === 'string' &&
|
||||
sql.includes(
|
||||
'INSERT INTO atlas.platform_product_category_commission_mappings',
|
||||
)
|
||||
)
|
||||
throw new Error('模拟映射写入失败');
|
||||
return original(sql, args);
|
||||
}) as typeof client.query);
|
||||
try {
|
||||
await expect(
|
||||
service.publish(workspace, user, randomUUID(), input),
|
||||
).rejects.toThrow('模拟映射写入失败');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await client.query(
|
||||
'SELECT is_current FROM atlas.commission_imports WHERE id=$1',
|
||||
[oldId],
|
||||
)
|
||||
).rows[0].is_current,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await client.query(
|
||||
'SELECT status FROM atlas.commission_category_rules WHERE id=$1',
|
||||
[oldRule],
|
||||
)
|
||||
).rows[0].status,
|
||||
).toBe('active');
|
||||
expect(
|
||||
(
|
||||
await client.query(
|
||||
'SELECT id FROM atlas.commission_imports WHERE version=$1',
|
||||
[request.version],
|
||||
)
|
||||
).rowCount,
|
||||
).toBe(0);
|
||||
});
|
||||
it('数据库拒绝直接改写已启用的历史费率', async () => {
|
||||
await expect(
|
||||
db.transaction((tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.commission_category_rules SET commission_rate=0.9 WHERE id=$1`,
|
||||
[oldRule],
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ code: '23514' });
|
||||
expect(
|
||||
(
|
||||
await client.query(
|
||||
`SELECT commission_rate FROM atlas.commission_category_rules WHERE id=$1`,
|
||||
[oldRule],
|
||||
)
|
||||
).rows[0].commission_rate,
|
||||
).toBe('0.100000');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'vitest';
|
||||
import { draftPricingEnabled } from '../src/config/app-config.module.js';
|
||||
|
||||
describe('草稿费率开发模式边界', () => {
|
||||
describe('草稿费率配置', () => {
|
||||
it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => {
|
||||
assert.equal(draftPricingEnabled('development', 'auto'), true);
|
||||
assert.equal(draftPricingEnabled('development', 'false'), false);
|
||||
@@ -10,8 +10,9 @@ describe('草稿费率开发模式边界', () => {
|
||||
assert.equal(draftPricingEnabled('test', 'true'), true);
|
||||
});
|
||||
|
||||
it('生产环境即使误设开关也不能使用草稿费率', () => {
|
||||
assert.equal(draftPricingEnabled('production', 'true'), false);
|
||||
assert.equal(draftPricingEnabled('production', 'auto'), false);
|
||||
it('生产环境默认允许草稿卡,可显式关闭', () => {
|
||||
assert.equal(draftPricingEnabled('production', 'true'), true);
|
||||
assert.equal(draftPricingEnabled('production', 'auto'), true);
|
||||
assert.equal(draftPricingEnabled('production', 'false'), false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,17 @@ describe('十进制定价引擎', () => {
|
||||
expect(o.can_confirm).toBe(false);
|
||||
expect(Number(o.profit_cny)).toBeLessThan(0);
|
||||
});
|
||||
it('零佣金仍生成正式报价,平台佣金费用为零', () => {
|
||||
const o = calculatePricing(
|
||||
input(),
|
||||
'RUB',
|
||||
'12.3',
|
||||
[rule],
|
||||
[{ ...commission, commission_rate: '0.000000' }],
|
||||
).options[0];
|
||||
expect(o.commission_rate).toBe('0.000000');
|
||||
expect(o.fees.commission_cny).toBe('0.0000');
|
||||
});
|
||||
it('实际重、尺寸和开区间边界精确生效', () => {
|
||||
const i = input();
|
||||
i.package.shipping_weight_g = '500';
|
||||
@@ -241,3 +252,74 @@ describe('十进制定价引擎', () => {
|
||||
expect(option.can_confirm).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('导入佣金的卢布分档边界', () => {
|
||||
const bands: CommissionRule[] = [
|
||||
{
|
||||
...commission,
|
||||
min_price: '0',
|
||||
max_price: '1500',
|
||||
min_inclusive: false,
|
||||
commission_rate: '0.12',
|
||||
},
|
||||
{
|
||||
...commission,
|
||||
min_price: '1500',
|
||||
max_price: '5000',
|
||||
min_inclusive: false,
|
||||
commission_rate: '0.14',
|
||||
},
|
||||
{
|
||||
...commission,
|
||||
min_price: '5000',
|
||||
max_price: null,
|
||||
min_inclusive: false,
|
||||
commission_rate: '0.205',
|
||||
},
|
||||
];
|
||||
it.each([
|
||||
['1500', '0.12'],
|
||||
['1500.01', '0.14'],
|
||||
['5000', '0.14'],
|
||||
['5000.01', '0.205'],
|
||||
])('售价 %s 只命中对应佣金档', (price, rate) => {
|
||||
const i = input();
|
||||
i.pricing.mode = 'fixed_sale';
|
||||
i.pricing.selling_price = price;
|
||||
const result = calculatePricing(
|
||||
i,
|
||||
'RUB',
|
||||
'12.3',
|
||||
[{ ...rule, max_merchandise_value: null }],
|
||||
bands,
|
||||
);
|
||||
expect(result.options).toHaveLength(1);
|
||||
expect(Number(result.options[0].commission_rate)).toBe(Number(rate));
|
||||
});
|
||||
it('人民币售价换算卢布后匹配费率', () => {
|
||||
const i = input();
|
||||
i.pricing.mode = 'fixed_sale';
|
||||
i.pricing.selling_price = '150';
|
||||
const result = calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'10',
|
||||
[{ ...rule, max_merchandise_value: null }],
|
||||
bands,
|
||||
);
|
||||
expect(result.options).toHaveLength(1);
|
||||
expect(Number(result.options[0].commission_rate)).toBe(0.12);
|
||||
i.pricing.selling_price = '150.01';
|
||||
expect(
|
||||
Number(
|
||||
calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'10',
|
||||
[{ ...rule, max_merchandise_value: null }],
|
||||
bands,
|
||||
).options[0].commission_rate,
|
||||
),
|
||||
).toBe(0.14);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -577,11 +577,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
|
||||
);
|
||||
const input = quote();
|
||||
input.pricing.temporary_commission_rate = null;
|
||||
assert.equal(
|
||||
(await request('/pricing/quote-previews', 'POST', input)).error
|
||||
.code,
|
||||
'PRICING_COMMISSION_UNAVAILABLE',
|
||||
const withoutCommission = await request(
|
||||
'/pricing/quote-previews',
|
||||
'POST',
|
||||
input,
|
||||
);
|
||||
assert.equal(withoutCommission.error.code, 'PRICING_COMMISSION_UNAVAILABLE');
|
||||
assert.match(withoutCommission.error.message, /本次自定义参数/);
|
||||
input.pricing.temporary_commission_rate = '0.12';
|
||||
input.pricing.mode = 'fixed_sale';
|
||||
input.pricing.selling_price = '10';
|
||||
@@ -598,7 +600,7 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
|
||||
};
|
||||
assert.equal((await save(state, payload)).status, 409);
|
||||
});
|
||||
it('开发草稿可试算并确认到商品编辑页,但不能通过首次上架校验', async () => {
|
||||
it('草稿费率可试算、确认且不阻断首次上架预检', async () => {
|
||||
await db.query(
|
||||
`UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`,
|
||||
[card],
|
||||
@@ -637,11 +639,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
|
||||
{ 'X-Atlas-Platform-Id': platform },
|
||||
);
|
||||
assert.equal(validation.status, 200, JSON.stringify(validation));
|
||||
assert.ok(
|
||||
assert.equal(
|
||||
validation.data.variants[0].issues.some(
|
||||
(issue: { code: string }) =>
|
||||
issue.code === 'PRICING_RATE_REVIEW_REQUIRED',
|
||||
issue.code === 'PRICING_RATE_REVIEW_REQUIRED' ||
|
||||
issue.code === 'PRICING_REQUIRED',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>(
|
||||
'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1',
|
||||
|
||||
@@ -276,6 +276,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
assert.equal(editor.response.status, 200);
|
||||
assert.equal(editor.body.data.product.id, first.productId);
|
||||
assert.equal(editor.body.data.profile.id, first.profileId);
|
||||
assert.equal(editor.body.data.profile.fulfillment, 'rfbs');
|
||||
assert.equal(editor.body.data.variants.length, 1);
|
||||
assert.equal(editor.body.data.variants[0].listing.status, 'not_listed');
|
||||
});
|
||||
@@ -2189,4 +2190,129 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('包装重量模式独立保存,并允许 SKU 已确认重量继承通用尺寸', async () => {
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
const store = await tx.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.stores
|
||||
WHERE workspace_id = current_setting('app.workspace_id')::uuid
|
||||
AND platform_id = $1 AND is_default = true LIMIT 1`,
|
||||
[platformId],
|
||||
);
|
||||
if (!store.rows.length)
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.stores
|
||||
(workspace_id, platform_id, name, platform_client_id,
|
||||
currency_code, is_default, status, credential_status)
|
||||
VALUES (current_setting('app.workspace_id')::uuid, $1,
|
||||
'包装规格模式测试店铺', $2, 'RUB', true, 'active', 'valid')`,
|
||||
[platformId, `editor-package-${randomUUID()}`],
|
||||
);
|
||||
});
|
||||
const created = await createCollectionItem('包装规格模式测试商品');
|
||||
const entered = await request(
|
||||
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(entered.response.status, 201);
|
||||
const before = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(before.response.status, 200);
|
||||
const snapshot = before.body.data;
|
||||
const variant = snapshot.variants[0];
|
||||
const payload = {
|
||||
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: {
|
||||
length_mm: '120',
|
||||
width_mm: '60',
|
||||
height_mm: '30',
|
||||
shipping_weight_g: null,
|
||||
status: 'missing',
|
||||
},
|
||||
package_weight_mode: 'variant',
|
||||
package_dimensions_mode: 'common',
|
||||
},
|
||||
profile: {
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
fulfillment: 'rfbs',
|
||||
title: snapshot.profile.title,
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
platform_product_category_id: snapshot.profile.category.id,
|
||||
category_source: snapshot.profile.category.source,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
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: variant.title,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
package_spec: {
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
height_mm: null,
|
||||
shipping_weight_g: '400',
|
||||
status: 'confirmed',
|
||||
},
|
||||
offer_id: variant.listing.offer_id,
|
||||
media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id),
|
||||
},
|
||||
],
|
||||
};
|
||||
const saved = await request(`/api/products/${created.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
|
||||
const after = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(after.body.data.product.package_weight_mode, 'variant');
|
||||
assert.equal(after.body.data.product.package_dimensions_mode, 'common');
|
||||
assert.equal(after.body.data.product.default_package_spec.shipping_weight_g, null);
|
||||
assert.equal(after.body.data.variants[0].package_spec.shipping_weight_g, '400.000');
|
||||
assert.equal(after.body.data.variants[0].effective_package_spec.length_mm, '120.000');
|
||||
assert.equal(after.body.data.variants[0].effective_package_spec.status, 'confirmed');
|
||||
const {
|
||||
package_weight_mode: _weightMode,
|
||||
package_dimensions_mode: _dimensionsMode,
|
||||
...legacyCommon
|
||||
} = payload.common;
|
||||
const legacySave = await request(`/api/products/${created.productId}/editor-draft`, {
|
||||
method: 'PUT',
|
||||
headers: headers({
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': randomUUID(),
|
||||
'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`,
|
||||
}),
|
||||
body: JSON.stringify({ ...payload, common: legacyCommon }),
|
||||
});
|
||||
assert.equal(legacySave.response.status, 200, JSON.stringify(legacySave.body));
|
||||
const unchangedMode = await request(`/api/products/${created.productId}/editor`, {
|
||||
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||
});
|
||||
assert.equal(unchangedMode.body.data.product.package_weight_mode, 'variant');
|
||||
});
|
||||
});
|
||||
|
||||
+14
-2
@@ -5,6 +5,10 @@ import { router } from './router';
|
||||
import { SessionProvider } from '@/services/session';
|
||||
import './App.css';
|
||||
|
||||
// 控件圆角与 SKU 编辑表单一致;页面卡片仍使用原有的基础圆角。
|
||||
const controlCorners = { borderRadius: 7, borderRadiusSM: 7, borderRadiusLG: 7 };
|
||||
const fieldStyle = { ...controlCorners, controlHeight: 35 };
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
@@ -12,11 +16,19 @@ function App() {
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#8b5cf6',
|
||||
// 全局圆角 10px,输入框/按钮/卡片等同步生效
|
||||
// 卡片等容器维持现有圆角,交互控件使用下方独立配置。
|
||||
borderRadius: 10,
|
||||
// 全局正文字号沿用 Ant Design 默认值
|
||||
fontSize: 14,
|
||||
},
|
||||
components: {
|
||||
Button: controlCorners,
|
||||
Input: fieldStyle,
|
||||
InputNumber: fieldStyle,
|
||||
Select: fieldStyle,
|
||||
Cascader: fieldStyle,
|
||||
TreeSelect: fieldStyle,
|
||||
DatePicker: fieldStyle,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Input } from 'antd';
|
||||
import type { InputProps } from 'antd';
|
||||
|
||||
const digitsOnly = /^\d+$/;
|
||||
|
||||
export function isPositivePackageInteger(value: unknown): value is string {
|
||||
return typeof value === 'string' && digitsOnly.test(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
export const packageIntegerRule = {
|
||||
validator: (_: unknown, value: unknown) =>
|
||||
value === null || value === undefined || value === '' || isPositivePackageInteger(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入大于 0 的整数')),
|
||||
};
|
||||
|
||||
/** 保留旧小数原值供人工核对,新输入仅接收数字,避免自动取整造成计费数据变化。 */
|
||||
export function PackageIntegerInput({ onChange, ...props }: InputProps) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
inputMode="numeric"
|
||||
onChange={(event) => {
|
||||
if (/^\d*$/.test(event.target.value)) onChange?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -29,8 +29,23 @@ import type {
|
||||
} from '@atlas/contracts';
|
||||
import { pricingPreviewRequestSchema } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { PackageIntegerInput } from '@/components/PackageIntegerInput';
|
||||
import { percentToRate, rateToPercent } from './pricingPercentage';
|
||||
import { pricingInputProblem } from './pricingInputValidation';
|
||||
|
||||
function detachPolicy(p: PricingParameters): PricingParameters {
|
||||
return {
|
||||
...p,
|
||||
pricing_policy_id: null,
|
||||
fx:
|
||||
p.fx.mode === 'policy_fixed'
|
||||
? p.fx.rate
|
||||
? { mode: 'one_time_override', rate: p.fx.rate, reason: '沿用原方案固定汇率' }
|
||||
: { mode: 'system' }
|
||||
: p.fx,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultPricing(catalog: PricingCatalog): PricingParameters | null {
|
||||
const policy = catalog.policies.find(
|
||||
@@ -63,13 +78,19 @@ export function PricingParametersFields({
|
||||
onChange,
|
||||
catalog,
|
||||
policyEditing = false,
|
||||
expandFees = false,
|
||||
}: {
|
||||
policyEditing?: boolean;
|
||||
expandFees?: boolean;
|
||||
value: PricingParameters;
|
||||
onChange: (p: PricingParameters) => void;
|
||||
catalog: PricingCatalog;
|
||||
}) {
|
||||
const patch = (v: Partial<PricingParameters>) => onChange({ ...p, ...v });
|
||||
const [feesOpen, setFeesOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (expandFees) setFeesOpen(true);
|
||||
}, [expandFees]);
|
||||
const rates: Array<[keyof PricingParameters, string]> = [
|
||||
['desired_markup_rate', '目标加价率'],
|
||||
['list_price_discount_rate', '划线折扣率'],
|
||||
@@ -100,7 +121,7 @@ export function PricingParametersFields({
|
||||
mode: p.mode,
|
||||
selling_price: p.selling_price,
|
||||
});
|
||||
else patch({ pricing_policy_id: null });
|
||||
else onChange(detachPolicy(p));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -112,13 +133,13 @@ export function PricingParametersFields({
|
||||
value={p.rate_card_id}
|
||||
options={catalog.rate_cards.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} · ${x.version}${x.development_only ? ' · 开发草稿' : ''}`,
|
||||
label: `${x.name} · ${x.version}${x.development_only ? ' · 待审核费率' : ''}`,
|
||||
}))}
|
||||
onChange={(id) => {
|
||||
const c = catalog.rate_cards.find((x) => x.id === id)!;
|
||||
patch({
|
||||
onChange({
|
||||
...detachPolicy(p),
|
||||
rate_card_id: id,
|
||||
pricing_policy_id: null,
|
||||
transport_method_ids: c.transports.map((t) => t.id),
|
||||
default_transport_method_id: c.transports[0]?.id ?? null,
|
||||
});
|
||||
@@ -148,6 +169,8 @@ export function PricingParametersFields({
|
||||
目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。
|
||||
</Typography.Text>
|
||||
<Collapse
|
||||
activeKey={feesOpen ? ['fees'] : []}
|
||||
onChange={(keys) => setFeesOpen(keys.includes('fees'))}
|
||||
items={[
|
||||
{
|
||||
key: 'fees',
|
||||
@@ -230,10 +253,35 @@ export function PricingParametersFields({
|
||||
step="1"
|
||||
precision={2}
|
||||
addonAfter="%"
|
||||
value={p.temporary_commission_rate === null ? null : rateToPercent(p.temporary_commission_rate)}
|
||||
onChange={(v) => patch({ temporary_commission_rate: v === null ? null : percentToRate(v) })}
|
||||
disabled={policyEditing}
|
||||
value={
|
||||
p.temporary_commission_rate === null
|
||||
? null
|
||||
: rateToPercent(p.temporary_commission_rate)
|
||||
}
|
||||
onChange={(v) => {
|
||||
const rate = v === null ? null : percentToRate(v);
|
||||
const selected = catalog.policies.find(
|
||||
(policy) => policy.id === p.pricing_policy_id
|
||||
);
|
||||
if (
|
||||
rate !== null &&
|
||||
selected &&
|
||||
!selected.allow_commission_override
|
||||
)
|
||||
onChange({
|
||||
...detachPolicy(p),
|
||||
temporary_commission_rate: rate,
|
||||
});
|
||||
else patch({ temporary_commission_rate: rate });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{!policyEditing && (
|
||||
<Typography.Text type="secondary">
|
||||
仅在缺少正式佣金规则时使用。方案不允许临时佣金时,填写后改为本次自定义参数;请依据已核实的类目费率输入。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -282,6 +330,7 @@ export function PricingCalculator({
|
||||
const [preview, setPreview] = useState<PricingPreview | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [commissionError, setCommissionError] = useState<'missing' | 'forbidden' | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sequence = useRef(0);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
@@ -295,10 +344,16 @@ export function PricingCalculator({
|
||||
controller = new AbortController();
|
||||
setPreview(null);
|
||||
setError('');
|
||||
setCommissionError(null);
|
||||
setBusy(false);
|
||||
const problem = pricingInputProblem(input);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return () => controller.abort();
|
||||
}
|
||||
const parsed = pricingPreviewRequestSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map((i) => i.message).join(';'));
|
||||
setError('请检查定价方案、运输方式和汇率参数');
|
||||
return () => controller.abort();
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
@@ -309,11 +364,24 @@ export function PricingCalculator({
|
||||
if (sequence.current === seq) {
|
||||
setPreview(p);
|
||||
setSelected(p.recommended_option_id);
|
||||
setCommissionError(null);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (sequence.current === seq && !controller.signal.aborted)
|
||||
setError(apiErrorMessage(e));
|
||||
if (sequence.current === seq && !controller.signal.aborted) {
|
||||
const code = apiErrorCode(e);
|
||||
if (code === 'PRICING_COMMISSION_UNAVAILABLE') {
|
||||
setCommissionError('missing');
|
||||
setError(
|
||||
input.pricing.temporary_commission_rate === null
|
||||
? '该类目缺少有效佣金规则,请填写已核实的临时佣金率'
|
||||
: '当前方案不允许使用临时佣金,请改用本次自定义参数或允许覆盖的方案'
|
||||
);
|
||||
} else if (code === 'PRICING_COMMISSION_OVERRIDE_FORBIDDEN') {
|
||||
setCommissionError('forbidden');
|
||||
setError('该类目已有正式佣金规则,请清空临时佣金率');
|
||||
} else setError(apiErrorMessage(e));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (sequence.current === seq) setBusy(false);
|
||||
@@ -340,9 +408,10 @@ export function PricingCalculator({
|
||||
const saved = await pricingApi.createPolicy(input.store_id, {
|
||||
name: policyName ?? '',
|
||||
note: null,
|
||||
allow_commission_override: !!localCatalog.policies.find(
|
||||
(x) => x.id === p.pricing_policy_id
|
||||
)?.allow_commission_override,
|
||||
allow_commission_override:
|
||||
p.temporary_commission_rate !== null ||
|
||||
!!localCatalog.policies.find((x) => x.id === p.pricing_policy_id)
|
||||
?.allow_commission_override,
|
||||
parameters: {
|
||||
...p,
|
||||
pricing_policy_id: null,
|
||||
@@ -437,16 +506,17 @@ export function PricingCalculator({
|
||||
<Col span={12} key={k}>
|
||||
<label>
|
||||
{label}
|
||||
<InputNumber<string>
|
||||
<PackageIntegerInput
|
||||
aria-label={label}
|
||||
style={{ width: '100%' }}
|
||||
stringMode
|
||||
min="0.001"
|
||||
value={input.package[k]}
|
||||
onChange={(v) =>
|
||||
onChange={(event) =>
|
||||
setInput({
|
||||
...input,
|
||||
package: { ...input.package, [k]: v ?? '' },
|
||||
package: {
|
||||
...input.package,
|
||||
[k]: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -493,6 +563,7 @@ export function PricingCalculator({
|
||||
<PricingParametersFields
|
||||
value={input.pricing}
|
||||
catalog={localCatalog}
|
||||
expandFees={commissionError !== null}
|
||||
onChange={(pricing) => setInput({ ...input, pricing })}
|
||||
/>
|
||||
</Space>
|
||||
@@ -513,7 +584,17 @@ export function PricingCalculator({
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
试算结果 {busy && <Tag color="processing">计算中</Tag>}
|
||||
</Typography.Title>
|
||||
{error && <Alert type="warning" title={error} />}
|
||||
{error && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title={error}
|
||||
description={
|
||||
commissionError === 'missing'
|
||||
? '左侧“国内费用、汇率与临时佣金”已展开。临时费率仅用于本次试算,不会保存到定价方案。'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{expired && (
|
||||
<Alert
|
||||
type="warning"
|
||||
@@ -560,6 +641,11 @@ export function PricingCalculator({
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
{option.commission_details && (
|
||||
<Alert type="info" title={`平台佣金 ${rateToPercent(option.commission_rate)}% · ${option.commission_details.category_name}`} description={
|
||||
`${option.commission_details.min_price === null ? '' : `${option.commission_details.min_price} ${option.commission_details.min_inclusive ? '≤' : '<'} `}售价${option.commission_details.max_price === null ? '' : ` ${option.commission_details.max_inclusive ? '≤' : '<'} ${option.commission_details.max_price}`} ${option.commission_details.currency_code} · 版本 ${option.commission_details.version} · ${option.commission_details.source === 'competitor_capture' ? '导入并审核' : '正式规则'}`
|
||||
} />
|
||||
)}
|
||||
{option.warnings.map((w) => (
|
||||
<Alert key={w} title={w} type="warning" />
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, InputNumber, Modal, Space, Tag, Typography } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, Form, InputNumber, Modal, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
@@ -12,8 +13,9 @@ import type {
|
||||
} from '@atlas/contracts';
|
||||
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { defaultPricing, PricingCalculator } from './PricingCalculator';
|
||||
import { pricingInputProblem } from './pricingInputValidation';
|
||||
type Values = SaveProductEditorDraftRequest;
|
||||
export function skuPricingInput(
|
||||
values: Values,
|
||||
@@ -24,6 +26,12 @@ export function skuPricingInput(
|
||||
const v = values.variants[index],
|
||||
base = values.common.default_package_spec,
|
||||
pkg = v.package_spec;
|
||||
const effectivePackage = {
|
||||
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
|
||||
length_mm: pkg.length_mm ?? base.length_mm ?? '',
|
||||
width_mm: pkg.width_mm ?? base.width_mm ?? '',
|
||||
height_mm: pkg.height_mm ?? base.height_mm ?? '',
|
||||
};
|
||||
return {
|
||||
store_id: values.profile.target_store_id,
|
||||
platform_product_category_id: values.profile.platform_product_category_id ?? '',
|
||||
@@ -35,16 +43,23 @@ export function skuPricingInput(
|
||||
},
|
||||
purchase_cost_cny: v.purchase_cost_cny ?? '',
|
||||
package: {
|
||||
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
|
||||
length_mm: pkg.length_mm ?? base.length_mm ?? '',
|
||||
width_mm: pkg.width_mm ?? base.width_mm ?? '',
|
||||
height_mm: pkg.height_mm ?? base.height_mm ?? '',
|
||||
status: (pkg.status ?? base.status) as 'confirmed',
|
||||
...effectivePackage,
|
||||
status: Object.values(effectivePackage).every(Boolean)
|
||||
? 'confirmed'
|
||||
: ((pkg.status ?? base.status) as 'confirmed'),
|
||||
},
|
||||
pricing: parameters,
|
||||
};
|
||||
}
|
||||
const keyFor = pricingInputKey;
|
||||
type PricingSlots = { status: ReactNode; summary: ReactNode; details: ReactNode };
|
||||
|
||||
function cny(value: string | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const amount = Number(value);
|
||||
return Number.isFinite(amount) ? `¥ ${amount.toFixed(2)}` : '—';
|
||||
}
|
||||
|
||||
export function SkuPricing({
|
||||
form,
|
||||
index,
|
||||
@@ -52,6 +67,7 @@ export function SkuPricing({
|
||||
auto,
|
||||
batch,
|
||||
onChanged,
|
||||
children,
|
||||
}: {
|
||||
form: FormInstance<Values>;
|
||||
index: number;
|
||||
@@ -59,6 +75,7 @@ export function SkuPricing({
|
||||
auto: boolean;
|
||||
batch: number;
|
||||
onChanged: () => void;
|
||||
children: (slots: PricingSlots) => ReactNode;
|
||||
}) {
|
||||
Form.useWatch('variants', form);
|
||||
Form.useWatch('common', form);
|
||||
@@ -104,7 +121,10 @@ export function SkuPricing({
|
||||
},
|
||||
[]
|
||||
);
|
||||
const params = v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null);
|
||||
const fulfillmentSupported = values.profile?.fulfillment === 'rfbs';
|
||||
const params = fulfillmentSupported
|
||||
? (v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null))
|
||||
: null;
|
||||
const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
|
||||
const liveKey = live ? keyFor(live) : '';
|
||||
const liveRef = useRef(liveKey);
|
||||
@@ -193,10 +213,14 @@ export function SkuPricing({
|
||||
}
|
||||
async function calculate(force = false, override?: PricingParameters) {
|
||||
if (!catalog || !v || original?.listing.status === 'imported') return;
|
||||
if (!fulfillmentSupported) {
|
||||
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
|
||||
return;
|
||||
}
|
||||
const current = form.getFieldValue(['variants', index]) as Values['variants'][number];
|
||||
const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog);
|
||||
if (!parameters) {
|
||||
setError('没有已审核物流费率,请先配置费率');
|
||||
setError('没有可用的物流费率,请检查费率卡和草稿卡使用配置');
|
||||
return;
|
||||
}
|
||||
const input = skuPricingInput(
|
||||
@@ -206,10 +230,15 @@ export function SkuPricing({
|
||||
parameters
|
||||
),
|
||||
inputKey = keyFor(input);
|
||||
const problem = pricingInputProblem(input);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
if (!force && ((lastRequest.current === inputKey && resultValid) || confirmed)) return;
|
||||
const parsed = pricingPreviewRequestSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
setError('请补齐采购价、类目,并确认有效包装重量和尺寸');
|
||||
setError('请检查定价方案、运输方式和汇率参数');
|
||||
return;
|
||||
}
|
||||
setParameters(parameters);
|
||||
@@ -238,7 +267,13 @@ export function SkuPricing({
|
||||
apply(input, p, recommended.id, false);
|
||||
} catch (e) {
|
||||
if (seq === sequence.current && !controller.current.signal.aborted)
|
||||
setError(apiErrorMessage(e));
|
||||
setError(
|
||||
apiErrorCode(e) === 'PRICING_COMMISSION_UNAVAILABLE'
|
||||
? input.pricing.temporary_commission_rate === null
|
||||
? '该类目缺少佣金规则;打开“试算详情”,填写已核实的临时佣金率'
|
||||
: '当前方案不允许临时佣金;打开“试算详情”,改用本次自定义参数'
|
||||
: apiErrorMessage(e)
|
||||
);
|
||||
} finally {
|
||||
if (seq === sequence.current) setBusy(false);
|
||||
}
|
||||
@@ -271,177 +306,259 @@ export function SkuPricing({
|
||||
}, [batch]);
|
||||
if (!v) return null;
|
||||
const imported = original?.listing.status === 'imported';
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'start', flexWrap: 'wrap', flex: 1 }}>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>采购价(CNY)</span>
|
||||
<InputNumber<string>
|
||||
aria-label={`SKU ${index + 1} 采购价`}
|
||||
min="0"
|
||||
stringMode
|
||||
precision={2}
|
||||
value={v.purchase_cost_cny}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue(['variants', index, 'purchase_cost_cny'], value);
|
||||
onChanged();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (auto && !imported) void calculate();
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
event.preventDefault();
|
||||
void calculate(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>
|
||||
销售价({catalog?.store.currency_code ?? original?.listing.currency_code ?? '—'}
|
||||
)
|
||||
</span>
|
||||
<InputNumber<string>
|
||||
aria-label={`SKU ${index + 1} 销售价`}
|
||||
min="0.01"
|
||||
stringMode
|
||||
precision={2}
|
||||
disabled={imported || !params}
|
||||
value={
|
||||
params?.mode === 'fixed_sale'
|
||||
? params.selling_price
|
||||
: (option?.selling_price ?? original?.listing.selling_price)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (params)
|
||||
setParameters({ ...params, mode: 'fixed_sale', selling_price: value });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (auto) void calculate(true);
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
event.preventDefault();
|
||||
void calculate(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sku-card-value">
|
||||
<span>预估净利润(CNY)</span>
|
||||
<strong
|
||||
style={{
|
||||
color: option && Number(option.profit_cny) < 0 ? '#cf1322' : '#389e0d',
|
||||
}}
|
||||
>
|
||||
{option?.profit_cny ?? '—'}
|
||||
</strong>
|
||||
</div>
|
||||
{!imported && (
|
||||
<Space>
|
||||
<Button loading={busy} onClick={() => void calculate(true)}>
|
||||
计算
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!catalog || !params}
|
||||
onClick={() => {
|
||||
restoreInheritance.current = false;
|
||||
setModal(
|
||||
skuPricingInput(
|
||||
form.getFieldsValue(true),
|
||||
index,
|
||||
snapshot.profile.id,
|
||||
params!
|
||||
)
|
||||
);
|
||||
}}
|
||||
const currency = catalog?.store.currency_code ?? original?.listing.currency_code ?? 'CNY';
|
||||
const saleCny =
|
||||
params?.mode === 'fixed_sale' && currency === 'CNY'
|
||||
? params.selling_price
|
||||
: (option?.merchandise_value_cny ??
|
||||
original?.listing.pricing_result?.merchandise_value_cny);
|
||||
const profitCny = option?.profit_cny;
|
||||
const statusText = busy
|
||||
? '计算中'
|
||||
: error || !fulfillmentSupported
|
||||
? '定价需处理'
|
||||
: confirmed
|
||||
? '定价已确认'
|
||||
: resultValid
|
||||
? result?.option.can_confirm === false
|
||||
? '亏损 · 请调整售价'
|
||||
: '已试算 · 待保存'
|
||||
: v.pricing_selection
|
||||
? '输入已变化 · 需重算'
|
||||
: '待定价';
|
||||
const needsAttention =
|
||||
!!error ||
|
||||
!fulfillmentSupported ||
|
||||
(resultValid && result?.option.can_confirm === false) ||
|
||||
(!!v.pricing_selection && !resultValid);
|
||||
const policyName =
|
||||
catalog?.policies.find((policy) => policy.id === params?.pricing_policy_id)?.name ??
|
||||
'本次参数';
|
||||
const feedback =
|
||||
error ||
|
||||
(!fulfillmentSupported
|
||||
? '当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式'
|
||||
: v.pricing_selection && !resultValid && !confirmed
|
||||
? '定价输入已变化,请重新试算后保存。'
|
||||
: confirmed
|
||||
? '当前输入与已保存的试算一致。'
|
||||
: resultValid
|
||||
? '试算完成,保存商品后确认本次报价。'
|
||||
: '填写采购价和包装规格后可开始试算。');
|
||||
return children({
|
||||
status: (
|
||||
<span
|
||||
className={`sku-pricing-status${needsAttention ? ' sku-pricing-status--warning' : confirmed ? ' sku-pricing-status--confirmed' : ''}`}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
),
|
||||
summary: (
|
||||
<div className="sku-pricing-summary">
|
||||
<div>
|
||||
<span>采购价</span>
|
||||
<strong>{cny(v.purchase_cost_cny)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>售价</span>
|
||||
<strong>{cny(saleCny)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预估净利润</span>
|
||||
<strong
|
||||
className={
|
||||
profitCny && Number(profitCny) < 0
|
||||
? 'sku-pricing-loss'
|
||||
: profitCny
|
||||
? 'sku-pricing-profit'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
试算详情
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
<div style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Tag color={confirmed ? 'success' : resultValid ? 'purple' : 'default'}>
|
||||
{busy
|
||||
? '计算中'
|
||||
: confirmed
|
||||
? '已确认'
|
||||
: resultValid
|
||||
? result?.option.can_confirm === false
|
||||
? '亏损 · 请调整售价'
|
||||
: '已试算 · 待保存'
|
||||
: v.pricing_selection
|
||||
? '输入已变化 · 需重新试算'
|
||||
: '待定价'}
|
||||
</Tag>
|
||||
{catalog?.rate_cards.find((card) => card.id === params?.rate_card_id)
|
||||
?.development_only && <Tag color="orange">开发草稿费率 · 不可上架</Tag>}
|
||||
{params?.mode === 'fixed_sale' && (
|
||||
<>
|
||||
<Tag>手动售价</Tag>
|
||||
{cny(profitCny)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
details: (
|
||||
<section className="sku-pricing-panel" aria-label={`SKU ${index + 1} 定价`}>
|
||||
<div className="sku-panel-heading">
|
||||
<h5>定价</h5>
|
||||
<div className="sku-panel-actions">
|
||||
{!imported && (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
loading={busy}
|
||||
type="primary"
|
||||
onClick={() => void calculate(true)}
|
||||
>
|
||||
重新试算
|
||||
</Button>
|
||||
)}
|
||||
{!imported && (
|
||||
<Button
|
||||
disabled={!catalog || !params}
|
||||
onClick={() => {
|
||||
const p = {
|
||||
...params,
|
||||
mode: 'target_markup' as const,
|
||||
selling_price: null,
|
||||
};
|
||||
setParameters(p);
|
||||
void calculate(true, p);
|
||||
restoreInheritance.current = false;
|
||||
setModal(
|
||||
skuPricingInput(
|
||||
form.getFieldsValue(true),
|
||||
index,
|
||||
snapshot.profile.id,
|
||||
params!
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
使用建议售价
|
||||
试算详情
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text type="secondary">
|
||||
{catalog?.policies.find((p) => p.id === params?.pricing_policy_id)?.name ??
|
||||
'本次参数'}
|
||||
{option ? ` · ${option.transport_name}` : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{error && (
|
||||
<div role="alert" style={{ color: '#ad6800', fontSize: 12 }}>
|
||||
{error}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
title={`${v.display_name ?? 'SKU'} · 定价试算`}
|
||||
open={!!modal}
|
||||
width={1000}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={() => setModal(null)}
|
||||
>
|
||||
{modal && catalog && (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
restoreInheritance.current = true;
|
||||
const base = form.getFieldValue(['common', 'default_package_spec']);
|
||||
setModal({ ...modal, package: { ...base, status: base.status } });
|
||||
</div>
|
||||
<div className="sku-pricing-fields">
|
||||
<label>
|
||||
<span>采购价(人民币)</span>
|
||||
<InputNumber<string>
|
||||
aria-label={`SKU ${index + 1} 采购价(人民币)`}
|
||||
min="0"
|
||||
stringMode
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={v.purchase_cost_cny}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue(['variants', index, 'purchase_cost_cny'], value);
|
||||
onChanged();
|
||||
}}
|
||||
>
|
||||
使用 SPU 包装重新试算
|
||||
</Button>
|
||||
<PricingCalculator
|
||||
key={JSON.stringify(modal)}
|
||||
initial={modal}
|
||||
previous={result?.option ?? original?.listing.pricing_result}
|
||||
catalog={catalog}
|
||||
onCancel={() => setModal(null)}
|
||||
onApply={(input, p, id) => {
|
||||
apply(input, p, id, true);
|
||||
setModal(null);
|
||||
onBlur={() => {
|
||||
if (auto && !imported) void calculate();
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
event.preventDefault();
|
||||
void calculate(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
</label>
|
||||
<label>
|
||||
<span>售价(人民币)</span>
|
||||
{currency === 'CNY' ? (
|
||||
<InputNumber<string>
|
||||
aria-label={`SKU ${index + 1} 售价(人民币)`}
|
||||
min="0.01"
|
||||
stringMode
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
disabled={imported || !params}
|
||||
value={
|
||||
params?.mode === 'fixed_sale'
|
||||
? params.selling_price
|
||||
: (option?.selling_price ?? original?.listing.selling_price)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (params)
|
||||
setParameters({
|
||||
...params,
|
||||
mode: 'fixed_sale',
|
||||
selling_price: value,
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (auto) void calculate(true);
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
event.preventDefault();
|
||||
void calculate(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="sku-pricing-readonly">{cny(saleCny)}</div>
|
||||
)}
|
||||
</label>
|
||||
<div>
|
||||
<span>预估净利润(人民币)</span>
|
||||
<div
|
||||
className={`sku-pricing-readonly${profitCny && Number(profitCny) < 0 ? ' sku-pricing-loss' : ' sku-pricing-profit'}`}
|
||||
>
|
||||
{cny(profitCny)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`sku-pricing-feedback${needsAttention ? ' sku-pricing-feedback--warning' : confirmed ? ' sku-pricing-feedback--confirmed' : ''}`}
|
||||
role={error ? 'alert' : 'status'}
|
||||
>
|
||||
<span className="sku-pricing-feedback-dot" aria-hidden="true" />
|
||||
<span>{feedback}</span>
|
||||
</div>
|
||||
<div className="sku-pricing-meta">
|
||||
<span>
|
||||
{policyName}
|
||||
{option ? ` · ${option.transport_name}` : ''}
|
||||
</span>
|
||||
{currency !== 'CNY' && (
|
||||
<span>
|
||||
平台报价为 {currency};人民币售价仅供参考,请在试算详情调整报价。
|
||||
</span>
|
||||
)}
|
||||
{params?.mode === 'fixed_sale' && !imported && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const next = {
|
||||
...params,
|
||||
mode: 'target_markup' as const,
|
||||
selling_price: null,
|
||||
};
|
||||
setParameters(next);
|
||||
void calculate(true, next);
|
||||
}}
|
||||
>
|
||||
使用建议售价
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
title={`${v.display_name ?? 'SKU'} · 定价试算`}
|
||||
open={!!modal}
|
||||
width={1000}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={() => setModal(null)}
|
||||
>
|
||||
{modal && catalog && (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
restoreInheritance.current = true;
|
||||
const base = form.getFieldValue([
|
||||
'common',
|
||||
'default_package_spec',
|
||||
]);
|
||||
setModal({
|
||||
...modal,
|
||||
package: { ...base, status: base.status },
|
||||
});
|
||||
}}
|
||||
>
|
||||
使用 SPU 包装重新试算
|
||||
</Button>
|
||||
<PricingCalculator
|
||||
key={JSON.stringify(modal)}
|
||||
initial={modal}
|
||||
previous={result?.option ?? original?.listing.pricing_result}
|
||||
catalog={catalog}
|
||||
onCancel={() => setModal(null)}
|
||||
onApply={(input, p, id) => {
|
||||
apply(input, p, id, true);
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PricingPreviewRequest } from '@atlas/contracts';
|
||||
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
|
||||
|
||||
const purchaseCost = /^\d{1,9}(\.\d{1,2})?$/;
|
||||
|
||||
export function pricingInputProblem(input: PricingPreviewRequest): string | null {
|
||||
if (!input.purchase_cost_cny) return '请先填写采购价(CNY)';
|
||||
if (!purchaseCost.test(input.purchase_cost_cny))
|
||||
return '采购价格式不正确:整数最多 9 位,小数最多 2 位';
|
||||
if (!input.platform_product_category_id) return '请先选择商品类目';
|
||||
if (
|
||||
![
|
||||
input.package.shipping_weight_g,
|
||||
input.package.length_mm,
|
||||
input.package.width_mm,
|
||||
input.package.height_mm,
|
||||
].every(isPositivePackageInteger)
|
||||
)
|
||||
return '包装重量和尺寸请输入大于 0 的整数(g / mm)';
|
||||
if (input.package.status !== 'confirmed')
|
||||
return '请核对包装重量和尺寸,再点击“核对后确认包装”';
|
||||
return null;
|
||||
}
|
||||
@@ -53,6 +53,11 @@ export default function SystemAdminLayout() {
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '平台类目与属性',
|
||||
},
|
||||
{
|
||||
key: '/system/commissions',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '类目佣金',
|
||||
},
|
||||
{
|
||||
key: '/system/pricing-rates',
|
||||
icon: <CalculatorOutlined />,
|
||||
@@ -68,7 +73,9 @@ export default function SystemAdminLayout() {
|
||||
系统管理 /{' '}
|
||||
{location.pathname.startsWith('/system/pricing-rates')
|
||||
? '物流费率审核'
|
||||
: '平台类目与属性'}
|
||||
: location.pathname.startsWith('/system/commissions')
|
||||
? '类目佣金'
|
||||
: '平台类目与属性'}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function PricingPage() {
|
||||
catalog && !parameters ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="当前平台没有可用于正式试算的物流费率"
|
||||
title="当前平台没有可用的物流费率"
|
||||
description={
|
||||
<Space orientation="vertical" size="small">
|
||||
{catalog.pending_rate_cards.length > 0 && (
|
||||
@@ -138,8 +138,8 @@ export default function PricingPage() {
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
待复核费率不能用于正式报价。系统管理员须在「系统管理 →
|
||||
物流费率审核」录入真实账单样本,复算通过后按生效日期启用。
|
||||
请检查费率卡状态和草稿卡使用配置。需要核实运费时,系统管理员可在「系统管理 →
|
||||
物流费率审核」录入真实账单样本并启用已审核版本。
|
||||
</span>
|
||||
{adminAccess === 'allowed' && (
|
||||
<Button
|
||||
@@ -183,15 +183,6 @@ export default function PricingPage() {
|
||||
/>
|
||||
{error && <Alert type="error" title={error} />}{' '}
|
||||
{!store && !error && <Empty description="请先创建目标门店" />}
|
||||
{catalog && (
|
||||
catalog.rate_cards.some((card) => card.development_only) && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="当前使用开发草稿费率"
|
||||
description="可用于方案、试算和商品编辑联调;按草稿费率确认的 SKU 不能首次上架,正式审核后需重新试算确认。"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{catalog && (
|
||||
<Tabs
|
||||
items={[
|
||||
|
||||
@@ -27,6 +27,7 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { productEditorApi } from '@/services/productEditor';
|
||||
import { usePlatform } from '@/services/platform';
|
||||
import { collectionApi } from '@/services/collection';
|
||||
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import {
|
||||
CollectedProductInfoPanel,
|
||||
type CollectedProductSummary,
|
||||
@@ -46,25 +47,6 @@ import './ProductEditPage.css';
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||
|
||||
/** 规格字段有值时才可据此恢复到对应的 SKU 编辑模式。 */
|
||||
function hasPackageWeight(spec: {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
}) {
|
||||
return Boolean(spec.shipping_weight_g);
|
||||
}
|
||||
|
||||
function hasPackageDimensions(spec: {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
}) {
|
||||
return Boolean(spec.length_mm && spec.width_mm && spec.height_mm);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 schema 补齐表单中缺失的属性草稿:aspect(变体维度)属性逐个 SKU 一条,
|
||||
* 其余类目级一条,确保 Form.List 字段与 schema 一一对应、可按定义渲染。
|
||||
@@ -108,7 +90,7 @@ function missingAttributeDrafts(
|
||||
}
|
||||
|
||||
/** 服务端快照 → antd 表单初值;已有变体不携带 client_key(仅新建/复制时生成)。 */
|
||||
function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
||||
function formValues(snapshot: ProductEditorSnapshot, platformCode: string): EditorFormValues {
|
||||
return {
|
||||
ai_source_material: snapshot.source_material,
|
||||
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
|
||||
@@ -116,10 +98,15 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
||||
...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec,
|
||||
package_weight_mode: snapshot.product.package_weight_mode,
|
||||
package_dimensions_mode: snapshot.product.package_dimensions_mode,
|
||||
},
|
||||
profile: {
|
||||
target_store_id: snapshot.profile.target_store.id,
|
||||
fulfillment: snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment'],
|
||||
fulfillment:
|
||||
platformCode.toUpperCase() === 'OZON' && snapshot.profile.fulfillment === 'unknown'
|
||||
? 'rfbs'
|
||||
: (snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment']),
|
||||
title: snapshot.profile.title,
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
@@ -258,6 +245,22 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
height_mm: nullable(spec.height_mm),
|
||||
shipping_weight_g: nullable(spec.shipping_weight_g),
|
||||
});
|
||||
const commonPackage = packageSpec(values.common.default_package_spec);
|
||||
const weightByVariant = values.common.package_weight_mode === 'variant';
|
||||
const dimensionsByVariant = values.common.package_dimensions_mode === 'variant';
|
||||
const packageComplete = (
|
||||
spec: Pick<
|
||||
typeof commonPackage,
|
||||
'length_mm' | 'width_mm' | 'height_mm' | 'shipping_weight_g'
|
||||
>
|
||||
) =>
|
||||
Boolean(spec.length_mm && spec.width_mm && spec.height_mm && spec.shipping_weight_g);
|
||||
commonPackage.status =
|
||||
commonPackage.status === 'ambiguous'
|
||||
? 'ambiguous'
|
||||
: packageComplete(commonPackage)
|
||||
? 'confirmed'
|
||||
: 'missing';
|
||||
return {
|
||||
...values,
|
||||
common: {
|
||||
@@ -268,7 +271,9 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
purchase_url: nullable(values.common.purchase_url),
|
||||
supply_note: nullable(values.common.supply_note),
|
||||
default_physical_spec: physical(values.common.default_physical_spec),
|
||||
default_package_spec: packageSpec(values.common.default_package_spec),
|
||||
default_package_spec: commonPackage,
|
||||
package_weight_mode: values.common.package_weight_mode ?? 'common',
|
||||
package_dimensions_mode: values.common.package_dimensions_mode ?? 'common',
|
||||
},
|
||||
profile: {
|
||||
...values.profile,
|
||||
@@ -282,21 +287,40 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean);
|
||||
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
|
||||
}),
|
||||
variants: values.variants.map((variant) => ({
|
||||
...variant,
|
||||
display_name: nullable(variant.display_name),
|
||||
source_sku: nullable(variant.source_sku),
|
||||
purchase_cost_cny: nullable(variant.purchase_cost_cny),
|
||||
offer_id: nullable(variant.offer_id),
|
||||
title: nullable(variant.title),
|
||||
specification_values: variant.specification_values.map((item) => ({
|
||||
axis_key: item.axis_key.trim(),
|
||||
value_key: item.value_key.trim(),
|
||||
value_name: item.value_name.trim(),
|
||||
})),
|
||||
physical_spec: physical(variant.physical_spec),
|
||||
package_spec: packageSpec(variant.package_spec),
|
||||
})),
|
||||
variants: values.variants.map((variant) => {
|
||||
const normalizedPackage = packageSpec(variant.package_spec);
|
||||
if (weightByVariant || dimensionsByVariant) {
|
||||
const effectivePackage = {
|
||||
...normalizedPackage,
|
||||
length_mm: normalizedPackage.length_mm ?? commonPackage.length_mm,
|
||||
width_mm: normalizedPackage.width_mm ?? commonPackage.width_mm,
|
||||
height_mm: normalizedPackage.height_mm ?? commonPackage.height_mm,
|
||||
shipping_weight_g:
|
||||
normalizedPackage.shipping_weight_g ?? commonPackage.shipping_weight_g,
|
||||
};
|
||||
normalizedPackage.status =
|
||||
normalizedPackage.status === 'ambiguous'
|
||||
? 'ambiguous'
|
||||
: packageComplete(effectivePackage)
|
||||
? 'confirmed'
|
||||
: 'missing';
|
||||
}
|
||||
return {
|
||||
...variant,
|
||||
display_name: nullable(variant.display_name),
|
||||
source_sku: nullable(variant.source_sku),
|
||||
purchase_cost_cny: nullable(variant.purchase_cost_cny),
|
||||
offer_id: nullable(variant.offer_id),
|
||||
title: nullable(variant.title),
|
||||
specification_values: variant.specification_values.map((item) => ({
|
||||
axis_key: item.axis_key.trim(),
|
||||
value_key: item.value_key.trim(),
|
||||
value_name: item.value_name.trim(),
|
||||
})),
|
||||
physical_spec: physical(variant.physical_spec),
|
||||
package_spec: normalizedPackage,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,10 +398,11 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
|
||||
const dimensions = isDimension ? millimetres(attribute.value) : null;
|
||||
const weight = isWeight ? numberInGrams(attribute.value) : null;
|
||||
if (isPackage && dimensions) {
|
||||
[result.package_length_mm, result.package_width_mm, result.package_height_mm] =
|
||||
dimensions;
|
||||
if (isPositivePackageInteger(dimensions[0])) result.package_length_mm = dimensions[0];
|
||||
if (isPositivePackageInteger(dimensions[1])) result.package_width_mm = dimensions[1];
|
||||
if (isPositivePackageInteger(dimensions[2])) result.package_height_mm = dimensions[2];
|
||||
}
|
||||
if (isPackage && weight) result.shipping_weight_g = weight;
|
||||
if (isPackage && isPositivePackageInteger(weight)) result.shipping_weight_g = weight;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -489,15 +514,9 @@ const ProductEditPage = () => {
|
||||
setSourceDraft(next.source_material);
|
||||
setSourceOverriddenFields(next.source_material_state.overridden_fields);
|
||||
sourceSpecsApplied.current = false;
|
||||
form.setFieldsValue(formValues(next));
|
||||
setUsesVariantPackageWeight(
|
||||
next.variants.length > 0 &&
|
||||
next.variants.every((variant) => hasPackageWeight(variant.package_spec))
|
||||
);
|
||||
setUsesVariantPackageDimensions(
|
||||
next.variants.length > 0 &&
|
||||
next.variants.every((variant) => hasPackageDimensions(variant.package_spec))
|
||||
);
|
||||
form.setFieldsValue(formValues(next, currentPlatform.code));
|
||||
setUsesVariantPackageWeight(next.product.package_weight_mode === 'variant');
|
||||
setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant');
|
||||
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
|
||||
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
|
||||
setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
|
||||
@@ -696,29 +715,40 @@ const ProductEditPage = () => {
|
||||
};
|
||||
}, [currentPlatform, snapshot]);
|
||||
const collected = sourceDraft ?? snapshot?.source_material ?? null;
|
||||
// 首次拿到采集数据时只自动填充 SPU 默认包装规格:只补空字段,绝不覆盖已有值;
|
||||
// 首次拿到采集数据时只自动填充通用模式下的 SPU 包装规格:只补空字段,绝不覆盖已有值;
|
||||
// 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。
|
||||
// sourceSpecsApplied 保证整个页面生命周期只执行一次。
|
||||
useEffect(() => {
|
||||
if (sourceSpecsApplied.current || !collected) return;
|
||||
const specs = detectedSpecs(collected.attributes);
|
||||
const packageSpec = form.getFieldValue(['common', 'default_package_spec']);
|
||||
const commonWeight = form.getFieldValue(['common', 'package_weight_mode']) !== 'variant';
|
||||
const commonDimensions =
|
||||
form.getFieldValue(['common', 'package_dimensions_mode']) !== 'variant';
|
||||
const hasAppliedValue = [
|
||||
[packageSpec?.length_mm, specs.package_length_mm],
|
||||
[packageSpec?.width_mm, specs.package_width_mm],
|
||||
[packageSpec?.height_mm, specs.package_height_mm],
|
||||
[packageSpec?.shipping_weight_g, specs.shipping_weight_g],
|
||||
].some(([current, detected]) => !current && !!detected);
|
||||
[commonDimensions, packageSpec?.length_mm, specs.package_length_mm],
|
||||
[commonDimensions, packageSpec?.width_mm, specs.package_width_mm],
|
||||
[commonDimensions, packageSpec?.height_mm, specs.package_height_mm],
|
||||
[commonWeight, packageSpec?.shipping_weight_g, specs.shipping_weight_g],
|
||||
].some(([enabled, current, detected]) => enabled && !current && !!detected);
|
||||
if (hasAppliedValue) {
|
||||
form.setFieldsValue({
|
||||
common: {
|
||||
default_package_spec: {
|
||||
...packageSpec,
|
||||
length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null,
|
||||
width_mm: packageSpec?.width_mm ?? specs.package_width_mm ?? null,
|
||||
height_mm: packageSpec?.height_mm ?? specs.package_height_mm ?? null,
|
||||
length_mm: commonDimensions
|
||||
? (packageSpec?.length_mm ?? specs.package_length_mm ?? null)
|
||||
: (packageSpec?.length_mm ?? null),
|
||||
width_mm: commonDimensions
|
||||
? (packageSpec?.width_mm ?? specs.package_width_mm ?? null)
|
||||
: (packageSpec?.width_mm ?? null),
|
||||
height_mm: commonDimensions
|
||||
? (packageSpec?.height_mm ?? specs.package_height_mm ?? null)
|
||||
: (packageSpec?.height_mm ?? null),
|
||||
shipping_weight_g:
|
||||
packageSpec?.shipping_weight_g ?? specs.shipping_weight_g ?? null,
|
||||
commonWeight
|
||||
? (packageSpec?.shipping_weight_g ?? specs.shipping_weight_g ?? null)
|
||||
: (packageSpec?.shipping_weight_g ?? null),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -750,53 +780,47 @@ const ProductEditPage = () => {
|
||||
}, [blocker, modal]);
|
||||
|
||||
const leave = () => navigate('/collection');
|
||||
/** 将 SPU 共用包装重量复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */
|
||||
const useVariantPackageWeight = () => {
|
||||
const defaultPackageSpec = form.getFieldValue([
|
||||
'common',
|
||||
'default_package_spec',
|
||||
]) as EditorFormValues['common']['default_package_spec'];
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => {
|
||||
const packageSpec = variant.package_spec;
|
||||
return {
|
||||
...variant,
|
||||
package_spec: {
|
||||
...packageSpec,
|
||||
shipping_weight_g:
|
||||
packageSpec.shipping_weight_g ?? defaultPackageSpec.shipping_weight_g,
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
setUsesVariantPackageWeight(true);
|
||||
/** 包装重量和尺寸独立切换;退出逐 SKU 模式时清除全部 SKU 对应覆盖值。 */
|
||||
const changePackageMode = (field: 'weight' | 'dimensions', mode: 'common' | 'variant') => {
|
||||
const keys =
|
||||
field === 'weight'
|
||||
? (['shipping_weight_g'] as const)
|
||||
: (['length_mm', 'width_mm', 'height_mm'] as const);
|
||||
const modeKey = field === 'weight' ? 'package_weight_mode' : 'package_dimensions_mode';
|
||||
if (mode === 'variant') {
|
||||
keys.forEach((key) =>
|
||||
form.setFieldValue(['common', 'default_package_spec', key], null)
|
||||
);
|
||||
form.setFieldValue(['common', 'default_package_spec', 'status'], 'missing');
|
||||
} else {
|
||||
const variants = (form.getFieldValue('variants') ?? []) as EditorFormValues['variants'];
|
||||
variants.forEach((_, index) => {
|
||||
keys.forEach((key) =>
|
||||
form.setFieldValue(['variants', index, 'package_spec', key], null)
|
||||
);
|
||||
form.setFieldValue(['variants', index, 'package_spec', 'status'], null);
|
||||
});
|
||||
}
|
||||
form.setFieldValue(['common', modeKey], mode);
|
||||
if (field === 'weight') setUsesVariantPackageWeight(mode === 'variant');
|
||||
else setUsesVariantPackageDimensions(mode === 'variant');
|
||||
setDirty(true);
|
||||
};
|
||||
/** 将 SPU 共用包装尺寸复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */
|
||||
const useVariantPackageDimensions = () => {
|
||||
const defaultPackageSpec = form.getFieldValue([
|
||||
'common',
|
||||
'default_package_spec',
|
||||
]) as EditorFormValues['common']['default_package_spec'];
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => {
|
||||
const packageSpec = variant.package_spec;
|
||||
return {
|
||||
...variant,
|
||||
package_spec: {
|
||||
...packageSpec,
|
||||
length_mm: packageSpec.length_mm ?? defaultPackageSpec.length_mm,
|
||||
width_mm: packageSpec.width_mm ?? defaultPackageSpec.width_mm,
|
||||
height_mm: packageSpec.height_mm ?? defaultPackageSpec.height_mm,
|
||||
},
|
||||
};
|
||||
}),
|
||||
const confirmCommonPackageMode = (field: 'weight' | 'dimensions') => {
|
||||
const label = field === 'weight' ? '包装重量' : '包装尺寸';
|
||||
modal.confirm({
|
||||
title: `将${label}设为通用?`,
|
||||
content: `确认后将清空所有 SKU 的${label},并恢复商品通用${label}的编辑。`,
|
||||
okText: '确认调整',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => changePackageMode(field, 'common'),
|
||||
});
|
||||
setUsesVariantPackageDimensions(true);
|
||||
setDirty(true);
|
||||
};
|
||||
const useVariantPackageWeight = () => changePackageMode('weight', 'variant');
|
||||
const useVariantPackageDimensions = () => changePackageMode('dimensions', 'variant');
|
||||
const useCommonPackageWeight = () => confirmCommonPackageMode('weight');
|
||||
const useCommonPackageDimensions = () => confirmCommonPackageMode('dimensions');
|
||||
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
|
||||
const useVariantTags = () => setUsesVariantTags(true);
|
||||
const useVariantDescription = () => setUsesVariantDescription(true);
|
||||
@@ -953,7 +977,12 @@ const ProductEditPage = () => {
|
||||
'height_mm',
|
||||
'shipping_weight_g',
|
||||
] as const) {
|
||||
if (!spec[key]) {
|
||||
if (
|
||||
(key === 'shipping_weight_g' && common.package_weight_mode === 'variant') ||
|
||||
(key !== 'shipping_weight_g' && common.package_dimensions_mode === 'variant')
|
||||
)
|
||||
continue;
|
||||
if (!spec[key] && isPositivePackageInteger(result.package_spec[key])) {
|
||||
spec[key] = result.package_spec[key];
|
||||
packageFilled = true;
|
||||
}
|
||||
@@ -1410,6 +1439,8 @@ const ProductEditPage = () => {
|
||||
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
|
||||
onUseVariantPackageWeight={useVariantPackageWeight}
|
||||
onUseVariantPackageDimensions={useVariantPackageDimensions}
|
||||
onUseCommonPackageWeight={useCommonPackageWeight}
|
||||
onUseCommonPackageDimensions={useCommonPackageDimensions}
|
||||
/>
|
||||
|
||||
<ProductAttributeSection
|
||||
@@ -1452,6 +1483,8 @@ const ProductEditPage = () => {
|
||||
onLoadMoreDictionaryValues={loadMoreDictionaryValues}
|
||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
onUseCommonPackageWeight={useCommonPackageWeight}
|
||||
onUseCommonPackageDimensions={useCommonPackageDimensions}
|
||||
usesVariantTags={usesVariantTags}
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
.product-editor .editor-section .dynamic-attribute-item .ant-form-item-label > label {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
overflow: visible;
|
||||
font-weight: 400;
|
||||
@@ -54,6 +54,24 @@
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* 横向表单的标签与输入框共用 35px 高度;长标签换行时整行仍保持垂直居中。 */
|
||||
.product-editor .editor-horizontal-section .dynamic-attribute-item .ant-form-item-label > label {
|
||||
display: flex;
|
||||
min-height: 35px;
|
||||
}
|
||||
.product-editor .editor-horizontal-section .dynamic-attribute-item .ant-form-item-row {
|
||||
align-items: center;
|
||||
}
|
||||
/* SKU 标签改为块级 flex,避免必填星号影响行盒高度,使同排输入框对齐。 */
|
||||
.product-editor .editor-section .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
|
||||
display: flex;
|
||||
min-height: 25px;
|
||||
}
|
||||
@container (max-width: 479px) {
|
||||
.product-editor .editor-horizontal-section .dynamic-attribute-item .ant-form-item-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
/* 多值字段把上限说明收进标题提示,避免额外说明行打乱 SKU 三列表单的节奏。 */
|
||||
.dynamic-attribute-label {
|
||||
display: inline-flex;
|
||||
@@ -106,10 +124,10 @@
|
||||
border-color: #667085;
|
||||
background: transparent;
|
||||
}
|
||||
/* 多选标签保持单行控件的 32px 基准高度;多项换行时仍可按内容自然增高。 */
|
||||
/* 多选标签与全站输入控件使用 35px 基准高度;多项换行时按内容自然增高。 */
|
||||
.product-color-select.ant-select-multiple .ant-select-selector {
|
||||
box-sizing: border-box;
|
||||
min-height: 32px;
|
||||
min-height: 35px;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px)
|
||||
22px 32px;
|
||||
22px max-content;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
max-width: 100%;
|
||||
@@ -31,7 +31,7 @@
|
||||
line-height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.package-dimensions-controls .ant-input-number {
|
||||
.package-dimensions-controls .ant-input {
|
||||
width: 100%;
|
||||
}
|
||||
.package-weight-control {
|
||||
@@ -39,6 +39,15 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.package-mode-button.ant-btn {
|
||||
height: 35px;
|
||||
padding-inline: 5px;
|
||||
color: #714edf;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.package-mode-button.ant-btn:hover {
|
||||
color: #6240d2;
|
||||
}
|
||||
|
||||
/* 类目下拉弹层(Cascader popup,经 classNames.root 挂载) */
|
||||
.editor-category-popup .ant-cascader-menu {
|
||||
@@ -90,6 +99,9 @@
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.package-dimensions-controls .package-mode-button {
|
||||
justify-self: start;
|
||||
}
|
||||
.package-dimension-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Button, Card, Cascader, Form, Input, InputNumber, Radio, Select, Tooltip } from 'antd';
|
||||
import { Button, Card, Cascader, Form, Input, Radio, Select, Tooltip } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import './ProductMainInfoSection.css';
|
||||
|
||||
@@ -20,62 +21,74 @@ function PackageFields({
|
||||
usesVariantPackageDimensions,
|
||||
onUseVariantPackageWeight,
|
||||
onUseVariantPackageDimensions,
|
||||
onUseCommonPackageWeight,
|
||||
onUseCommonPackageDimensions,
|
||||
}: {
|
||||
name: NamePath;
|
||||
usesVariantPackageWeight: boolean;
|
||||
usesVariantPackageDimensions: boolean;
|
||||
onUseVariantPackageWeight: () => void;
|
||||
onUseVariantPackageDimensions: () => void;
|
||||
onUseCommonPackageWeight: () => void;
|
||||
onUseCommonPackageDimensions: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="package-fields">
|
||||
{!usesVariantPackageWeight && (
|
||||
<Form.Item name={[...name, 'shipping_weight_g']} label="包装重量" required>
|
||||
<div className="package-weight-control">
|
||||
<InputNumber<string>
|
||||
min="0"
|
||||
step="0.001"
|
||||
stringMode
|
||||
<Form.Item label="包装重量" required={!usesVariantPackageWeight}>
|
||||
<div className="package-weight-control">
|
||||
<Form.Item name={[...name, 'shipping_weight_g']} noStyle rules={[packageIntegerRule]}>
|
||||
<PackageIntegerInput
|
||||
addonAfter="g"
|
||||
style={{ width: 150 }}
|
||||
disabled={usesVariantPackageWeight}
|
||||
/>
|
||||
<Tooltip title="按 SKU 分别填写包装重量">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写包装重量"
|
||||
onClick={onUseVariantPackageWeight}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!usesVariantPackageDimensions && (
|
||||
<Form.Item label="包装尺寸" required className="package-dimensions">
|
||||
<div className="package-dimensions-controls">
|
||||
<Form.Item name={[...name, 'length_mm']} noStyle>
|
||||
<InputNumber<string> min="0" step="0.001" stringMode placeholder="长" />
|
||||
</Form.Item>
|
||||
<span className="package-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'width_mm']} noStyle>
|
||||
<InputNumber<string> min="0" step="0.001" stringMode placeholder="宽" />
|
||||
</Form.Item>
|
||||
<span className="package-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'height_mm']} noStyle>
|
||||
<InputNumber<string> min="0" step="0.001" stringMode placeholder="高" />
|
||||
</Form.Item>
|
||||
<span className="package-dimension-unit">mm</span>
|
||||
<Tooltip title="按 SKU 分别填写包装尺寸">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写包装尺寸"
|
||||
onClick={onUseVariantPackageDimensions}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="link"
|
||||
className="package-mode-button"
|
||||
aria-label={`${usesVariantPackageWeight ? '设为通用' : '按 SKU 设置'}包装重量`}
|
||||
onClick={
|
||||
usesVariantPackageWeight
|
||||
? onUseCommonPackageWeight
|
||||
: onUseVariantPackageWeight
|
||||
}
|
||||
>
|
||||
{usesVariantPackageWeight ? '设为通用' : '按 SKU 设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="包装尺寸"
|
||||
required={!usesVariantPackageDimensions}
|
||||
className="package-dimensions"
|
||||
>
|
||||
<div className="package-dimensions-controls">
|
||||
<Form.Item name={[...name, 'length_mm']} noStyle rules={[packageIntegerRule]}>
|
||||
<PackageIntegerInput placeholder="长" disabled={usesVariantPackageDimensions} />
|
||||
</Form.Item>
|
||||
<span className="package-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'width_mm']} noStyle rules={[packageIntegerRule]}>
|
||||
<PackageIntegerInput placeholder="宽" disabled={usesVariantPackageDimensions} />
|
||||
</Form.Item>
|
||||
<span className="package-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'height_mm']} noStyle rules={[packageIntegerRule]}>
|
||||
<PackageIntegerInput placeholder="高" disabled={usesVariantPackageDimensions} />
|
||||
</Form.Item>
|
||||
<span className="package-dimension-unit">mm</span>
|
||||
<Button
|
||||
type="link"
|
||||
className="package-mode-button"
|
||||
aria-label={`${usesVariantPackageDimensions ? '设为通用' : '按 SKU 设置'}包装尺寸`}
|
||||
onClick={
|
||||
usesVariantPackageDimensions
|
||||
? onUseCommonPackageDimensions
|
||||
: onUseVariantPackageDimensions
|
||||
}
|
||||
>
|
||||
{usesVariantPackageDimensions ? '设为通用' : '按 SKU 设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +106,8 @@ type Props = {
|
||||
usesVariantPackageDimensions: boolean;
|
||||
onUseVariantPackageWeight: () => void;
|
||||
onUseVariantPackageDimensions: () => void;
|
||||
onUseCommonPackageWeight: () => void;
|
||||
onUseCommonPackageDimensions: () => void;
|
||||
usesVariantTitle: boolean;
|
||||
onUseVariantTitle: () => void;
|
||||
onCompareTitle: () => void;
|
||||
@@ -117,6 +132,8 @@ export function ProductMainInfoSection({
|
||||
usesVariantPackageDimensions,
|
||||
onUseVariantPackageWeight,
|
||||
onUseVariantPackageDimensions,
|
||||
onUseCommonPackageWeight,
|
||||
onUseCommonPackageDimensions,
|
||||
usesVariantTitle,
|
||||
onUseVariantTitle,
|
||||
onCompareTitle,
|
||||
@@ -172,7 +189,7 @@ export function ProductMainInfoSection({
|
||||
name={['profile', 'platform_product_category_id']}
|
||||
label="产品类目"
|
||||
className="span-full"
|
||||
rules={[{ required: true, message: '请选择产品类目' }]}
|
||||
required
|
||||
getValueProps={(categoryId: string | null) => ({
|
||||
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
|
||||
})}
|
||||
@@ -205,7 +222,8 @@ export function ProductMainInfoSection({
|
||||
name={['common', 'source_title']}
|
||||
label="中文商品名"
|
||||
className="span-full"
|
||||
rules={[{ required: true, message: '请填写中文商品名' }, { max: 1000 }]}
|
||||
required
|
||||
rules={[{ max: 1000 }]}
|
||||
>
|
||||
<Input placeholder="填写中文商品名" />
|
||||
</Form.Item>
|
||||
@@ -214,14 +232,7 @@ export function ProductMainInfoSection({
|
||||
<Form.Item
|
||||
name={['profile', 'title']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
whitespace: true,
|
||||
message: '请填写俄文发布标题',
|
||||
},
|
||||
{ max: 1000 },
|
||||
]}
|
||||
rules={[{ max: 1000 }]}
|
||||
>
|
||||
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
|
||||
</Form.Item>
|
||||
@@ -248,27 +259,27 @@ export function ProductMainInfoSection({
|
||||
<Form.Item
|
||||
name={['common', 'model']}
|
||||
label="型号"
|
||||
rules={[{ required: true, message: '请填写型号' }]}
|
||||
required
|
||||
>
|
||||
<Input className="editor-short-input" placeholder="首次上架前需填写" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['common', 'brand']}
|
||||
label="品牌"
|
||||
rules={[{ required: true, message: '请填写品牌;无品牌请填写“无品牌”' }]}
|
||||
required
|
||||
>
|
||||
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{(!usesVariantPackageWeight || !usesVariantPackageDimensions) && (
|
||||
<PackageFields
|
||||
name={['common', 'default_package_spec']}
|
||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
onUseVariantPackageWeight={onUseVariantPackageWeight}
|
||||
onUseVariantPackageDimensions={onUseVariantPackageDimensions}
|
||||
/>
|
||||
)}
|
||||
<PackageFields
|
||||
name={['common', 'default_package_spec']}
|
||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
onUseVariantPackageWeight={onUseVariantPackageWeight}
|
||||
onUseVariantPackageDimensions={onUseVariantPackageDimensions}
|
||||
onUseCommonPackageWeight={onUseCommonPackageWeight}
|
||||
onUseCommonPackageDimensions={onUseCommonPackageDimensions}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,366 +1,566 @@
|
||||
/* 「03 变体设置」区块:SKU 卡片、展开后的图片选择与规格面板。 */
|
||||
|
||||
/* SKU 卡片内的输入圆角与 antd 输入框保持一致 */
|
||||
.sku-section .ant-input {
|
||||
border-radius: 8px;
|
||||
/* SKU 卡片沿用编辑页配色,收起态仅保留识别、价格和单一试算状态。 */
|
||||
.sku-section.ant-card {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.sku-section .ant-input-number {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
.sku-section-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: #f7f5fa;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
.sku-section textarea.ant-input {
|
||||
resize: none;
|
||||
}
|
||||
.sku-section .ant-input-group-addon {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
.sku-section .ant-input-group .ant-input {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
.sku-section .ant-input-number-group .ant-input-number {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
/* 卡片列表与卡片骨架 */
|
||||
.sku-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sku-card.ant-card {
|
||||
position: relative;
|
||||
border: 1px solid #e8e4ee;
|
||||
border: 1px solid #e8e4ed;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 用 .sku-card.ant-card 提高权重:内层卡片同时命中外层
|
||||
.editor-section .ant-card-body(页面级)规则,同级特异性会按加载顺序被覆盖 */
|
||||
.sku-card.ant-card > .ant-card-body {
|
||||
padding: 12px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(74px, auto) minmax(70px, auto) auto;
|
||||
padding: 0;
|
||||
}
|
||||
.sku-main-preview {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
width: 112px;
|
||||
height: 144px;
|
||||
padding: 8px;
|
||||
}
|
||||
/* 卡片主体:左侧主图缩略图(贴边、跨"名称 + 货号"两行),右侧内容列 */
|
||||
.sku-card-main {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.sku-card-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
/* 顶部留出序号悬浮徽章的下半部分 */
|
||||
padding-top: 4px;
|
||||
}
|
||||
.sku-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 28px;
|
||||
}
|
||||
/* 序号:悬浮在卡片左上角,垂直方向一半露出卡片外 */
|
||||
.sku-card-index {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: -10px;
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #eee9fe;
|
||||
color: #7151df;
|
||||
font-weight: 700;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
/* 摘要行:名称 / 主图 / 货号 / 采购价 / 销售价 / 净利润 / 展开按钮。
|
||||
随宽度变化先压缩各字段的输入宽度,放不下时整行自动换行(不做断点隐藏)。 */
|
||||
.sku-card-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sku-card-summary .ant-form-item {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.sku-card-summary .ant-input,
|
||||
.sku-card-summary textarea.ant-input,
|
||||
.sku-card-summary .ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-card-field,
|
||||
.sku-card-value {
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* SKU 名称:无标签,直接内联在序号右侧,占据顶行剩余宽度 */
|
||||
.sku-card-name {
|
||||
flex: 1 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sku-card-field {
|
||||
/* 三个可编辑字段在同一行时,输入框允许收缩至 100px 后才换行。 */
|
||||
flex: 1 1 100px;
|
||||
min-width: 100px;
|
||||
}
|
||||
.sku-price-field {
|
||||
flex: 1 1 100px;
|
||||
}
|
||||
.sku-card-field > span,
|
||||
.sku-card-value > span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
padding: 15px 16px 12px;
|
||||
}
|
||||
.sku-main-thumbnail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
width: 75px;
|
||||
width: 96px;
|
||||
height: 128px;
|
||||
aspect-ratio: 3 / 4;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ece8f0;
|
||||
border: 1px solid #e8e4ed;
|
||||
border-radius: 8px;
|
||||
background: #f4f2f7;
|
||||
background: #f6f4f8;
|
||||
color: #90889c;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.sku-main-thumbnail .ant-image {
|
||||
position: absolute;
|
||||
inset: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sku-main-thumbnail .ant-image img {
|
||||
display: block;
|
||||
.sku-main-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
.sku-card-value {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sku-card-value strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #40394f;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sku-card-value strong {
|
||||
min-height: 32px;
|
||||
padding-top: 5px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.sku-expand-button {
|
||||
align-self: flex-start;
|
||||
margin-top: 23px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 展开区按图片、SKU 信息的顺序展示。 */
|
||||
.sku-card-expanded {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-media-panel,
|
||||
.sku-info-panel {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.sku-media-panel {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-media-panel h5.ant-typography,
|
||||
.sku-info-panel h5.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 10px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.sku-media-panel h5.ant-typography::before,
|
||||
.sku-media-panel h5.ant-typography::after,
|
||||
.sku-info-panel h5.ant-typography::before,
|
||||
.sku-info-panel h5.ant-typography::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px dashed #d9d4df;
|
||||
}
|
||||
.sku-media-local-note {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: #8d789c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-media-grid {
|
||||
display: grid;
|
||||
/* 每行固定八个图片位;主图跨两列、两行,其余图片按单格自动续排。 */
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
.sku-media-item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
/* 图片框固定为 3:4;图片容器铺满框后用 contain 水平、垂直居中。 */
|
||||
aspect-ratio: 3 / 4;
|
||||
align-self: start;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ece8f0;
|
||||
border-radius: 6px;
|
||||
background: #f5f3f7;
|
||||
}
|
||||
.sku-media-item--main {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.sku-media-main-badge {
|
||||
.sku-main-badge,
|
||||
.sku-main-count {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgb(64 57 79 / 72%);
|
||||
left: 8px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
background: rgb(41 36 51 / 82%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-media-item .ant-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
.sku-main-badge {
|
||||
top: 8px;
|
||||
}
|
||||
.sku-main-count {
|
||||
bottom: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.sku-media-item .ant-image img {
|
||||
.sku-main-count .anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-main-empty {
|
||||
padding: 4px;
|
||||
}
|
||||
.sku-card-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-card-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
.sku-card-index {
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: #f7f5fa;
|
||||
color: #77718a;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-card-name {
|
||||
min-width: 0;
|
||||
color: #292433;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sku-card-top > .ant-space {
|
||||
margin-left: auto;
|
||||
}
|
||||
.sku-card-top > .ant-space .sku-card-action.ant-btn {
|
||||
padding-inline: 4px;
|
||||
color: #714edf;
|
||||
}
|
||||
.sku-card-top > .ant-space .sku-card-action.ant-btn:hover {
|
||||
background: transparent;
|
||||
color: #714edf;
|
||||
}
|
||||
.sku-card-top > .ant-space .sku-card-action--muted.ant-btn,
|
||||
.sku-card-top > .ant-space .sku-card-action--muted.ant-btn:hover {
|
||||
color: #77718a;
|
||||
}
|
||||
.sku-card-offer {
|
||||
margin-top: 5px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sku-pricing-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 21px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
background: #f1ecff;
|
||||
color: #714edf;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sku-pricing-status--warning {
|
||||
background: #fff5e6;
|
||||
color: #956014;
|
||||
}
|
||||
.sku-pricing-status--confirmed {
|
||||
background: #eef8f1;
|
||||
color: #237b4c;
|
||||
}
|
||||
.sku-card-summary {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 10px 16px 11px;
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-pricing-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-pricing-summary > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-pricing-summary span,
|
||||
.sku-pricing-fields > label > span,
|
||||
.sku-pricing-fields > div > span {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
margin-bottom: 4px;
|
||||
color: #77718a;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sku-media-picker.ant-btn {
|
||||
.sku-pricing-summary strong {
|
||||
display: block;
|
||||
color: #292433;
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-pricing-summary .sku-pricing-profit,
|
||||
.sku-pricing-readonly.sku-pricing-profit {
|
||||
color: #237b4c;
|
||||
}
|
||||
.sku-pricing-summary .sku-pricing-loss,
|
||||
.sku-pricing-readonly.sku-pricing-loss {
|
||||
color: #c62843;
|
||||
}
|
||||
.sku-expand-button.ant-btn {
|
||||
border-color: #e8e4ed;
|
||||
border-radius: 7px;
|
||||
color: #292433;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-card-expanded {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
padding: 0 18px 18px;
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-pricing-panel,
|
||||
.sku-info-panel,
|
||||
.sku-media-panel {
|
||||
padding-top: 18px;
|
||||
}
|
||||
.sku-info-panel,
|
||||
.sku-media-panel {
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid #e8e4ed;
|
||||
}
|
||||
.sku-panel-heading {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
align-self: start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #90889c;
|
||||
font-size: 13px;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sku-media-picker--empty {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
.sku-panel-heading h5 {
|
||||
margin: 0;
|
||||
color: #292433;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sku-media-count {
|
||||
margin-left: 6px;
|
||||
color: #77718a;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.sku-panel-actions {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sku-panel-actions .ant-btn,
|
||||
.sku-media-panel .ant-btn,
|
||||
.sku-expand-button.ant-btn {
|
||||
border-radius: 7px;
|
||||
}
|
||||
.sku-pricing-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 11px;
|
||||
}
|
||||
.sku-pricing-fields > label,
|
||||
.sku-pricing-fields > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-pricing-fields .ant-input-number {
|
||||
width: 100%;
|
||||
min-height: 35px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.sku-pricing-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 35px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #e8e4ed;
|
||||
border-radius: 7px;
|
||||
background: #faf9fc;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sku-pricing-feedback {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 11px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 7px;
|
||||
background: #f1ecff;
|
||||
color: #40394f;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-pricing-feedback--warning {
|
||||
background: #fff5e6;
|
||||
}
|
||||
.sku-pricing-feedback--confirmed {
|
||||
background: #eef8f1;
|
||||
}
|
||||
.sku-pricing-feedback-dot {
|
||||
flex: 0 0 6px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background: #714edf;
|
||||
}
|
||||
.sku-pricing-feedback--warning .sku-pricing-feedback-dot {
|
||||
background: #956014;
|
||||
}
|
||||
.sku-pricing-feedback--confirmed .sku-pricing-feedback-dot {
|
||||
background: #237b4c;
|
||||
}
|
||||
.sku-pricing-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 9px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-info-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px 12px;
|
||||
}
|
||||
.sku-info-panel h5.ant-typography,
|
||||
.sku-info-panel > .ant-typography {
|
||||
.sku-info-panel > .sku-panel-heading,
|
||||
.sku-info-group-title,
|
||||
.sku-info-inherit-note,
|
||||
.sku-info-panel > .dynamic-attribute-list {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.sku-info-panel > .sku-panel-heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.sku-info-group-title {
|
||||
margin-top: 4px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-info-panel .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* SKU 信息保持标签在上、控件在下,独立于页面其他横向表单布局。 */
|
||||
.sku-info-panel .ant-form-item-row {
|
||||
display: block;
|
||||
}
|
||||
.sku-info-panel .ant-form-item-label {
|
||||
display: block;
|
||||
min-height: 26px;
|
||||
padding: 0 0 4px;
|
||||
padding: 0 0 5px;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-info-panel .ant-form-item-label > label {
|
||||
align-items: center;
|
||||
.sku-info-panel .ant-form-item-label > label,
|
||||
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
|
||||
justify-content: flex-start;
|
||||
font-weight: 600;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sku-info-panel .ant-form-item-control {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
.sku-info-panel .ant-input,
|
||||
.sku-info-panel .ant-input-number,
|
||||
.sku-info-panel .ant-select-selector {
|
||||
width: 100%;
|
||||
min-height: 35px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.sku-info-panel .ant-input-number,
|
||||
.sku-info-panel .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-info-panel .ant-input-textarea textarea {
|
||||
min-height: 54px;
|
||||
resize: vertical;
|
||||
}
|
||||
.sku-info-panel .ant-input-group-addon {
|
||||
border-radius: 0 7px 7px 0;
|
||||
}
|
||||
.sku-info-panel .ant-space-compact .ant-btn {
|
||||
min-height: 35px;
|
||||
}
|
||||
.sku-info-package-row {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 11px 12px;
|
||||
}
|
||||
.sku-info-package-row .sku-info-package-dimensions {
|
||||
grid-column: span 2;
|
||||
.sku-info-package-row > :only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.sku-info-variant-description {
|
||||
grid-column: span 2;
|
||||
.sku-info-panel .sku-info-package-row .ant-form-item-label > label {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-package-label {
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-package-label .ant-btn {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 0 2px;
|
||||
color: #714edf;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sku-package-label .ant-btn:hover {
|
||||
color: #6240d2;
|
||||
}
|
||||
.sku-info-variant-description,
|
||||
.sku-info-variant-title,
|
||||
.sku-info-variant-tags {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
/* 所有动态 SKU 属性与包装、文案字段共用“SKU 信息”网格。 */
|
||||
.sku-info-panel .dynamic-attribute-list {
|
||||
display: contents;
|
||||
}
|
||||
.sku-info-panel .dynamic-attribute-item {
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label {
|
||||
display: block;
|
||||
padding: 0 0 4px;
|
||||
text-align: left;
|
||||
}
|
||||
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 5px;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr)
|
||||
28px;
|
||||
gap: 6px;
|
||||
grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr) 12px minmax(0, 1fr) 25px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.sku-dimension-separator {
|
||||
color: #98a2b3;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
.sku-inline-dimensions .ant-input,
|
||||
.sku-inline-dimensions .ant-input-number {
|
||||
min-width: 0;
|
||||
}
|
||||
.sku-dimension-separator,
|
||||
.sku-dimension-unit {
|
||||
color: #77718a;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
}
|
||||
.sku-info-inherit-note {
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 图片顺序与主图选择共用现有选图数据,不新增单独的主图字段。 */
|
||||
.sku-media-layout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.sku-media-main {
|
||||
position: relative;
|
||||
flex: 0 0 133px;
|
||||
width: 133px;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #714edf;
|
||||
border-radius: 9px;
|
||||
background: #f6f4f8;
|
||||
}
|
||||
.sku-media-main--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-style: dashed;
|
||||
color: #90889c;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-media-main .ant-image {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.sku-media-main .ant-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.sku-media-main-badge {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
background: #714edf;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sku-media-main-caption {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 8px 4px 5px;
|
||||
background: linear-gradient(transparent, rgb(30 20 45 / 70%));
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sku-media-other {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
.sku-media-other-item {
|
||||
display: flex;
|
||||
width: 64px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.sku-media-other-item .ant-image {
|
||||
display: flex;
|
||||
width: 64px;
|
||||
height: 78px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e8e4ed;
|
||||
border-radius: 7px;
|
||||
background: #f6f4f8;
|
||||
}
|
||||
.sku-media-other-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.sku-media-other-item .ant-btn {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.sku-media-add.ant-btn {
|
||||
width: 64px;
|
||||
height: 78px;
|
||||
border: 1px solid #e8e4ed;
|
||||
border-radius: 7px;
|
||||
background: #f6f4f8;
|
||||
}
|
||||
.sku-media-other-item:hover .ant-image {
|
||||
border-color: #714edf;
|
||||
}
|
||||
.sku-media-add.ant-btn {
|
||||
color: #77718a;
|
||||
font-size: 22px;
|
||||
}
|
||||
.sku-media-note {
|
||||
margin: 10px 0 0;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
.add-sku-row {
|
||||
display: flex;
|
||||
@@ -370,24 +570,59 @@
|
||||
.add-sku-button {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sku-inline-dimensions {
|
||||
@container (max-width: 620px) {
|
||||
.sku-card.ant-card > .ant-card-body {
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(128px, auto) auto auto;
|
||||
}
|
||||
.sku-main-preview {
|
||||
grid-row: 1;
|
||||
width: 96px;
|
||||
height: 128px;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
.sku-main-thumbnail {
|
||||
width: 84px;
|
||||
height: 112px;
|
||||
}
|
||||
.sku-card-main {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sku-card-top > .ant-space {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.sku-card-summary {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sku-inline-dimensions > span {
|
||||
display: none;
|
||||
.sku-expand-button.ant-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.sku-info-panel {
|
||||
grid-template-columns: 1fr;
|
||||
.sku-pricing-fields {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sku-pricing-fields > div:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@container (max-width: 430px) {
|
||||
.sku-pricing-summary strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
.sku-pricing-fields,
|
||||
.sku-info-panel,
|
||||
.sku-info-package-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sku-info-package-row .sku-info-package-dimensions {
|
||||
grid-column: span 1;
|
||||
.sku-pricing-fields > div:last-child,
|
||||
.sku-info-variant-description,
|
||||
.sku-info-variant-title,
|
||||
.sku-info-variant-tags {
|
||||
grid-column: auto;
|
||||
}
|
||||
.sku-info-variant-description {
|
||||
grid-column: span 1;
|
||||
.sku-panel-heading {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import './SectionTitle.css';
|
||||
|
||||
type Props = {
|
||||
number: string;
|
||||
title: string;
|
||||
title: ReactNode;
|
||||
description?: string;
|
||||
extra?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PictureOutlined } from '@ant-design/icons';
|
||||
|
||||
type Props = {
|
||||
imageUrl?: string;
|
||||
imageCount: number;
|
||||
sourcePreview: boolean;
|
||||
};
|
||||
|
||||
/** 收起态主图沿用 SKU 图片顺序,来源候选图不计入已选图片数量。 */
|
||||
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
|
||||
const imageLabel = sourcePreview ? '来源图' : '主图';
|
||||
|
||||
return (
|
||||
<div className="sku-main-preview">
|
||||
<div
|
||||
className="sku-main-thumbnail"
|
||||
role="img"
|
||||
aria-label={`SKU ${sourcePreview ? '来源图片' : '主图'},已选 ${imageCount} 张图片`}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<>
|
||||
<img src={imageUrl} alt="" />
|
||||
<span className="sku-main-badge">{imageLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="sku-main-empty">暂无主图</span>
|
||||
)}
|
||||
<span className="sku-main-count" aria-hidden="true">
|
||||
<PictureOutlined /> {sourcePreview ? '已选 0 张' : `${imageCount} 张图`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +213,7 @@
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
border-radius: 7px;
|
||||
background: rgb(255 255 255 / 94%);
|
||||
color: #26232d;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Button, Form, Input, Select, Space, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { RobotOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
SaveProductEditorDraftRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
|
||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type NamePath = Array<string | number>;
|
||||
type Props = {
|
||||
form: FormInstance<EditorFormValues>;
|
||||
fieldName: number;
|
||||
sourceId: string | null;
|
||||
usesVariantPackageWeight: boolean;
|
||||
usesVariantPackageDimensions: boolean;
|
||||
onUseCommonPackageWeight: () => void;
|
||||
onUseCommonPackageDimensions: () => void;
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
usesVariantTitle: boolean;
|
||||
skuInfoAspectDefinitions: ProductAttributeSchema[];
|
||||
colorNameDefinition: ProductAttributeSchema | undefined;
|
||||
catalogSyncing: string | null;
|
||||
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
};
|
||||
|
||||
/** 长×宽×高行内输入组(单位 mm)。 */
|
||||
function InlineDimensions({ name }: { name: NamePath }) {
|
||||
return (
|
||||
<div className="sku-inline-dimensions">
|
||||
<Form.Item name={[...name, 'length_mm']} noStyle rules={[packageIntegerRule]}>
|
||||
<PackageIntegerInput placeholder="长" />
|
||||
</Form.Item>
|
||||
<span className="sku-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'width_mm']} noStyle rules={[packageIntegerRule]}>
|
||||
<PackageIntegerInput placeholder="宽" />
|
||||
</Form.Item>
|
||||
<span className="sku-dimension-separator">×</span>
|
||||
<Form.Item name={[...name, 'height_mm']} noStyle rules={[packageIntegerRule]}>
|
||||
<PackageIntegerInput placeholder="高" />
|
||||
</Form.Item>
|
||||
<span className="sku-dimension-unit">mm</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** SKU 自身的规格、文案与类目属性;空白覆盖值由服务端继承商品默认值。 */
|
||||
export function SkuInfoPanel({
|
||||
form,
|
||||
fieldName,
|
||||
sourceId,
|
||||
usesVariantPackageWeight,
|
||||
usesVariantPackageDimensions,
|
||||
onUseCommonPackageWeight,
|
||||
onUseCommonPackageDimensions,
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
usesVariantTitle,
|
||||
skuInfoAspectDefinitions,
|
||||
colorNameDefinition,
|
||||
catalogSyncing,
|
||||
dictionaryValues,
|
||||
onLoadDictionaryValues,
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className="sku-info-panel" aria-label="规格与发布信息">
|
||||
<div className="sku-panel-heading">
|
||||
<h5>规格与发布信息</h5>
|
||||
</div>
|
||||
<div className="sku-info-group-title">SKU 基础资料</div>
|
||||
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
|
||||
<Input placeholder="填写 SKU 名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical">
|
||||
<Input placeholder="首次上架前填写" />
|
||||
</Form.Item>
|
||||
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
|
||||
<>
|
||||
<div className="sku-info-group-title">包装规格</div>
|
||||
<div className="sku-info-package-row">
|
||||
{usesVariantPackageWeight && (
|
||||
<Form.Item
|
||||
name={[fieldName, 'package_spec', 'shipping_weight_g']}
|
||||
label={
|
||||
<span className="sku-package-label">
|
||||
<span>包装重量</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={onUseCommonPackageWeight}
|
||||
>
|
||||
设为通用
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
required
|
||||
layout="vertical"
|
||||
className="sku-info-package-weight"
|
||||
rules={[packageIntegerRule]}
|
||||
>
|
||||
<PackageIntegerInput addonAfter="g" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantPackageDimensions && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="sku-package-label">
|
||||
<span>包装尺寸</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={onUseCommonPackageDimensions}
|
||||
>
|
||||
设为通用
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
required
|
||||
layout="vertical"
|
||||
className="sku-info-package-dimensions"
|
||||
>
|
||||
<InlineDimensions name={[fieldName, 'package_spec']} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text type="secondary" className="sku-info-inherit-note">
|
||||
按 SKU 设置的包装规格需在各 SKU 中填写。
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<div className="sku-info-group-title">发布文案与类目属性</div>
|
||||
<Form.Item
|
||||
label="俄文发布标题"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-title"
|
||||
required={usesVariantTitle}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name={[fieldName, 'title']} noStyle>
|
||||
<Input placeholder="留空则继承 SPU 标题" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 文案"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'title')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 标题中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'title')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
{usesVariantTags && (
|
||||
<Form.Item label="主题标签" layout="vertical" className="sku-info-variant-tags">
|
||||
<Space.Compact
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Form.Item name={[fieldName, 'tags']} noStyle>
|
||||
<Select
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="留空则继承 SPU 标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 标签"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'tags')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 标签中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'tags')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantDescription && (
|
||||
<Form.Item
|
||||
label="产品描述"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-description"
|
||||
>
|
||||
<Space.Compact
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Form.Item name={[fieldName, 'description']} noStyle>
|
||||
<Input.TextArea rows={3} placeholder="留空则继承 SPU 描述" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 描述"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onAiCopy(sourceId, 'description')}
|
||||
/>
|
||||
<Button
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看当前 SKU 描述中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() => sourceId && onCompareCopy(sourceId, 'description')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{skuInfoAspectDefinitions.length > 0 && sourceId && (
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={skuInfoAspectDefinitions}
|
||||
scope="variant"
|
||||
variantId={sourceId}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={onLoadDictionaryValues}
|
||||
onSyncDictionaryValues={onSyncDictionaryValues}
|
||||
onOpenDictionaryValues={onOpenDictionaryValues}
|
||||
onLoadMoreDictionaryValues={onLoadMoreDictionaryValues}
|
||||
itemLayout="vertical"
|
||||
forceRequiredNames={['商品颜色']}
|
||||
colorNameDefinition={colorNameDefinition}
|
||||
/>
|
||||
)}
|
||||
{skuInfoAspectDefinitions.length > 0 && !sourceId && (
|
||||
<Typography.Text type="secondary" className="sku-info-inherit-note">
|
||||
新 SKU 保存后可填写平台类目属性。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button, Image } from 'antd';
|
||||
import type { SkuEditorImage } from './SkuImageEditorModal';
|
||||
|
||||
type Props = {
|
||||
images: SkuEditorImage[];
|
||||
imageCount: number;
|
||||
sourcePreview: boolean;
|
||||
localPreview: boolean;
|
||||
onPick: () => void;
|
||||
onMakeMain: (index: number) => void;
|
||||
};
|
||||
|
||||
/** 主图位与其他图片分层展示;图片顺序仍由表单中的 media_asset_ids 决定。 */
|
||||
export function SkuMediaPanel({
|
||||
images,
|
||||
imageCount,
|
||||
sourcePreview,
|
||||
localPreview,
|
||||
onPick,
|
||||
onMakeMain,
|
||||
}: Props) {
|
||||
const [main, ...others] = images;
|
||||
return (
|
||||
<section className="sku-media-panel" aria-label="SKU 图片">
|
||||
<div className="sku-panel-heading">
|
||||
<h5>
|
||||
SKU 图片{' '}
|
||||
<small className="sku-media-count">
|
||||
{sourcePreview ? '已选' : '共'} {imageCount} 张
|
||||
</small>
|
||||
</h5>
|
||||
<Button onClick={onPick}>选择图片</Button>
|
||||
</div>
|
||||
<div className="sku-media-layout">
|
||||
<div className={`sku-media-main${main ? '' : ' sku-media-main--empty'}`}>
|
||||
<span className="sku-media-main-badge">
|
||||
{sourcePreview ? '来源主图' : '主图'}
|
||||
</span>
|
||||
{main ? <Image src={main.url} alt="SKU 主图" /> : <span>暂无主图</span>}
|
||||
{main && (
|
||||
<span className="sku-media-main-caption">
|
||||
{sourcePreview ? '选择后可用作商品主图' : '第 1 张用于商品主图'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sku-media-other">
|
||||
{others.map((image, offset) => (
|
||||
<div className="sku-media-other-item" key={image.id}>
|
||||
<Image src={image.url} alt={`SKU 图片 ${offset + 2}`} />
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => onMakeMain(offset + 1)}
|
||||
aria-label={`将第 ${offset + 2} 张图片设为主图`}
|
||||
>
|
||||
设为主图
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
className="sku-media-add"
|
||||
type="dashed"
|
||||
onClick={onPick}
|
||||
aria-label="添加 SKU 图片"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{sourcePreview && (
|
||||
<p className="sku-media-note">来源图片仅供预览,选择后才成为正式主图。</p>
|
||||
)}
|
||||
{localPreview && (
|
||||
<p className="sku-media-note">当前图片仅在本页预览,保存前请确认顺序。</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
.sku-single-ai-empty strong { color: #494556; }
|
||||
.sku-single-ai-sparkle { color: #7465e6; font-size: 36px; }
|
||||
.sku-single-ai-styles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
|
||||
.sku-single-ai-styles button { min-height: 34px; border: 1px solid #e1deeb; border-radius: 6px; background: #fff; cursor: pointer; }
|
||||
.sku-single-ai-styles button { min-height: 35px; border: 1px solid #e1deeb; border-radius: 7px; background: #fff; cursor: pointer; }
|
||||
.sku-single-ai-styles button.selected { border-color: #7062e9; background: #f2f0ff; color: #6254dc; }
|
||||
.sku-single-ai-styles + .ant-btn, .sku-single-ai-section > .ant-btn-link { padding-left: 0; }
|
||||
.sku-single-ai-dimensions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) 78px; gap: 6px; margin-bottom: 8px; }
|
||||
@@ -29,7 +29,7 @@
|
||||
.sku-single-ai-footer .ant-typography { font-size: 12px; }
|
||||
.sku-single-ai-footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.sku-single-ai-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; max-height: 450px; overflow-y: auto; }
|
||||
.sku-single-ai-picker button { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 4px; border: 1px solid #e7e4ed; border-radius: 6px; background: #fff; cursor: pointer; }
|
||||
.sku-single-ai-picker button { display: flex; flex-direction: column; gap: 5px; min-width: 0; padding: 4px; border: 1px solid #e7e4ed; border-radius: 7px; background: #fff; cursor: pointer; }
|
||||
.sku-single-ai-picker img { width: 100%; aspect-ratio: 3 / 4; object-fit: contain; }
|
||||
.sku-single-ai-picker span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@media (max-width: 760px) {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e5e3eb;
|
||||
border-radius: 8px;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
@@ -129,9 +129,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sku-watermark-picker-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sku-watermark-picker-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sku-watermark-picker-grid { grid-template-columns: 1fr; }
|
||||
.sku-watermark-picker-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type {
|
||||
CommissionImportCategory,
|
||||
CommissionImportDetail,
|
||||
CommissionImportPreview,
|
||||
CommissionImportRequest,
|
||||
CommissionImportVersion,
|
||||
} from '@atlas/contracts';
|
||||
import { commissionImportRequestSchema } from '@atlas/contracts';
|
||||
import { platformCatalogAdminApi, type AdminPlatform } from '@/services/platformCatalogAdmin';
|
||||
import { categoryCommissionsApi } from '@/services/categoryCommissions';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
const percent = (rate: string) => `${(Number(rate) * 100).toFixed(2)}%`;
|
||||
const columns = [
|
||||
{
|
||||
title: '类目',
|
||||
key: 'category',
|
||||
render: (_: unknown, c: CommissionImportCategory) => (
|
||||
<>
|
||||
<div>
|
||||
{c.parent_name} / {c.name}
|
||||
</div>
|
||||
<Typography.Text type="secondary">{c.source_category_id}</Typography.Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...['≤ 1500₽', '1500–5000₽(不含 1500)', '> 5000₽'].map((title, i) => ({
|
||||
title,
|
||||
key: String(i),
|
||||
render: (_: unknown, c: CommissionImportCategory) => percent(c.bands[i].commission_rate),
|
||||
})),
|
||||
{ title: '商品类型', dataIndex: 'mapped_types', key: 'types' },
|
||||
{
|
||||
title: '核对结果',
|
||||
key: 'status',
|
||||
render: (_: unknown, c: CommissionImportCategory) =>
|
||||
c.status === 'excluded' ? (
|
||||
<>
|
||||
<Tag color="warning">未映射</Tag>
|
||||
{c.issues.join(';')}
|
||||
</>
|
||||
) : (
|
||||
<Tag color={c.change === 'unchanged' ? 'default' : 'blue'}>
|
||||
{{ added: '新增', changed: '变更', unchanged: '未变' }[c.change]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SystemCommissionsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]),
|
||||
[platform, setPlatform] = useState('');
|
||||
const [versions, setVersions] = useState<CommissionImportVersion[]>([]),
|
||||
[selected, setSelected] = useState(''),
|
||||
[detail, setDetail] = useState<CommissionImportDetail | null>(null);
|
||||
const [error, setError] = useState(''),
|
||||
[loading, setLoading] = useState(false),
|
||||
[open, setOpen] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
[saving, setSaving] = useState(false);
|
||||
const [version, setVersion] = useState(''),
|
||||
[source, setSource] = useState(''),
|
||||
[date, setDate] = useState(''),
|
||||
[json, setJson] = useState('');
|
||||
const [preview, setPreview] = useState<CommissionImportPreview | null>(null),
|
||||
[previewInput, setPreviewInput] = useState<CommissionImportRequest | null>(null),
|
||||
[modalError, setModalError] = useState('');
|
||||
const [scope, setScope] = useState(false),
|
||||
[search, setSearch] = useState('');
|
||||
const [previewSearch, setPreviewSearch] = useState('');
|
||||
const [onlyExcluded, setOnlyExcluded] = useState(false);
|
||||
const sequence = useRef(0),
|
||||
publishKey = useRef('');
|
||||
useEffect(() => {
|
||||
void platformCatalogAdminApi
|
||||
.platforms()
|
||||
.then((items) => {
|
||||
setPlatforms(items.filter((p) => p.code.toUpperCase() === 'OZON'));
|
||||
setPlatform(items.find((p) => p.code.toUpperCase() === 'OZON')?.id ?? '');
|
||||
})
|
||||
.catch((e) => setError(apiErrorMessage(e)));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!platform) return;
|
||||
setLoading(true);
|
||||
setVersions([]);
|
||||
setDetail(null);
|
||||
setSelected('');
|
||||
void categoryCommissionsApi
|
||||
.list(platform)
|
||||
.then((items) => {
|
||||
if (active) {
|
||||
setVersions(items);
|
||||
setSelected(items[0]?.id ?? '');
|
||||
setError('');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(apiErrorMessage(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [platform]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setDetail(null);
|
||||
if (selected)
|
||||
void categoryCommissionsApi
|
||||
.detail(selected)
|
||||
.then((d) => {
|
||||
if (active) setDetail(d);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(apiErrorMessage(e));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selected]);
|
||||
const invalidate = () => {
|
||||
sequence.current++;
|
||||
setPreview(null);
|
||||
setPreviewInput(null);
|
||||
setScope(false);
|
||||
setBusy(false);
|
||||
setModalError('');
|
||||
setPreviewSearch('');
|
||||
setOnlyExcluded(false);
|
||||
};
|
||||
const start = () => {
|
||||
invalidate();
|
||||
setVersion('');
|
||||
setSource('');
|
||||
setDate(new Date().toISOString().slice(0, 10));
|
||||
setJson('');
|
||||
setOpen(true);
|
||||
};
|
||||
const check = async () => {
|
||||
const seq = ++sequence.current;
|
||||
setBusy(true);
|
||||
setModalError('');
|
||||
setPreview(null);
|
||||
setScope(false);
|
||||
try {
|
||||
if (new TextEncoder().encode(json).length > 2 * 1024 * 1024)
|
||||
throw new Error('JSON 不能超过 2 MiB');
|
||||
const input = commissionImportRequestSchema.safeParse({
|
||||
platform_id: platform,
|
||||
version,
|
||||
source_reference: source,
|
||||
captured_on: date,
|
||||
document: JSON.parse(json),
|
||||
});
|
||||
if (!input.success)
|
||||
throw new Error('请填写版本名称、至少 3 字来源说明、有效采集日期,并上传 JSON');
|
||||
const p = await categoryCommissionsApi.preview(input.data);
|
||||
if (seq === sequence.current) {
|
||||
setPreview(p);
|
||||
setPreviewInput(input.data);
|
||||
publishKey.current = crypto.randomUUID();
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq === sequence.current)
|
||||
setModalError(
|
||||
e instanceof SyntaxError
|
||||
? 'JSON 格式不正确,请检查文件内容'
|
||||
: apiErrorMessage(e)
|
||||
);
|
||||
} finally {
|
||||
if (seq === sequence.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
const publish = async () => {
|
||||
if (!preview || !previewInput) return;
|
||||
setSaving(true);
|
||||
setModalError('');
|
||||
try {
|
||||
const v = await categoryCommissionsApi.publish(
|
||||
{
|
||||
...previewInput,
|
||||
preview_hash: preview.preview_hash,
|
||||
confirm_scope: true,
|
||||
},
|
||||
publishKey.current
|
||||
);
|
||||
setVersions((old) => [v, ...old.map((x) => ({ ...x, is_current: false }))]);
|
||||
setSelected(v.id);
|
||||
setOpen(false);
|
||||
message.success('佣金规则已启用,相关类目可直接试算');
|
||||
} catch (e) {
|
||||
setModalError(apiErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={3}>类目佣金</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
导入类目分档费率,核对后用于商品定价。范围:Ozon · RU ·
|
||||
rFBS;价格区间币种:RUB。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
{error && <Alert type="error" title={error} />}
|
||||
<Space>
|
||||
<Select
|
||||
aria-label="佣金平台"
|
||||
style={{ width: 220 }}
|
||||
value={platform || undefined}
|
||||
options={platforms.map((p) => ({ value: p.id, label: p.name }))}
|
||||
onChange={setPlatform}
|
||||
/>
|
||||
<Button type="primary" disabled={!platform} onClick={start}>
|
||||
导入 JSON
|
||||
</Button>
|
||||
</Space>
|
||||
<Card title="佣金版本">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={versions}
|
||||
pagination={{ pageSize: 8 }}
|
||||
locale={{ emptyText: '尚未导入佣金规则,点击“导入 JSON”开始' }}
|
||||
columns={[
|
||||
{ title: '版本', dataIndex: 'version' },
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, v) => (
|
||||
<Tag color={v.is_current ? 'green' : 'default'}>
|
||||
{v.is_current ? '当前启用' : '历史版本'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '来源', dataIndex: 'source_reference' },
|
||||
{ title: '采集日期', dataIndex: 'captured_on' },
|
||||
{
|
||||
title: '启用时间',
|
||||
render: (_, v) => new Date(v.activated_at).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '覆盖',
|
||||
render: (_, v) =>
|
||||
`${v.summary.eligible} 类 / ${v.summary.mapped_types} 类型`,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, v) => (
|
||||
<Button type="link" onClick={() => setSelected(v.id)}>
|
||||
{selected === v.id ? '正在查看' : '查看规则'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
{detail && (
|
||||
<Card
|
||||
title={`${detail.version} · 分档规则`}
|
||||
extra={
|
||||
<Input
|
||||
aria-label="搜索佣金类目"
|
||||
placeholder="搜索类目名称或 ID"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="source_category_id"
|
||||
columns={columns}
|
||||
dataSource={detail.categories.filter((c) =>
|
||||
`${c.name} ${c.parent_name} ${c.source_category_id}`.includes(search)
|
||||
)}
|
||||
pagination={{ pageSize: 15, showSizeChanger: true }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<Modal
|
||||
title="导入类目佣金"
|
||||
open={open}
|
||||
width={1150}
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
||||
maskClosable={!saving}
|
||||
closable={!saving}
|
||||
onCancel={() => {
|
||||
if (!saving) {
|
||||
invalidate();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
invalidate();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button loading={busy} disabled={saving} onClick={() => void check()}>
|
||||
解析并预览
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saving}
|
||||
disabled={!preview || !scope || !preview.summary.eligible}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
确认并启用
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="info"
|
||||
title="核对来源后启用"
|
||||
description="导入会完整替换本入口上次启用的规则。历史报价保留原快照;新的试算使用新版本。0% 佣金正常导入;无法映射的类目在预览中提示,暂不参与算价。"
|
||||
/>
|
||||
<Space wrap>
|
||||
<Input
|
||||
aria-label="佣金版本名称"
|
||||
placeholder="版本名称,例如 2026-09-29"
|
||||
value={version}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
invalidate();
|
||||
setVersion(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
aria-label="佣金来源说明"
|
||||
placeholder="来源说明 / 资费页面"
|
||||
style={{ width: 350 }}
|
||||
value={source}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
invalidate();
|
||||
setSource(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<label>
|
||||
采集日期{' '}
|
||||
<Input
|
||||
aria-label="佣金采集日期"
|
||||
type="date"
|
||||
value={date}
|
||||
disabled={saving}
|
||||
onChange={(e) => {
|
||||
invalidate();
|
||||
setDate(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Space>
|
||||
<label>
|
||||
选择 JSON 文件(最多 2 MiB){' '}
|
||||
<input
|
||||
aria-label="佣金 JSON 文件"
|
||||
type="file"
|
||||
accept=".json,.txt,application/json"
|
||||
disabled={saving}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
invalidate();
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setModalError('文件不能超过 2 MiB');
|
||||
return;
|
||||
}
|
||||
const seq = sequence.current;
|
||||
try {
|
||||
const text = await file.text();
|
||||
if (seq === sequence.current) setJson(text);
|
||||
} catch {
|
||||
if (seq === sequence.current)
|
||||
setModalError('无法读取文件,请重新选择');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<Input.TextArea
|
||||
aria-label="佣金 JSON 内容"
|
||||
rows={5}
|
||||
value={json}
|
||||
disabled={saving}
|
||||
placeholder="也可以在这里粘贴原始 JSON"
|
||||
onChange={(e) => {
|
||||
invalidate();
|
||||
setJson(e.target.value);
|
||||
}}
|
||||
/>
|
||||
{modalError && <Alert type="error" title={modalError} />}
|
||||
{preview && (
|
||||
<>
|
||||
<Alert
|
||||
type={
|
||||
preview.summary.excluded || preview.summary.removed
|
||||
? 'warning'
|
||||
: 'success'
|
||||
}
|
||||
title={`共 ${preview.summary.categories} 类 / ${preview.summary.rules} 条规则;可用 ${preview.summary.eligible} 类,覆盖 ${preview.summary.mapped_types} 个商品类型${preview.summary.excluded ? `;${preview.summary.excluded} 类目录异常,暂未映射` : ''}`}
|
||||
description={
|
||||
preview.summary.removed
|
||||
? `相对上一版本,有 ${preview.summary.removed} 个原已覆盖类目将不再参与算价;请查看下表。`
|
||||
: preview.summary.excluded
|
||||
? '目录异常仅作提示,可继续启用其他已映射类目。'
|
||||
: '全部类目已映射;可搜索类目核对各档费率。'
|
||||
}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Input
|
||||
aria-label="搜索预览类目"
|
||||
placeholder="搜索类目名称或 ID"
|
||||
value={previewSearch}
|
||||
onChange={(e) => setPreviewSearch(e.target.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={onlyExcluded}
|
||||
onChange={(e) => setOnlyExcluded(e.target.checked)}
|
||||
>
|
||||
只看异常类目
|
||||
</Checkbox>
|
||||
</Space>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="source_category_id"
|
||||
columns={columns}
|
||||
dataSource={preview.categories.filter(
|
||||
(c) =>
|
||||
(!onlyExcluded || c.status === 'excluded') &&
|
||||
`${c.name} ${c.parent_name} ${c.source_category_id}`.includes(
|
||||
previewSearch
|
||||
)
|
||||
)}
|
||||
pagination={{ pageSize: 8, showSizeChanger: true }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={scope}
|
||||
disabled={saving}
|
||||
onChange={(e) => setScope(e.target.checked)}
|
||||
>
|
||||
我已核对来源,确认适用于 Ozon RU / rFBS,售价区间单位为卢布(RUB)
|
||||
</Checkbox>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import SystemCommissionsPage from '@/pages/system/SystemCommissionsPage';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom';
|
||||
import MainLayout from '@/layouts/MainLayout';
|
||||
@@ -85,6 +86,7 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <Navigate to="/system/platform-catalog" replace /> },
|
||||
{ path: 'platform-catalog', element: <SystemCatalogPage /> },
|
||||
{ path: 'pricing-rates', element: <SystemPricingRatesPage /> },
|
||||
{ path: 'commissions', element: <SystemCommissionsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
CommissionImportRequest,
|
||||
CommissionImportPublish,
|
||||
CommissionImportPreview,
|
||||
CommissionImportVersion,
|
||||
CommissionImportDetail,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from './api';
|
||||
export const categoryCommissionsApi = {
|
||||
list: (platform: string) =>
|
||||
api.get<CommissionImportVersion[]>('/system/pricing/commissions', {
|
||||
params: { platform_id: platform },
|
||||
}),
|
||||
detail: (id: string) => api.get<CommissionImportDetail>(`/system/pricing/commissions/${id}`),
|
||||
preview: (input: CommissionImportRequest) =>
|
||||
api.post<CommissionImportPreview>('/system/pricing/commissions/preview', input),
|
||||
publish: (input: CommissionImportPublish, key: string) =>
|
||||
api.post<CommissionImportVersion>('/system/pricing/commissions/imports', input, {
|
||||
headers: { 'Idempotency-Key': key },
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user