feat(edit): 定价试算开发、SKU 样式功能修改

This commit is contained in:
R524809
2026-09-29 17:50:19 +08:00
parent 84b230e69a
commit 0a303414d8
52 changed files with 4278 additions and 1393 deletions
+2 -6
View File
@@ -101,11 +101,7 @@ export function draftPricingEnabled(
nodeEnvironment: Environment['NODE_ENV'], nodeEnvironment: Environment['NODE_ENV'],
setting: Environment['PRICING_ALLOW_DRAFT_RATES'], setting: Environment['PRICING_ALLOW_DRAFT_RATES'],
): boolean { ): boolean {
return ( return setting === 'true' || (setting === 'auto' && nodeEnvironment !== 'test');
nodeEnvironment !== 'production' &&
(setting === 'true' ||
(nodeEnvironment === 'development' && setting === 'auto'))
);
} }
export type AppConfig = Omit< export type AppConfig = Omit<
Environment, Environment,
@@ -152,7 +148,7 @@ function createAppConfig(environment: Environment): AppConfig {
objectStorageSecretKey, objectStorageSecretKey,
objectStorageForcePathStyle: objectStorageForcePathStyle:
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true', environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
// 草稿费率仅用于本地商品编辑联调,生产配置无法开启。 // 草稿费率默认可参与报价;仍可通过环境配置限制新试算。
pricingAllowDraftRates: draftPricingEnabled( pricingAllowDraftRates: draftPricingEnabled(
environment.NODE_ENV, environment.NODE_ENV,
environment.PRICING_ALLOW_DRAFT_RATES, environment.PRICING_ALLOW_DRAFT_RATES,
+5
View File
@@ -58,6 +58,11 @@ export async function createApplication() {
}); });
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。 // Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
app.use(helmet({ contentSecurityPolicy: false })); 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('json', { limit: config.API_BODY_LIMIT_BYTES });
app.useBodyParser('urlencoded', { app.useBodyParser('urlencoded', {
limit: config.API_BODY_LIMIT_BYTES, 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;
}
}
+28
View File
@@ -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 };
}
+7
View File
@@ -52,6 +52,9 @@ export type LogisticsRule = {
volume_limit_round_scale: number | null; volume_limit_round_scale: number | null;
}; };
export type CommissionRule = { export type CommissionRule = {
category_name?: string;
source?: string;
effective_from?: string | Date;
id: string | null; id: string | null;
currency_code: string; currency_code: string;
min_price: string | null; min_price: string | null;
@@ -254,6 +257,10 @@ export function calculatePricing(
logistics_rate_rule_id: r.id, logistics_rate_rule_id: r.id,
commission_rule_id: c.id, commission_rule_id: c.id,
commission_rate: c.commission_rate, 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), selling_price: rounded.toFixed(2),
list_price: list.toFixed(2), list_price: list.toFixed(2),
currency_code: currency, currency_code: currency,
@@ -54,6 +54,15 @@ export class PricingController {
constructor( constructor(
@Inject(PricingService) private readonly service: PricingService, @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( @Get('pricing/categories') async categories(
@CurrentWorkspace() w: Workspace, @CurrentWorkspace() w: Workspace,
@Query('store_id') store: string, @Query('store_id') store: string,
+4 -1
View File
@@ -1,3 +1,5 @@
import { CommissionImportController } from './commission-import.controller.js';
import { CommissionImportService } from './commission-import.service.js';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { PricingController } from './pricing.controller.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 { PricingAdminService } from './pricing-admin.service.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js'; import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
@Module({ @Module({
controllers: [PricingController, PricingAdminController], controllers: [PricingController, PricingAdminController, CommissionImportController],
providers: [ providers: [
PricingService, PricingService,
CommissionImportService,
PricingAdminService, PricingAdminService,
SystemAdminGuard, SystemAdminGuard,
IdempotencyService, IdempotencyService,
+26 -21
View File
@@ -1,3 +1,5 @@
import { readCommissionRules } from './commission-rules.js';
import type { CommissionRulesResult } from '@atlas/contracts';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database'; 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) { async categories(workspaceId: string, storeId: string, q: string) {
return this.db.workspaceTransaction(workspaceId, async (tx) => { return this.db.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.store(tx, storeId); const store = await this.store(tx, storeId);
@@ -174,7 +194,7 @@ export class PricingService {
}; };
}); });
} }
/** 开发模式可复算草稿;保存确认时复读规则,正式模式仍只接受已审核版本。 */ /** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) { async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
const card = ( const card = (
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>( await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
@@ -190,7 +210,7 @@ export class PricingService {
throw new ApiError( throw new ApiError(
422, 422,
'PRICING_RULES_UNAVAILABLE', 'PRICING_RULES_UNAVAILABLE',
'没有已审核且有效的物流费率,请先配置正式费率', '没有可用的物流费率,请检查费率卡状态和草稿卡使用配置',
); );
const category = await tx.query( const category = await tx.query(
`SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`, `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], [card.id, input.pricing.transport_method_ids, card.status],
) )
).rows; ).rows;
const mapping = ( const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment);
await tx.query<{ commission_category_id: string }>( const mapping = official.mapping;
`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`, let commissions = official.commissions;
[
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
: [];
if ( if (
!commissions.length && !commissions.length &&
input.pricing.temporary_commission_rate !== null && input.pricing.temporary_commission_rate !== null &&
@@ -266,7 +271,7 @@ export class PricingService {
throw new ApiError( throw new ApiError(
422, 422,
'PRICING_COMMISSION_UNAVAILABLE', 'PRICING_COMMISSION_UNAVAILABLE',
'该类目尚无有效佣金规则;允许临时佣金的方案可在详情中补充', '该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数',
); );
if ( if (
commissions.some((c) => c.id) && commissions.some((c) => c.id) &&
@@ -17,6 +17,8 @@ export type ProductRow = {
default_package_width_mm: string | null; default_package_width_mm: string | null;
default_package_height_mm: string | null; default_package_height_mm: string | null;
default_shipping_weight_g: string | null; default_shipping_weight_g: string | null;
package_weight_mode: 'common' | 'variant';
package_dimensions_mode: 'common' | 'variant';
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed'; physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
package_spec_status: 'missing' | 'ambiguous' | 'confirmed'; package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
ai_source_material_overrides: unknown; ai_source_material_overrides: unknown;
@@ -140,9 +140,10 @@ export class ProductEditorService {
); );
const created = await transaction.query<{ id: string }>( const created = await transaction.query<{ id: string }>(
`INSERT INTO atlas.product_platform_profiles `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, 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 FROM atlas.platforms p WHERE p.id = $2
ON CONFLICT (workspace_id, product_id, platform_id) DO NOTHING ON CONFLICT (workspace_id, product_id, platform_id) DO NOTHING
RETURNING id`, RETURNING id`,
@@ -434,6 +435,8 @@ export class ProductEditorService {
}, },
default_physical_spec: defaultPhysicalSpec, default_physical_spec: defaultPhysicalSpec,
default_package_spec: defaultPackageSpec, default_package_spec: defaultPackageSpec,
package_weight_mode: product.package_weight_mode,
package_dimensions_mode: product.package_dimensions_mode,
}, },
profile: { profile: {
id: profile.id, id: profile.id,
@@ -822,6 +825,7 @@ export class ProductEditorService {
default_package_length_mm = $11, default_package_width_mm = $12, default_package_length_mm = $11, default_package_width_mm = $12,
default_package_height_mm = $13, default_shipping_weight_g = $14, default_package_height_mm = $13, default_shipping_weight_g = $14,
physical_spec_status = $15, package_spec_status = $16, 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_overrides = $17::jsonb,
ai_source_material_base_revision = $18, ai_source_material_base_revision = $18,
revision = revision + 1, updated_at = now() revision = revision + 1, updated_at = now()
@@ -847,6 +851,8 @@ export class ProductEditorService {
input.ai_source_material.source_snapshot_revision, input.ai_source_material.source_snapshot_revision,
productId, productId,
expected.product, expected.product,
input.common.package_weight_mode ?? product.package_weight_mode,
input.common.package_dimensions_mode ?? product.package_dimensions_mode,
], ],
); );
if (!updatedProduct.rows[0]) if (!updatedProduct.rows[0])
@@ -2301,10 +2307,11 @@ export class ProductEditorService {
check( check(
variant.package_spec.status, variant.package_spec.status,
[ [
variant.package_spec.length_mm, variant.package_spec.length_mm ?? input.common.default_package_spec.length_mm,
variant.package_spec.width_mm, variant.package_spec.width_mm ?? input.common.default_package_spec.width_mm,
variant.package_spec.height_mm, variant.package_spec.height_mm ?? input.common.default_package_spec.height_mm,
variant.package_spec.shipping_weight_g, variant.package_spec.shipping_weight_g ??
input.common.default_package_spec.shipping_weight_g,
], ],
`variants.${index}.package_spec`, `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_length_mm::text, p.default_package_width_mm::text,
p.default_package_height_mm::text, p.default_shipping_weight_g::text, p.default_package_height_mm::text, p.default_shipping_weight_g::text,
p.physical_spec_status, p.package_spec_status, 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, p.ai_source_material_overrides, p.ai_source_material_base_revision,
c.normalized_payload, c.source_snapshot_revision c.normalized_payload, c.source_snapshot_revision
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id 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_length_mm::text, p.default_package_width_mm::text,
p.default_package_height_mm::text, p.default_shipping_weight_g::text, p.default_package_height_mm::text, p.default_shipping_weight_g::text,
p.physical_spec_status, p.package_spec_status, 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, p.ai_source_material_overrides, p.ai_source_material_base_revision,
c.normalized_payload, c.source_snapshot_revision c.normalized_payload, c.source_snapshot_revision
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id 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; offer_id: string | null;
selling_price: string | null; selling_price: string | null;
pricing_valid: boolean; pricing_valid: boolean;
pricing_development_only: boolean;
package_length_mm: string | null; package_length_mm: string | null;
package_width_mm: string | null; package_width_mm: string | null;
package_height_mm: string | null; package_height_mm: string | null;
@@ -291,12 +290,6 @@ export class ProductEditorValidationOperations {
field: `variants.${variant.id}.pricing_selection`, field: `variants.${variant.id}.pricing_selection`,
message: '请按当前采购价和包装确认报价', message: '请按当前采购价和包装确认报价',
}); });
if (variant.pricing_development_only)
issues.push({
code: 'PRICING_RATE_REVIEW_REQUIRED',
field: `variants.${variant.id}.pricing_selection`,
message: '当前报价使用开发草稿费率;请在费率正式审核后重新试算确认',
});
if (!variant.selling_price) if (!variant.selling_price)
issues.push({ issues.push({
code: 'PRICE_REQUIRED', code: 'PRICE_REQUIRED',
@@ -363,7 +356,6 @@ export class ProductEditorValidationOperations {
const variants = await transaction.query<VariantValidationRow>( const variants = await transaction.query<VariantValidationRow>(
`SELECT v.id, v.display_name, listing.status AS listing_status, `SELECT v.id, v.display_name, listing.status AS listing_status,
listing.offer_id, listing.selling_price::text, listing.pricing_valid, 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_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_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, 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.products product ON product.id = v.product_id
JOIN atlas.product_variant_platform_listings listing JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = v.id AND listing.profile_id = $1 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 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 WHERE v.product_id = $2 AND v.deleted_at IS NULL
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[])) AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
+466
View File
@@ -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 { describe, it } from 'vitest';
import { draftPricingEnabled } from '../src/config/app-config.module.js'; import { draftPricingEnabled } from '../src/config/app-config.module.js';
describe('草稿费率开发模式边界', () => { describe('草稿费率配置', () => {
it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => { it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => {
assert.equal(draftPricingEnabled('development', 'auto'), true); assert.equal(draftPricingEnabled('development', 'auto'), true);
assert.equal(draftPricingEnabled('development', 'false'), false); assert.equal(draftPricingEnabled('development', 'false'), false);
@@ -10,8 +10,9 @@ describe('草稿费率开发模式边界', () => {
assert.equal(draftPricingEnabled('test', 'true'), true); assert.equal(draftPricingEnabled('test', 'true'), true);
}); });
it('生产环境即使误设开关也不能使用草稿费率', () => { it('生产环境默认允许草稿卡,可显式关闭', () => {
assert.equal(draftPricingEnabled('production', 'true'), false); assert.equal(draftPricingEnabled('production', 'true'), true);
assert.equal(draftPricingEnabled('production', 'auto'), false); assert.equal(draftPricingEnabled('production', 'auto'), true);
assert.equal(draftPricingEnabled('production', 'false'), false);
}); });
}); });
+82
View File
@@ -99,6 +99,17 @@ describe('十进制定价引擎', () => {
expect(o.can_confirm).toBe(false); expect(o.can_confirm).toBe(false);
expect(Number(o.profit_cny)).toBeLessThan(0); 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('实际重、尺寸和开区间边界精确生效', () => { it('实际重、尺寸和开区间边界精确生效', () => {
const i = input(); const i = input();
i.package.shipping_weight_g = '500'; i.package.shipping_weight_g = '500';
@@ -241,3 +252,74 @@ describe('十进制定价引擎', () => {
expect(option.can_confirm).toBe(false); 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);
});
});
+11 -7
View File
@@ -577,11 +577,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
); );
const input = quote(); const input = quote();
input.pricing.temporary_commission_rate = null; input.pricing.temporary_commission_rate = null;
assert.equal( const withoutCommission = await request(
(await request('/pricing/quote-previews', 'POST', input)).error '/pricing/quote-previews',
.code, 'POST',
'PRICING_COMMISSION_UNAVAILABLE', input,
); );
assert.equal(withoutCommission.error.code, 'PRICING_COMMISSION_UNAVAILABLE');
assert.match(withoutCommission.error.message, /本次自定义参数/);
input.pricing.temporary_commission_rate = '0.12'; input.pricing.temporary_commission_rate = '0.12';
input.pricing.mode = 'fixed_sale'; input.pricing.mode = 'fixed_sale';
input.pricing.selling_price = '10'; input.pricing.selling_price = '10';
@@ -598,7 +600,7 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
}; };
assert.equal((await save(state, payload)).status, 409); assert.equal((await save(state, payload)).status, 409);
}); });
it('开发草稿可试算并确认到商品编辑页,但不能通过首次上架校验', async () => { it('草稿费率可试算、确认且不阻断首次上架预检', async () => {
await db.query( await db.query(
`UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`, `UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`,
[card], [card],
@@ -637,11 +639,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
{ 'X-Atlas-Platform-Id': platform }, { 'X-Atlas-Platform-Id': platform },
); );
assert.equal(validation.status, 200, JSON.stringify(validation)); assert.equal(validation.status, 200, JSON.stringify(validation));
assert.ok( assert.equal(
validation.data.variants[0].issues.some( validation.data.variants[0].issues.some(
(issue: { code: string }) => (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 }>( const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>(
'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1', 'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1',
+126
View File
@@ -276,6 +276,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
assert.equal(editor.response.status, 200); assert.equal(editor.response.status, 200);
assert.equal(editor.body.data.product.id, first.productId); assert.equal(editor.body.data.product.id, first.productId);
assert.equal(editor.body.data.profile.id, first.profileId); 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.length, 1);
assert.equal(editor.body.data.variants[0].listing.status, 'not_listed'); 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
View File
@@ -5,6 +5,10 @@ import { router } from './router';
import { SessionProvider } from '@/services/session'; import { SessionProvider } from '@/services/session';
import './App.css'; import './App.css';
// 控件圆角与 SKU 编辑表单一致;页面卡片仍使用原有的基础圆角。
const controlCorners = { borderRadius: 7, borderRadiusSM: 7, borderRadiusLG: 7 };
const fieldStyle = { ...controlCorners, controlHeight: 35 };
function App() { function App() {
return ( return (
<ConfigProvider <ConfigProvider
@@ -12,11 +16,19 @@ function App() {
theme={{ theme={{
token: { token: {
colorPrimary: '#8b5cf6', colorPrimary: '#8b5cf6',
// 全局圆角 10px,输入框/按钮/卡片等同步生效 // 卡片等容器维持现有圆角,交互控件使用下方独立配置。
borderRadius: 10, borderRadius: 10,
// 全局正文字号沿用 Ant Design 默认值
fontSize: 14, fontSize: 14,
}, },
components: {
Button: controlCorners,
Input: fieldStyle,
InputNumber: fieldStyle,
Select: fieldStyle,
Cascader: fieldStyle,
TreeSelect: fieldStyle,
DatePicker: fieldStyle,
},
}} }}
> >
<AntdApp> <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'; } from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts'; import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing'; 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 { 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 { export function defaultPricing(catalog: PricingCatalog): PricingParameters | null {
const policy = catalog.policies.find( const policy = catalog.policies.find(
@@ -63,13 +78,19 @@ export function PricingParametersFields({
onChange, onChange,
catalog, catalog,
policyEditing = false, policyEditing = false,
expandFees = false,
}: { }: {
policyEditing?: boolean; policyEditing?: boolean;
expandFees?: boolean;
value: PricingParameters; value: PricingParameters;
onChange: (p: PricingParameters) => void; onChange: (p: PricingParameters) => void;
catalog: PricingCatalog; catalog: PricingCatalog;
}) { }) {
const patch = (v: Partial<PricingParameters>) => onChange({ ...p, ...v }); 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]> = [ const rates: Array<[keyof PricingParameters, string]> = [
['desired_markup_rate', '目标加价率'], ['desired_markup_rate', '目标加价率'],
['list_price_discount_rate', '划线折扣率'], ['list_price_discount_rate', '划线折扣率'],
@@ -100,7 +121,7 @@ export function PricingParametersFields({
mode: p.mode, mode: p.mode,
selling_price: p.selling_price, selling_price: p.selling_price,
}); });
else patch({ pricing_policy_id: null }); else onChange(detachPolicy(p));
}} }}
/> />
</div> </div>
@@ -112,13 +133,13 @@ export function PricingParametersFields({
value={p.rate_card_id} value={p.rate_card_id}
options={catalog.rate_cards.map((x) => ({ options={catalog.rate_cards.map((x) => ({
value: x.id, value: x.id,
label: `${x.name} · ${x.version}${x.development_only ? ' · 开发草稿' : ''}`, label: `${x.name} · ${x.version}${x.development_only ? ' · 待审核费率' : ''}`,
}))} }))}
onChange={(id) => { onChange={(id) => {
const c = catalog.rate_cards.find((x) => x.id === id)!; const c = catalog.rate_cards.find((x) => x.id === id)!;
patch({ onChange({
...detachPolicy(p),
rate_card_id: id, rate_card_id: id,
pricing_policy_id: null,
transport_method_ids: c.transports.map((t) => t.id), transport_method_ids: c.transports.map((t) => t.id),
default_transport_method_id: c.transports[0]?.id ?? null, default_transport_method_id: c.transports[0]?.id ?? null,
}); });
@@ -148,6 +169,8 @@ export function PricingParametersFields({
目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。 目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。
</Typography.Text> </Typography.Text>
<Collapse <Collapse
activeKey={feesOpen ? ['fees'] : []}
onChange={(keys) => setFeesOpen(keys.includes('fees'))}
items={[ items={[
{ {
key: 'fees', key: 'fees',
@@ -230,10 +253,35 @@ export function PricingParametersFields({
step="1" step="1"
precision={2} precision={2}
addonAfter="%" addonAfter="%"
value={p.temporary_commission_rate === null ? null : rateToPercent(p.temporary_commission_rate)} disabled={policyEditing}
onChange={(v) => patch({ temporary_commission_rate: v === null ? null : percentToRate(v) })} 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> </label>
{!policyEditing && (
<Typography.Text type="secondary">
仅在缺少正式佣金规则时使用。方案不允许临时佣金时,填写后改为本次自定义参数;请依据已核实的类目费率输入。
</Typography.Text>
)}
</Space> </Space>
), ),
}, },
@@ -282,6 +330,7 @@ export function PricingCalculator({
const [preview, setPreview] = useState<PricingPreview | null>(null); const [preview, setPreview] = useState<PricingPreview | null>(null);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [commissionError, setCommissionError] = useState<'missing' | 'forbidden' | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const sequence = useRef(0); const sequence = useRef(0);
const [refresh, setRefresh] = useState(0); const [refresh, setRefresh] = useState(0);
@@ -295,10 +344,16 @@ export function PricingCalculator({
controller = new AbortController(); controller = new AbortController();
setPreview(null); setPreview(null);
setError(''); setError('');
setCommissionError(null);
setBusy(false); setBusy(false);
const problem = pricingInputProblem(input);
if (problem) {
setError(problem);
return () => controller.abort();
}
const parsed = pricingPreviewRequestSchema.safeParse(input); const parsed = pricingPreviewRequestSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
setError(parsed.error.issues.map((i) => i.message).join(';')); setError('请检查定价方案、运输方式和汇率参数');
return () => controller.abort(); return () => controller.abort();
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -309,11 +364,24 @@ export function PricingCalculator({
if (sequence.current === seq) { if (sequence.current === seq) {
setPreview(p); setPreview(p);
setSelected(p.recommended_option_id); setSelected(p.recommended_option_id);
setCommissionError(null);
} }
}) })
.catch((e) => { .catch((e) => {
if (sequence.current === seq && !controller.signal.aborted) if (sequence.current === seq && !controller.signal.aborted) {
setError(apiErrorMessage(e)); 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(() => { .finally(() => {
if (sequence.current === seq) setBusy(false); if (sequence.current === seq) setBusy(false);
@@ -340,9 +408,10 @@ export function PricingCalculator({
const saved = await pricingApi.createPolicy(input.store_id, { const saved = await pricingApi.createPolicy(input.store_id, {
name: policyName ?? '', name: policyName ?? '',
note: null, note: null,
allow_commission_override: !!localCatalog.policies.find( allow_commission_override:
(x) => x.id === p.pricing_policy_id p.temporary_commission_rate !== null ||
)?.allow_commission_override, !!localCatalog.policies.find((x) => x.id === p.pricing_policy_id)
?.allow_commission_override,
parameters: { parameters: {
...p, ...p,
pricing_policy_id: null, pricing_policy_id: null,
@@ -437,16 +506,17 @@ export function PricingCalculator({
<Col span={12} key={k}> <Col span={12} key={k}>
<label> <label>
{label} {label}
<InputNumber<string> <PackageIntegerInput
aria-label={label} aria-label={label}
style={{ width: '100%' }} style={{ width: '100%' }}
stringMode
min="0.001"
value={input.package[k]} value={input.package[k]}
onChange={(v) => onChange={(event) =>
setInput({ setInput({
...input, ...input,
package: { ...input.package, [k]: v ?? '' }, package: {
...input.package,
[k]: event.target.value,
},
}) })
} }
/> />
@@ -493,6 +563,7 @@ export function PricingCalculator({
<PricingParametersFields <PricingParametersFields
value={input.pricing} value={input.pricing}
catalog={localCatalog} catalog={localCatalog}
expandFees={commissionError !== null}
onChange={(pricing) => setInput({ ...input, pricing })} onChange={(pricing) => setInput({ ...input, pricing })}
/> />
</Space> </Space>
@@ -513,7 +584,17 @@ export function PricingCalculator({
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
试算结果 {busy && <Tag color="processing">计算中</Tag>} 试算结果 {busy && <Tag color="processing">计算中</Tag>}
</Typography.Title> </Typography.Title>
{error && <Alert type="warning" title={error} />} {error && (
<Alert
type="warning"
title={error}
description={
commissionError === 'missing'
? '左侧“国内费用、汇率与临时佣金”已展开。临时费率仅用于本次试算,不会保存到定价方案。'
: undefined
}
/>
)}
{expired && ( {expired && (
<Alert <Alert
type="warning" type="warning"
@@ -560,6 +641,11 @@ export function PricingCalculator({
</Col> </Col>
</Row> </Row>
</Card> </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) => ( {option.warnings.map((w) => (
<Alert key={w} title={w} type="warning" /> <Alert key={w} title={w} type="warning" />
))} ))}
+291 -174
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; 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 { FormInstance } from 'antd';
import type { import type {
PricingCatalog, PricingCatalog,
@@ -12,8 +13,9 @@ import type {
} from '@atlas/contracts'; } from '@atlas/contracts';
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts'; import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing'; import { pricingApi } from '@/services/pricing';
import { apiErrorMessage } from '@/services/api'; import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { defaultPricing, PricingCalculator } from './PricingCalculator'; import { defaultPricing, PricingCalculator } from './PricingCalculator';
import { pricingInputProblem } from './pricingInputValidation';
type Values = SaveProductEditorDraftRequest; type Values = SaveProductEditorDraftRequest;
export function skuPricingInput( export function skuPricingInput(
values: Values, values: Values,
@@ -24,6 +26,12 @@ export function skuPricingInput(
const v = values.variants[index], const v = values.variants[index],
base = values.common.default_package_spec, base = values.common.default_package_spec,
pkg = v.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 { return {
store_id: values.profile.target_store_id, store_id: values.profile.target_store_id,
platform_product_category_id: values.profile.platform_product_category_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 ?? '', purchase_cost_cny: v.purchase_cost_cny ?? '',
package: { package: {
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '', ...effectivePackage,
length_mm: pkg.length_mm ?? base.length_mm ?? '', status: Object.values(effectivePackage).every(Boolean)
width_mm: pkg.width_mm ?? base.width_mm ?? '', ? 'confirmed'
height_mm: pkg.height_mm ?? base.height_mm ?? '', : ((pkg.status ?? base.status) as 'confirmed'),
status: (pkg.status ?? base.status) as 'confirmed',
}, },
pricing: parameters, pricing: parameters,
}; };
} }
const keyFor = pricingInputKey; 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({ export function SkuPricing({
form, form,
index, index,
@@ -52,6 +67,7 @@ export function SkuPricing({
auto, auto,
batch, batch,
onChanged, onChanged,
children,
}: { }: {
form: FormInstance<Values>; form: FormInstance<Values>;
index: number; index: number;
@@ -59,6 +75,7 @@ export function SkuPricing({
auto: boolean; auto: boolean;
batch: number; batch: number;
onChanged: () => void; onChanged: () => void;
children: (slots: PricingSlots) => ReactNode;
}) { }) {
Form.useWatch('variants', form); Form.useWatch('variants', form);
Form.useWatch('common', 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 live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
const liveKey = live ? keyFor(live) : ''; const liveKey = live ? keyFor(live) : '';
const liveRef = useRef(liveKey); const liveRef = useRef(liveKey);
@@ -193,10 +213,14 @@ export function SkuPricing({
} }
async function calculate(force = false, override?: PricingParameters) { async function calculate(force = false, override?: PricingParameters) {
if (!catalog || !v || original?.listing.status === 'imported') return; 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 current = form.getFieldValue(['variants', index]) as Values['variants'][number];
const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog); const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog);
if (!parameters) { if (!parameters) {
setError('没有已审核物流费率,请先配置费率'); setError('没有可用的物流费率,请检查费率卡和草稿卡使用配置');
return; return;
} }
const input = skuPricingInput( const input = skuPricingInput(
@@ -206,10 +230,15 @@ export function SkuPricing({
parameters parameters
), ),
inputKey = keyFor(input); inputKey = keyFor(input);
const problem = pricingInputProblem(input);
if (problem) {
setError(problem);
return;
}
if (!force && ((lastRequest.current === inputKey && resultValid) || confirmed)) return; if (!force && ((lastRequest.current === inputKey && resultValid) || confirmed)) return;
const parsed = pricingPreviewRequestSchema.safeParse(input); const parsed = pricingPreviewRequestSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
setError('请补齐采购价、类目,并确认有效包装重量和尺寸'); setError('请检查定价方案、运输方式和汇率参数');
return; return;
} }
setParameters(parameters); setParameters(parameters);
@@ -238,7 +267,13 @@ export function SkuPricing({
apply(input, p, recommended.id, false); apply(input, p, recommended.id, false);
} catch (e) { } catch (e) {
if (seq === sequence.current && !controller.current.signal.aborted) 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 { } finally {
if (seq === sequence.current) setBusy(false); if (seq === sequence.current) setBusy(false);
} }
@@ -271,177 +306,259 @@ export function SkuPricing({
}, [batch]); }, [batch]);
if (!v) return null; if (!v) return null;
const imported = original?.listing.status === 'imported'; const imported = original?.listing.status === 'imported';
return ( const currency = catalog?.store.currency_code ?? original?.listing.currency_code ?? 'CNY';
<div style={{ display: 'flex', gap: 12, alignItems: 'start', flexWrap: 'wrap', flex: 1 }}> const saleCny =
<div className="sku-card-field sku-price-field"> params?.mode === 'fixed_sale' && currency === 'CNY'
<span>采购价(CNY)</span> ? params.selling_price
<InputNumber<string> : (option?.merchandise_value_cny ??
aria-label={`SKU ${index + 1} 采购价`} original?.listing.pricing_result?.merchandise_value_cny);
min="0" const profitCny = option?.profit_cny;
stringMode const statusText = busy
precision={2} ? '计算中'
value={v.purchase_cost_cny} : error || !fulfillmentSupported
onChange={(value) => { ? '定价需处理'
form.setFieldValue(['variants', index, 'purchase_cost_cny'], value); : confirmed
onChanged(); ? '定价已确认'
}} : resultValid
onBlur={() => { ? result?.option.can_confirm === false
if (auto && !imported) void calculate(); ? '亏损 · 请调整售价'
}} : '已试算 · 待保存'
onPressEnter={(event) => { : v.pricing_selection
event.preventDefault(); ? '输入已变化 · 需重算'
void calculate(true); : '待定价';
}} const needsAttention =
/> !!error ||
</div> !fulfillmentSupported ||
<div className="sku-card-field sku-price-field"> (resultValid && result?.option.can_confirm === false) ||
<span> (!!v.pricing_selection && !resultValid);
销售价({catalog?.store.currency_code ?? original?.listing.currency_code ?? '—'} const policyName =
) catalog?.policies.find((policy) => policy.id === params?.pricing_policy_id)?.name ??
</span> '本次参数';
<InputNumber<string> const feedback =
aria-label={`SKU ${index + 1} 销售价`} error ||
min="0.01" (!fulfillmentSupported
stringMode ? '当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式'
precision={2} : v.pricing_selection && !resultValid && !confirmed
disabled={imported || !params} ? '定价输入已变化,请重新试算后保存。'
value={ : confirmed
params?.mode === 'fixed_sale' ? '当前输入与已保存的试算一致。'
? params.selling_price : resultValid
: (option?.selling_price ?? original?.listing.selling_price) ? '试算完成,保存商品后确认本次报价。'
} : '填写采购价和包装规格后可开始试算。');
onChange={(value) => { return children({
if (params) status: (
setParameters({ ...params, mode: 'fixed_sale', selling_price: value }); <span
}} className={`sku-pricing-status${needsAttention ? ' sku-pricing-status--warning' : confirmed ? ' sku-pricing-status--confirmed' : ''}`}
onBlur={() => { >
if (auto) void calculate(true); {statusText}
}} </span>
onPressEnter={(event) => { ),
event.preventDefault(); summary: (
void calculate(true); <div className="sku-pricing-summary">
}} <div>
/> <span>采购价</span>
</div> <strong>{cny(v.purchase_cost_cny)}</strong>
<div className="sku-card-value"> </div>
<span>预估净利润(CNY)</span> <div>
<strong <span>售价</span>
style={{ <strong>{cny(saleCny)}</strong>
color: option && Number(option.profit_cny) < 0 ? '#cf1322' : '#389e0d', </div>
}} <div>
> <span>预估净利润</span>
{option?.profit_cny ?? '—'} <strong
</strong> className={
</div> profitCny && Number(profitCny) < 0
{!imported && ( ? 'sku-pricing-loss'
<Space> : profitCny
<Button loading={busy} onClick={() => void calculate(true)}> ? 'sku-pricing-profit'
计算 : ''
</Button> }
<Button
disabled={!catalog || !params}
onClick={() => {
restoreInheritance.current = false;
setModal(
skuPricingInput(
form.getFieldsValue(true),
index,
snapshot.profile.id,
params!
)
);
}}
> >
试算详情 {cny(profitCny)}
</Button> </strong>
</Space> </div>
)} </div>
<div style={{ width: '100%' }}> ),
<Space wrap> details: (
<Tag color={confirmed ? 'success' : resultValid ? 'purple' : 'default'}> <section className="sku-pricing-panel" aria-label={`SKU ${index + 1} 定价`}>
{busy <div className="sku-panel-heading">
? '计算中' <h5>定价</h5>
: confirmed <div className="sku-panel-actions">
? '已确认' {!imported && (
: 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>
<Button <Button
size="small" loading={busy}
type="link" type="primary"
onClick={() => void calculate(true)}
>
重新试算
</Button>
)}
{!imported && (
<Button
disabled={!catalog || !params}
onClick={() => { onClick={() => {
const p = { restoreInheritance.current = false;
...params, setModal(
mode: 'target_markup' as const, skuPricingInput(
selling_price: null, form.getFieldsValue(true),
}; index,
setParameters(p); snapshot.profile.id,
void calculate(true, p); params!
)
);
}} }}
> >
使用建议售价 试算详情
</Button> </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>
)} </div>
</div> <div className="sku-pricing-fields">
<Modal <label>
title={`${v.display_name ?? 'SKU'} · 定价试算`} <span>采购价(人民币)</span>
open={!!modal} <InputNumber<string>
width={1000} aria-label={`SKU ${index + 1} 采购价(人民币)`}
footer={null} min="0"
destroyOnHidden stringMode
onCancel={() => setModal(null)} precision={2}
> prefix="¥"
{modal && catalog && ( value={v.purchase_cost_cny}
<> onChange={(value) => {
<Typography.Paragraph type="secondary"> form.setFieldValue(['variants', index, 'purchase_cost_cny'], value);
包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。 onChanged();
</Typography.Paragraph>
<Button
size="small"
onClick={() => {
restoreInheritance.current = true;
const base = form.getFieldValue(['common', 'default_package_spec']);
setModal({ ...modal, package: { ...base, status: base.status } });
}} }}
> onBlur={() => {
使用 SPU 包装重新试算 if (auto && !imported) void calculate();
</Button> }}
<PricingCalculator onPressEnter={(event) => {
key={JSON.stringify(modal)} event.preventDefault();
initial={modal} void calculate(true);
previous={result?.option ?? original?.listing.pricing_result}
catalog={catalog}
onCancel={() => setModal(null)}
onApply={(input, p, id) => {
apply(input, p, id, true);
setModal(null);
}} }}
/> />
</> </label>
)} <label>
</Modal> <span>售价(人民币)</span>
</div> {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;
}
+8 -1
View File
@@ -53,6 +53,11 @@ export default function SystemAdminLayout() {
icon: <DatabaseOutlined />, icon: <DatabaseOutlined />,
label: '平台类目与属性', label: '平台类目与属性',
}, },
{
key: '/system/commissions',
icon: <CalculatorOutlined />,
label: '类目佣金',
},
{ {
key: '/system/pricing-rates', key: '/system/pricing-rates',
icon: <CalculatorOutlined />, icon: <CalculatorOutlined />,
@@ -68,7 +73,9 @@ export default function SystemAdminLayout() {
系统管理 /{' '} 系统管理 /{' '}
{location.pathname.startsWith('/system/pricing-rates') {location.pathname.startsWith('/system/pricing-rates')
? '物流费率审核' ? '物流费率审核'
: '平台类目与属性'} : location.pathname.startsWith('/system/commissions')
? '类目佣金'
: '平台类目与属性'}
</span> </span>
<Button <Button
type="text" type="text"
+3 -12
View File
@@ -126,7 +126,7 @@ export default function PricingPage() {
catalog && !parameters ? ( catalog && !parameters ? (
<Alert <Alert
type="warning" type="warning"
title="当前平台没有可用于正式试算的物流费率" title="当前平台没有可用的物流费率"
description={ description={
<Space orientation="vertical" size="small"> <Space orientation="vertical" size="small">
{catalog.pending_rate_cards.length > 0 && ( {catalog.pending_rate_cards.length > 0 && (
@@ -138,8 +138,8 @@ export default function PricingPage() {
</span> </span>
)} )}
<span> <span>
待复核费率不能用于正式报价。系统管理员须在「系统管理 → 请检查费率卡状态和草稿卡使用配置。需要核实运费时,系统管理员可在「系统管理 →
物流费率审核」录入真实账单样本,复算通过后按生效日期启用。 物流费率审核」录入真实账单样本并启用已审核版本。
</span> </span>
{adminAccess === 'allowed' && ( {adminAccess === 'allowed' && (
<Button <Button
@@ -183,15 +183,6 @@ export default function PricingPage() {
/> />
{error && <Alert type="error" title={error} />}{' '} {error && <Alert type="error" title={error} />}{' '}
{!store && !error && <Empty description="请先创建目标门店" />} {!store && !error && <Empty description="请先创建目标门店" />}
{catalog && (
catalog.rate_cards.some((card) => card.development_only) && (
<Alert
type="warning"
title="当前使用开发草稿费率"
description="可用于方案、试算和商品编辑联调;按草稿费率确认的 SKU 不能首次上架,正式审核后需重新试算确认。"
/>
)
)}
{catalog && ( {catalog && (
<Tabs <Tabs
items={[ items={[
+136 -103
View File
@@ -27,6 +27,7 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { productEditorApi } from '@/services/productEditor'; import { productEditorApi } from '@/services/productEditor';
import { usePlatform } from '@/services/platform'; import { usePlatform } from '@/services/platform';
import { collectionApi } from '@/services/collection'; import { collectionApi } from '@/services/collection';
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
import { import {
CollectedProductInfoPanel, CollectedProductInfoPanel,
type CollectedProductSummary, type CollectedProductSummary,
@@ -46,25 +47,6 @@ import './ProductEditPage.css';
type EditorFormValues = SaveProductEditorDraftRequest; type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number]; 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 一条, * 按 schema 补齐表单中缺失的属性草稿:aspect(变体维度)属性逐个 SKU 一条,
* 其余类目级一条,确保 Form.List 字段与 schema 一一对应、可按定义渲染。 * 其余类目级一条,确保 Form.List 字段与 schema 一一对应、可按定义渲染。
@@ -108,7 +90,7 @@ function missingAttributeDrafts(
} }
/** 服务端快照 → antd 表单初值;已有变体不携带 client_key(仅新建/复制时生成)。 */ /** 服务端快照 → antd 表单初值;已有变体不携带 client_key(仅新建/复制时生成)。 */
function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { function formValues(snapshot: ProductEditorSnapshot, platformCode: string): EditorFormValues {
return { return {
ai_source_material: snapshot.source_material, ai_source_material: snapshot.source_material,
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields, ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
@@ -116,10 +98,15 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
...snapshot.product.common, ...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec, default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: snapshot.product.default_package_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: { profile: {
target_store_id: snapshot.profile.target_store.id, 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, title: snapshot.profile.title,
description: snapshot.profile.description, description: snapshot.profile.description,
tags: snapshot.profile.tags, tags: snapshot.profile.tags,
@@ -258,6 +245,22 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
height_mm: nullable(spec.height_mm), height_mm: nullable(spec.height_mm),
shipping_weight_g: nullable(spec.shipping_weight_g), 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 { return {
...values, ...values,
common: { common: {
@@ -268,7 +271,9 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
purchase_url: nullable(values.common.purchase_url), purchase_url: nullable(values.common.purchase_url),
supply_note: nullable(values.common.supply_note), supply_note: nullable(values.common.supply_note),
default_physical_spec: physical(values.common.default_physical_spec), 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: { profile: {
...values.profile, ...values.profile,
@@ -282,21 +287,40 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean); const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean);
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : []; return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
}), }),
variants: values.variants.map((variant) => ({ variants: values.variants.map((variant) => {
...variant, const normalizedPackage = packageSpec(variant.package_spec);
display_name: nullable(variant.display_name), if (weightByVariant || dimensionsByVariant) {
source_sku: nullable(variant.source_sku), const effectivePackage = {
purchase_cost_cny: nullable(variant.purchase_cost_cny), ...normalizedPackage,
offer_id: nullable(variant.offer_id), length_mm: normalizedPackage.length_mm ?? commonPackage.length_mm,
title: nullable(variant.title), width_mm: normalizedPackage.width_mm ?? commonPackage.width_mm,
specification_values: variant.specification_values.map((item) => ({ height_mm: normalizedPackage.height_mm ?? commonPackage.height_mm,
axis_key: item.axis_key.trim(), shipping_weight_g:
value_key: item.value_key.trim(), normalizedPackage.shipping_weight_g ?? commonPackage.shipping_weight_g,
value_name: item.value_name.trim(), };
})), normalizedPackage.status =
physical_spec: physical(variant.physical_spec), normalizedPackage.status === 'ambiguous'
package_spec: packageSpec(variant.package_spec), ? '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 dimensions = isDimension ? millimetres(attribute.value) : null;
const weight = isWeight ? numberInGrams(attribute.value) : null; const weight = isWeight ? numberInGrams(attribute.value) : null;
if (isPackage && dimensions) { if (isPackage && dimensions) {
[result.package_length_mm, result.package_width_mm, result.package_height_mm] = if (isPositivePackageInteger(dimensions[0])) result.package_length_mm = dimensions[0];
dimensions; 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; return result;
} }
@@ -489,15 +514,9 @@ const ProductEditPage = () => {
setSourceDraft(next.source_material); setSourceDraft(next.source_material);
setSourceOverriddenFields(next.source_material_state.overridden_fields); setSourceOverriddenFields(next.source_material_state.overridden_fields);
sourceSpecsApplied.current = false; sourceSpecsApplied.current = false;
form.setFieldsValue(formValues(next)); form.setFieldsValue(formValues(next, currentPlatform.code));
setUsesVariantPackageWeight( setUsesVariantPackageWeight(next.product.package_weight_mode === 'variant');
next.variants.length > 0 && setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant');
next.variants.every((variant) => hasPackageWeight(variant.package_spec))
);
setUsesVariantPackageDimensions(
next.variants.length > 0 &&
next.variants.every((variant) => hasPackageDimensions(variant.package_spec))
);
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0)); setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
setUsesVariantDescription(next.variants.some((variant) => !!variant.description)); setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
setUsesVariantTitle(next.variants.some((variant) => !!variant.title)); setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
@@ -696,29 +715,40 @@ const ProductEditPage = () => {
}; };
}, [currentPlatform, snapshot]); }, [currentPlatform, snapshot]);
const collected = sourceDraft ?? snapshot?.source_material ?? null; const collected = sourceDraft ?? snapshot?.source_material ?? null;
// 首次拿到采集数据时只自动填充 SPU 默认包装规格:只补空字段,绝不覆盖已有值; // 首次拿到采集数据时只自动填充通用模式下的 SPU 包装规格:只补空字段,绝不覆盖已有值;
// 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。 // 商品净重、尺寸属于 Ozon 类目属性,必须等 schema 决定 SPU/SKU 归属后由动态字段填写。
// sourceSpecsApplied 保证整个页面生命周期只执行一次。 // sourceSpecsApplied 保证整个页面生命周期只执行一次。
useEffect(() => { useEffect(() => {
if (sourceSpecsApplied.current || !collected) return; if (sourceSpecsApplied.current || !collected) return;
const specs = detectedSpecs(collected.attributes); const specs = detectedSpecs(collected.attributes);
const packageSpec = form.getFieldValue(['common', 'default_package_spec']); 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 = [ const hasAppliedValue = [
[packageSpec?.length_mm, specs.package_length_mm], [commonDimensions, packageSpec?.length_mm, specs.package_length_mm],
[packageSpec?.width_mm, specs.package_width_mm], [commonDimensions, packageSpec?.width_mm, specs.package_width_mm],
[packageSpec?.height_mm, specs.package_height_mm], [commonDimensions, packageSpec?.height_mm, specs.package_height_mm],
[packageSpec?.shipping_weight_g, specs.shipping_weight_g], [commonWeight, packageSpec?.shipping_weight_g, specs.shipping_weight_g],
].some(([current, detected]) => !current && !!detected); ].some(([enabled, current, detected]) => enabled && !current && !!detected);
if (hasAppliedValue) { if (hasAppliedValue) {
form.setFieldsValue({ form.setFieldsValue({
common: { common: {
default_package_spec: { default_package_spec: {
...packageSpec, ...packageSpec,
length_mm: packageSpec?.length_mm ?? specs.package_length_mm ?? null, length_mm: commonDimensions
width_mm: packageSpec?.width_mm ?? specs.package_width_mm ?? null, ? (packageSpec?.length_mm ?? specs.package_length_mm ?? null)
height_mm: packageSpec?.height_mm ?? specs.package_height_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: 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]); }, [blocker, modal]);
const leave = () => navigate('/collection'); const leave = () => navigate('/collection');
/** 将 SPU 共用包装重量复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */ /** 包装重量和尺寸独立切换;退出逐 SKU 模式时清除全部 SKU 对应覆盖值。 */
const useVariantPackageWeight = () => { const changePackageMode = (field: 'weight' | 'dimensions', mode: 'common' | 'variant') => {
const defaultPackageSpec = form.getFieldValue([ const keys =
'common', field === 'weight'
'default_package_spec', ? (['shipping_weight_g'] as const)
]) as EditorFormValues['common']['default_package_spec']; : (['length_mm', 'width_mm', 'height_mm'] as const);
const variants = form.getFieldValue('variants') as EditorFormValues['variants']; const modeKey = field === 'weight' ? 'package_weight_mode' : 'package_dimensions_mode';
form.setFieldsValue({ if (mode === 'variant') {
variants: variants.map((variant) => { keys.forEach((key) =>
const packageSpec = variant.package_spec; form.setFieldValue(['common', 'default_package_spec', key], null)
return { );
...variant, form.setFieldValue(['common', 'default_package_spec', 'status'], 'missing');
package_spec: { } else {
...packageSpec, const variants = (form.getFieldValue('variants') ?? []) as EditorFormValues['variants'];
shipping_weight_g: variants.forEach((_, index) => {
packageSpec.shipping_weight_g ?? defaultPackageSpec.shipping_weight_g, keys.forEach((key) =>
}, form.setFieldValue(['variants', index, 'package_spec', key], null)
}; );
}), form.setFieldValue(['variants', index, 'package_spec', 'status'], null);
}); });
setUsesVariantPackageWeight(true); }
form.setFieldValue(['common', modeKey], mode);
if (field === 'weight') setUsesVariantPackageWeight(mode === 'variant');
else setUsesVariantPackageDimensions(mode === 'variant');
setDirty(true); setDirty(true);
}; };
/** 将 SPU 共用包装尺寸复制到每个 SKU,保留默认值作为未填写 SKU 的安全回退。 */ const confirmCommonPackageMode = (field: 'weight' | 'dimensions') => {
const useVariantPackageDimensions = () => { const label = field === 'weight' ? '包装重量' : '包装尺寸';
const defaultPackageSpec = form.getFieldValue([ modal.confirm({
'common', title: `将${label}设为通用?`,
'default_package_spec', content: `确认后将清空所有 SKU 的${label},并恢复商品通用${label}的编辑。`,
]) as EditorFormValues['common']['default_package_spec']; okText: '确认调整',
const variants = form.getFieldValue('variants') as EditorFormValues['variants']; okButtonProps: { danger: true },
form.setFieldsValue({ cancelText: '取消',
variants: variants.map((variant) => { onOk: () => changePackageMode(field, 'common'),
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,
},
};
}),
}); });
setUsesVariantPackageDimensions(true);
setDirty(true);
}; };
const useVariantPackageWeight = () => changePackageMode('weight', 'variant');
const useVariantPackageDimensions = () => changePackageMode('dimensions', 'variant');
const useCommonPackageWeight = () => confirmCommonPackageMode('weight');
const useCommonPackageDimensions = () => confirmCommonPackageMode('dimensions');
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */ /** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
const useVariantTags = () => setUsesVariantTags(true); const useVariantTags = () => setUsesVariantTags(true);
const useVariantDescription = () => setUsesVariantDescription(true); const useVariantDescription = () => setUsesVariantDescription(true);
@@ -953,7 +977,12 @@ const ProductEditPage = () => {
'height_mm', 'height_mm',
'shipping_weight_g', 'shipping_weight_g',
] as const) { ] 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]; spec[key] = result.package_spec[key];
packageFilled = true; packageFilled = true;
} }
@@ -1410,6 +1439,8 @@ const ProductEditPage = () => {
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })} onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
onUseVariantPackageWeight={useVariantPackageWeight} onUseVariantPackageWeight={useVariantPackageWeight}
onUseVariantPackageDimensions={useVariantPackageDimensions} onUseVariantPackageDimensions={useVariantPackageDimensions}
onUseCommonPackageWeight={useCommonPackageWeight}
onUseCommonPackageDimensions={useCommonPackageDimensions}
/> />
<ProductAttributeSection <ProductAttributeSection
@@ -1452,6 +1483,8 @@ const ProductEditPage = () => {
onLoadMoreDictionaryValues={loadMoreDictionaryValues} onLoadMoreDictionaryValues={loadMoreDictionaryValues}
usesVariantPackageWeight={usesVariantPackageWeight} usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageDimensions={usesVariantPackageDimensions} usesVariantPackageDimensions={usesVariantPackageDimensions}
onUseCommonPackageWeight={useCommonPackageWeight}
onUseCommonPackageDimensions={useCommonPackageDimensions}
usesVariantTags={usesVariantTags} usesVariantTags={usesVariantTags}
usesVariantDescription={usesVariantDescription} usesVariantDescription={usesVariantDescription}
usesVariantTitle={usesVariantTitle} usesVariantTitle={usesVariantTitle}
@@ -44,7 +44,7 @@
.product-editor .editor-section .dynamic-attribute-item .ant-form-item-label > label { .product-editor .editor-section .dynamic-attribute-item .ant-form-item-label > label {
width: 100%; width: 100%;
height: auto; height: auto;
align-items: flex-start; align-items: center;
justify-content: flex-end; justify-content: flex-end;
overflow: visible; overflow: visible;
font-weight: 400; font-weight: 400;
@@ -54,6 +54,24 @@
white-space: normal; white-space: normal;
word-break: break-word; 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 三列表单的节奏。 */ /* 多值字段把上限说明收进标题提示,避免额外说明行打乱 SKU 三列表单的节奏。 */
.dynamic-attribute-label { .dynamic-attribute-label {
display: inline-flex; display: inline-flex;
@@ -106,10 +124,10 @@
border-color: #667085; border-color: #667085;
background: transparent; background: transparent;
} }
/* 多选标签保持单行控件的 32px 基准高度;多项换行时仍可按内容自然增高。 */ /* 多选标签与全站输入控件使用 35px 基准高度;多项换行时按内容自然增高。 */
.product-color-select.ant-select-multiple .ant-select-selector { .product-color-select.ant-select-multiple .ant-select-selector {
box-sizing: border-box; box-sizing: border-box;
min-height: 32px; min-height: 35px;
padding-top: 1px; padding-top: 1px;
padding-bottom: 1px; padding-bottom: 1px;
} }
@@ -14,7 +14,7 @@
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px) minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(64px, 130px)
22px 32px; 22px max-content;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
max-width: 100%; max-width: 100%;
@@ -31,7 +31,7 @@
line-height: 32px; line-height: 32px;
white-space: nowrap; white-space: nowrap;
} }
.package-dimensions-controls .ant-input-number { .package-dimensions-controls .ant-input {
width: 100%; width: 100%;
} }
.package-weight-control { .package-weight-control {
@@ -39,6 +39,15 @@
align-items: center; align-items: center;
gap: 4px; 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 挂载) */ /* 类目下拉弹层(Cascader popup,经 classNames.root 挂载) */
.editor-category-popup .ant-cascader-menu { .editor-category-popup .ant-cascader-menu {
@@ -90,6 +99,9 @@
width: 100%; width: 100%;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.package-dimensions-controls .package-mode-button {
justify-self: start;
}
.package-dimension-separator { .package-dimension-separator {
display: none; 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 type { FormInstance } from 'antd';
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons'; import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
import type { ProductEditorSnapshot } from '@atlas/contracts'; import type { ProductEditorSnapshot } from '@atlas/contracts';
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
import { SectionTitle } from './SectionTitle'; import { SectionTitle } from './SectionTitle';
import './ProductMainInfoSection.css'; import './ProductMainInfoSection.css';
@@ -20,62 +21,74 @@ function PackageFields({
usesVariantPackageDimensions, usesVariantPackageDimensions,
onUseVariantPackageWeight, onUseVariantPackageWeight,
onUseVariantPackageDimensions, onUseVariantPackageDimensions,
onUseCommonPackageWeight,
onUseCommonPackageDimensions,
}: { }: {
name: NamePath; name: NamePath;
usesVariantPackageWeight: boolean; usesVariantPackageWeight: boolean;
usesVariantPackageDimensions: boolean; usesVariantPackageDimensions: boolean;
onUseVariantPackageWeight: () => void; onUseVariantPackageWeight: () => void;
onUseVariantPackageDimensions: () => void; onUseVariantPackageDimensions: () => void;
onUseCommonPackageWeight: () => void;
onUseCommonPackageDimensions: () => void;
}) { }) {
return ( return (
<div className="package-fields"> <div className="package-fields">
{!usesVariantPackageWeight && ( <Form.Item label="包装重量" required={!usesVariantPackageWeight}>
<Form.Item name={[...name, 'shipping_weight_g']} label="包装重量" required> <div className="package-weight-control">
<div className="package-weight-control"> <Form.Item name={[...name, 'shipping_weight_g']} noStyle rules={[packageIntegerRule]}>
<InputNumber<string> <PackageIntegerInput
min="0"
step="0.001"
stringMode
addonAfter="g" addonAfter="g"
style={{ width: 150 }} style={{ width: 150 }}
disabled={usesVariantPackageWeight}
/> />
<Tooltip title="按 SKU 分别填写包装重量"> </Form.Item>
<Button <Button
type="text" type="link"
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />} className="package-mode-button"
aria-label="按 SKU 分别填写包装重量" aria-label={`${usesVariantPackageWeight ? '设为通用' : '按 SKU 设置'}包装重量`}
onClick={onUseVariantPackageWeight} onClick={
/> usesVariantPackageWeight
</Tooltip> ? onUseCommonPackageWeight
</div> : onUseVariantPackageWeight
</Form.Item> }
)} >
{!usesVariantPackageDimensions && ( {usesVariantPackageWeight ? '设为通用' : '按 SKU 设置'}
<Form.Item label="包装尺寸" required className="package-dimensions"> </Button>
<div className="package-dimensions-controls"> </div>
<Form.Item name={[...name, 'length_mm']} noStyle> </Form.Item>
<InputNumber<string> min="0" step="0.001" stringMode placeholder="长" /> <Form.Item
</Form.Item> label="包装尺寸"
<span className="package-dimension-separator">×</span> required={!usesVariantPackageDimensions}
<Form.Item name={[...name, 'width_mm']} noStyle> className="package-dimensions"
<InputNumber<string> min="0" step="0.001" stringMode placeholder="宽" /> >
</Form.Item> <div className="package-dimensions-controls">
<span className="package-dimension-separator">×</span> <Form.Item name={[...name, 'length_mm']} noStyle rules={[packageIntegerRule]}>
<Form.Item name={[...name, 'height_mm']} noStyle> <PackageIntegerInput placeholder="长" disabled={usesVariantPackageDimensions} />
<InputNumber<string> min="0" step="0.001" stringMode placeholder="高" /> </Form.Item>
</Form.Item> <span className="package-dimension-separator">×</span>
<span className="package-dimension-unit">mm</span> <Form.Item name={[...name, 'width_mm']} noStyle rules={[packageIntegerRule]}>
<Tooltip title="按 SKU 分别填写包装尺寸"> <PackageIntegerInput placeholder="宽" disabled={usesVariantPackageDimensions} />
<Button </Form.Item>
type="text" <span className="package-dimension-separator">×</span>
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />} <Form.Item name={[...name, 'height_mm']} noStyle rules={[packageIntegerRule]}>
aria-label="按 SKU 分别填写包装尺寸" <PackageIntegerInput placeholder="高" disabled={usesVariantPackageDimensions} />
onClick={onUseVariantPackageDimensions} </Form.Item>
/> <span className="package-dimension-unit">mm</span>
</Tooltip> <Button
</div> type="link"
</Form.Item> className="package-mode-button"
)} aria-label={`${usesVariantPackageDimensions ? '设为通用' : '按 SKU 设置'}包装尺寸`}
onClick={
usesVariantPackageDimensions
? onUseCommonPackageDimensions
: onUseVariantPackageDimensions
}
>
{usesVariantPackageDimensions ? '设为通用' : '按 SKU 设置'}
</Button>
</div>
</Form.Item>
</div> </div>
); );
} }
@@ -93,6 +106,8 @@ type Props = {
usesVariantPackageDimensions: boolean; usesVariantPackageDimensions: boolean;
onUseVariantPackageWeight: () => void; onUseVariantPackageWeight: () => void;
onUseVariantPackageDimensions: () => void; onUseVariantPackageDimensions: () => void;
onUseCommonPackageWeight: () => void;
onUseCommonPackageDimensions: () => void;
usesVariantTitle: boolean; usesVariantTitle: boolean;
onUseVariantTitle: () => void; onUseVariantTitle: () => void;
onCompareTitle: () => void; onCompareTitle: () => void;
@@ -117,6 +132,8 @@ export function ProductMainInfoSection({
usesVariantPackageDimensions, usesVariantPackageDimensions,
onUseVariantPackageWeight, onUseVariantPackageWeight,
onUseVariantPackageDimensions, onUseVariantPackageDimensions,
onUseCommonPackageWeight,
onUseCommonPackageDimensions,
usesVariantTitle, usesVariantTitle,
onUseVariantTitle, onUseVariantTitle,
onCompareTitle, onCompareTitle,
@@ -172,7 +189,7 @@ export function ProductMainInfoSection({
name={['profile', 'platform_product_category_id']} name={['profile', 'platform_product_category_id']}
label="产品类目" label="产品类目"
className="span-full" className="span-full"
rules={[{ required: true, message: '请选择产品类目' }]} required
getValueProps={(categoryId: string | null) => ({ getValueProps={(categoryId: string | null) => ({
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [], value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
})} })}
@@ -205,7 +222,8 @@ export function ProductMainInfoSection({
name={['common', 'source_title']} name={['common', 'source_title']}
label="中文商品名" label="中文商品名"
className="span-full" className="span-full"
rules={[{ required: true, message: '请填写中文商品名' }, { max: 1000 }]} required
rules={[{ max: 1000 }]}
> >
<Input placeholder="填写中文商品名" /> <Input placeholder="填写中文商品名" />
</Form.Item> </Form.Item>
@@ -214,14 +232,7 @@ export function ProductMainInfoSection({
<Form.Item <Form.Item
name={['profile', 'title']} name={['profile', 'title']}
noStyle noStyle
rules={[ rules={[{ max: 1000 }]}
{
required: true,
whitespace: true,
message: '请填写俄文发布标题',
},
{ max: 1000 },
]}
> >
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} /> <Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
</Form.Item> </Form.Item>
@@ -248,27 +259,27 @@ export function ProductMainInfoSection({
<Form.Item <Form.Item
name={['common', 'model']} name={['common', 'model']}
label="型号" label="型号"
rules={[{ required: true, message: '请填写型号' }]} required
> >
<Input className="editor-short-input" placeholder="首次上架前需填写" /> <Input className="editor-short-input" placeholder="首次上架前需填写" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name={['common', 'brand']} name={['common', 'brand']}
label="品牌" label="品牌"
rules={[{ required: true, message: '请填写品牌;无品牌请填写“无品牌”' }]} required
> >
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" /> <Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
</Form.Item> </Form.Item>
</div> </div>
{(!usesVariantPackageWeight || !usesVariantPackageDimensions) && ( <PackageFields
<PackageFields name={['common', 'default_package_spec']}
name={['common', 'default_package_spec']} usesVariantPackageWeight={usesVariantPackageWeight}
usesVariantPackageWeight={usesVariantPackageWeight} usesVariantPackageDimensions={usesVariantPackageDimensions}
usesVariantPackageDimensions={usesVariantPackageDimensions} onUseVariantPackageWeight={onUseVariantPackageWeight}
onUseVariantPackageWeight={onUseVariantPackageWeight} onUseVariantPackageDimensions={onUseVariantPackageDimensions}
onUseVariantPackageDimensions={onUseVariantPackageDimensions} onUseCommonPackageWeight={onUseCommonPackageWeight}
/> onUseCommonPackageDimensions={onUseCommonPackageDimensions}
)} />
</Card> </Card>
); );
} }
@@ -1,366 +1,566 @@
/* 「03 变体设置」区块:SKU 卡片、展开后的图片选择与规格面板。 */ /* SKU 卡片沿用编辑页配色,收起态仅保留识别、价格和单一试算状态。 */
.sku-section.ant-card {
/* SKU 卡片内的输入圆角与 antd 输入框保持一致 */ container-type: inline-size;
.sku-section .ant-input {
border-radius: 8px;
} }
.sku-section .ant-input-number { .sku-section-count {
width: 100%; display: inline-flex;
border-radius: 8px; 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 { .sku-card-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.sku-card.ant-card { .sku-card.ant-card {
position: relative; border: 1px solid #e8e4ed;
border: 1px solid #e8e4ee;
border-radius: 12px; border-radius: 12px;
box-shadow: none; box-shadow: none;
overflow: hidden;
} }
/* 用 .sku-card.ant-card 提高权重:内层卡片同时命中外层
.editor-section .ant-card-body(页面级)规则,同级特异性会按加载顺序被覆盖 */
.sku-card.ant-card > .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 { .sku-card-main {
grid-column: 2;
grid-row: 1;
display: flex; 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; align-items: flex-start;
} gap: 12px;
.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 {
min-width: 0; min-width: 0;
} padding: 15px 16px 12px;
/* 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;
} }
.sku-main-thumbnail { .sku-main-thumbnail {
position: relative; position: relative;
display: flex; display: flex;
flex: 0 0 auto; width: 96px;
align-self: flex-start; height: 128px;
width: 75px;
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 4px;
overflow: hidden; overflow: hidden;
border: 1px solid #ece8f0; border: 1px solid #e8e4ed;
border-radius: 8px; border-radius: 8px;
background: #f4f2f7; background: #f6f4f8;
color: #90889c; color: #90889c;
font-size: 10px; font-size: 11px;
text-align: center; text-align: center;
} }
.sku-main-thumbnail .ant-image { .sku-main-thumbnail img {
position: absolute;
inset: 0 4px;
display: flex;
align-items: center;
justify-content: center;
}
.sku-main-thumbnail .ant-image img {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
object-position: center;
} }
.sku-card-value { .sku-main-badge,
flex: 0 0 auto; .sku-main-count {
}
.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 {
position: absolute; position: absolute;
top: 4px;
left: 4px;
z-index: 1; z-index: 1;
padding: 2px 6px; left: 8px;
border-radius: 4px; padding: 3px 6px;
background: rgb(64 57 79 / 72%); border-radius: 5px;
background: rgb(41 36 51 / 82%);
color: #fff; color: #fff;
font-size: 11px; font-size: 11px;
line-height: 18px; font-weight: 600;
white-space: nowrap;
} }
.sku-media-item .ant-image { .sku-main-badge {
position: absolute; top: 8px;
inset: 0; }
display: flex; .sku-main-count {
bottom: 8px;
display: inline-flex;
align-items: center; 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%; 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; display: block;
object-fit: contain; margin-bottom: 4px;
object-position: center; 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; display: flex;
width: 100%;
height: 100%;
aspect-ratio: 3 / 4;
align-self: start;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
border-radius: 6px; gap: 12px;
color: #90889c; margin-bottom: 12px;
font-size: 13px;
} }
.sku-media-picker--empty { .sku-panel-heading h5 {
grid-column: span 2; margin: 0;
grid-row: span 2; 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 { .sku-info-panel {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 11px 12px;
} }
.sku-info-panel h5.ant-typography, .sku-info-panel > .sku-panel-heading,
.sku-info-panel > .ant-typography { .sku-info-group-title,
.sku-info-inherit-note,
.sku-info-panel > .dynamic-attribute-list {
grid-column: 1 / -1; 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 { .sku-info-panel .ant-form-item {
margin-bottom: 0; margin-bottom: 0;
min-width: 0;
} }
/* SKU 信息保持标签在上、控件在下,独立于页面其他横向表单布局。 */
.sku-info-panel .ant-form-item-row { .sku-info-panel .ant-form-item-row {
display: block; display: block;
} }
.sku-info-panel .ant-form-item-label { .sku-info-panel .ant-form-item-label {
display: block; display: block;
min-height: 26px; padding: 0 0 5px;
padding: 0 0 4px;
text-align: left; text-align: left;
} }
.sku-info-panel .ant-form-item-label > label { .sku-info-panel .ant-form-item-label > label,
align-items: center; .product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
justify-content: flex-start; justify-content: flex-start;
font-weight: 600; color: #77718a;
font-size: 12px;
font-weight: 500;
} }
.sku-info-panel .ant-form-item-control { .sku-info-panel .ant-input,
display: block; .sku-info-panel .ant-input-number,
min-width: 0; .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 { .sku-info-package-row {
display: grid; display: grid;
grid-column: 1 / -1; grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px; gap: 11px 12px;
} }
.sku-info-package-row .sku-info-package-dimensions { .sku-info-package-row > :only-child {
grid-column: span 2; grid-column: 1 / -1;
} }
.sku-info-variant-description { .sku-info-panel .sku-info-package-row .ant-form-item-label > label {
grid-column: span 2; 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 { .sku-info-panel .dynamic-attribute-list {
display: contents; display: contents;
} }
.sku-info-panel .dynamic-attribute-item { .sku-info-panel .dynamic-attribute-item {
margin-bottom: 0; min-width: 0;
} }
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label { .product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label {
display: block; display: block;
padding: 0 0 4px; padding: 0 0 5px;
text-align: left;
}
.product-editor .sku-info-panel .dynamic-attribute-item .ant-form-item-label > label {
align-items: center;
justify-content: flex-start;
text-align: left; text-align: left;
} }
.sku-inline-dimensions { .sku-inline-dimensions {
display: grid; display: grid;
grid-template-columns: grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr) 12px minmax(0, 1fr) 25px;
minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr) 16px minmax(81px, 1.5fr)
28px;
gap: 6px;
align-items: center; align-items: center;
gap: 4px;
} }
.sku-dimension-separator { .sku-inline-dimensions .ant-input,
color: #98a2b3; .sku-inline-dimensions .ant-input-number {
font-size: 16px; min-width: 0;
font-weight: 600;
text-align: center;
} }
.sku-dimension-separator,
.sku-dimension-unit { .sku-dimension-unit {
color: #77718a; color: #77718a;
font-size: 13px; text-align: center;
text-align: right; }
.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 { .add-sku-row {
display: flex; display: flex;
@@ -370,24 +570,59 @@
.add-sku-button { .add-sku-button {
min-width: 180px; min-width: 180px;
} }
@container (max-width: 620px) {
@media (max-width: 560px) { .sku-card.ant-card > .ant-card-body {
.sku-inline-dimensions { 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; grid-template-columns: 1fr;
} }
.sku-inline-dimensions > span { .sku-expand-button.ant-btn {
display: none; justify-self: start;
} }
.sku-info-panel { .sku-pricing-fields {
grid-template-columns: 1fr; 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 { .sku-info-package-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.sku-info-package-row .sku-info-package-dimensions { .sku-pricing-fields > div:last-child,
grid-column: span 1; .sku-info-variant-description,
.sku-info-variant-title,
.sku-info-variant-tags {
grid-column: auto;
} }
.sku-info-variant-description { .sku-panel-heading {
grid-column: span 1; flex-wrap: wrap;
} }
} }
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ import './SectionTitle.css';
type Props = { type Props = {
number: string; number: string;
title: string; title: ReactNode;
description?: string; description?: string;
extra?: ReactNode; 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; height: 24px;
min-width: 24px; min-width: 24px;
padding: 0; padding: 0;
border-radius: 5px; border-radius: 7px;
background: rgb(255 255 255 / 94%); background: rgb(255 255 255 / 94%);
color: #26232d; 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-empty strong { color: #494556; }
.sku-single-ai-sparkle { color: #7465e6; font-size: 36px; } .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 { 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 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-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; } .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 .ant-typography { font-size: 12px; }
.sku-single-ai-footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } .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 { 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 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; } .sku-single-ai-picker span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -37,7 +37,7 @@
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
border: 1px solid #e5e3eb; border: 1px solid #e5e3eb;
border-radius: 8px; border-radius: 7px;
background: #fff; background: #fff;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
@@ -129,9 +129,13 @@
} }
@media (max-width: 720px) { @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) { @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>
);
}
+2
View File
@@ -1,3 +1,4 @@
import SystemCommissionsPage from '@/pages/system/SystemCommissionsPage';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom'; import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom';
import MainLayout from '@/layouts/MainLayout'; import MainLayout from '@/layouts/MainLayout';
@@ -85,6 +86,7 @@ export const router = createBrowserRouter([
{ index: true, element: <Navigate to="/system/platform-catalog" replace /> }, { index: true, element: <Navigate to="/system/platform-catalog" replace /> },
{ path: 'platform-catalog', element: <SystemCatalogPage /> }, { path: 'platform-catalog', element: <SystemCatalogPage /> },
{ path: 'pricing-rates', element: <SystemPricingRatesPage /> }, { 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 },
}),
};
+24
View File
@@ -0,0 +1,24 @@
# 类目佣金导入 API
> 2026-09-29。产品方案见 `docs/product/category-commission-import-plan.md`。所有金额与费率为十进制字符串;导入仅支持 Ozon / RU / rFBS / RUB。
## 管理端
全部 `/system/pricing/commissions` 接口要求实时有效的系统管理员授权,普通用户返回 403 `SYSTEM_ADMIN_REQUIRED`。
- `GET /system/pricing/commissions?platform_id={uuid}`:返回导入版本列表(不含原始文件),最近版本在前。
- `GET /system/pricing/commissions/:id`:版本详情,含解析后的类目、档位、异常、变更状态。
- `POST /system/pricing/commissions/preview`:只读预览。请求 `{platform_id,version,source_reference,captured_on,document}`,`document` 为来源 JSON 对象。返回 `{preview_hash,categories,summary,previous_import_id}`。分类结果为 eligible/excluded,差异为 added/changed/unchanged;summary 包含总类目、规则数、可用/排除类目、映射商品类型、移除类目数。格式不正确返回 422 `COMMISSION_IMPORT_INVALID`;允许预览全部无法映射的结果;没有可映射类目时禁止启用空版本。
- `POST /system/pricing/commissions/imports`:201,要求 UUID `Idempotency-Key`。请求在预览输入基础上增加 `{preview_hash,confirm_scope:true}`。确认适用范围表示管理员确认数据适用于 Ozon RU/rFBS 的 RUB 售价;目录异常和移除数量只在预览中提示,不要求额外勾选确认。只支持立即启用,完整替换本入口的前一版本,不修改非本入口规则;发生其他来源有效映射冲突时拒绝启用。
预览指纹覆盖完整输入、解析结果、可映射的商品类型及当前版本。启用时锁平台并重新核对,变化返回 409 `COMMISSION_PREVIEW_STALE`。同一来源内容不重复导入,同平台版本名称唯一,冲突返回 409 `COMMISSION_IMPORT_EXISTS`。旧导入的规则/映射结束有效期,新规则使用新行;禁止改写历史费率,历史报价保持不变。
最多 100 个一级类目、2,000 个二级类目,JSON 总大小最多 2 MiB。首版只接收三种固定区间的完整组合,校验叶子序号及 label/value 百分比一致、所有来源 cate_id 唯一;0% 是有效佣金,可正常导入;未匹配、父子关系不符、无有效类型的类目在预览中提示,并因无法建立商品类型映射而暂不参与算价。缺少或重叠档位不能发布。
## 卖家查询
`GET /pricing/commission-rules?store_id={uuid}&platform_product_category_id={uuid}`,也可用 `description_category_id={外部类目ID}` 替代内部类目 ID,两者必须且只能指定一个。当前固定 RU/rFBS,无需前端重复传入履约枚举。
需要门店属于当前工作空间。返回 `{data:{category_name,currency_code,bands,version,source,effective_from}}`;每档含 `{id,min_price,max_price,min_inclusive,max_inclusive,commission_rate}`。内部类目必须属于门店平台且有效;外部 ID 必须能唯一解析到同平台已核对佣金类目。没有可用规则返回 422 `PRICING_COMMISSION_UNAVAILABLE`,不能将缺失规则推定为 0% 佣金。
报价与此查询共用正式佣金解析器。现有 `PricingOption` 增加可选 `commission_details`(类目、区间、来源版本),以兼容已持久化的历史报价。规则换版会使待确认预览失效,已确认报价不重新计算。
+7 -7
View File
@@ -10,8 +10,8 @@
需要已认证工作空间。返回 `{data: PricingCatalog}`: 需要已认证工作空间。返回 `{data: PricingCatalog}`:
- `store`:门店 ID、名称及报价币种(CNY / RUB)。 - `store`:门店 ID、名称及报价币种(CNY / RUB)。
- `rate_cards[]`:该门店平台、RU / rFBS 的有效费率卡及运输方式。正式环境必须 active、在有效期内、有审核时间及审核人。`development_only=true` 表示本地开发模式临时可选的 draft 卡。 - `rate_cards[]`:该门店平台、RU / rFBS 的可用费率卡及运输方式,包含已审核且在有效期内的 active 卡,以及配置允许时的 draft 卡。`development_only=true` 为兼容现有 DTO 保留,表示所选卡尚未审核,并不代表禁止首次上架。
- `pending_rate_cards[]`:该平台 RU / rFBS 待审核草稿的名称、版本和状态,仅用于说明阻断原因,不作为方案或报价可选项。 - `pending_rate_cards[]`:该平台 RU / rFBS 待审核草稿的名称、版本和状态;在允许草稿报价时也可能同时出现在 `rate_cards[]`,用于展示审核状态。
- `policies[]`:该门店活动方案,默认方案优先,包含完整参数和 revision。 - `policies[]`:该门店活动方案,默认方案优先,包含完整参数和 revision。
- `exchange_rate`:CBR 最新 CNY→RUB 官方参考汇率,包含 ID、日期和 stale;缺少数据时为 null。超过 72 小时提示过期参考,不自动代填其他来源。 - `exchange_rate`:CBR 最新 CNY→RUB 官方参考汇率,包含 ID、日期和 stale;缺少数据时为 null。超过 72 小时提示过期参考,不自动代填其他来源。
@@ -46,7 +46,7 @@
``` ```
- 独立试算 `binding=null`;SKU 必须提供 profile,已有 variant_id 与新建 client_key 二选一。验证资源归属,已上架 SKU 不接受新报价。 - 独立试算 `binding=null`;SKU 必须提供 profile,已有 variant_id 与新建 client_key 二选一。验证资源归属,已上架 SKU 不接受新报价。
- 包装输入是编辑草稿合并后的有效 g/mm 值,必须已确认;尺寸最多三位小数,重量至少 1 g。保存时从实际持久化草稿重新合并并核对,不能仅信任客户端摘要。 - 包装输入是编辑草稿合并后的有效 g/mm 值,必须已确认;接口为兼容历史值允许最多三位小数,重量至少 1 g,当前编辑页与试算界面只接受正整数。保存时从实际持久化草稿重新合并并核对,不能仅信任客户端摘要。
- `mode=target_markup`:目标利润=采购成本×加价率;求解销售价。`fixed_sale`:selling_price 必填,按该售价核算利润。 - `mode=target_markup`:目标利润=采购成本×加价率;求解销售价。`fixed_sale`:selling_price 必填,按该售价核算利润。
- 百分比费用以销售额为基数;除加价率外每项小于 1、最多六位小数。引擎拒绝总扣费率≥1。划线价=销售价÷(1−折扣率)。 - 百分比费用以销售额为基数;除加价率外每项小于 1、最多六位小数。引擎拒绝总扣费率≥1。划线价=销售价÷(1−折扣率)。
- 枚举运输方式×产品类型×佣金档,按最终币种舍入后回验货值、佣金、重量和体积重边界。默认运输不可用时 recommended_option_id=null,不替换为另一方式。 - 枚举运输方式×产品类型×佣金档,按最终币种舍入后回验货值、佣金、重量和体积重边界。默认运输不可用时 recommended_option_id=null,不替换为另一方式。
@@ -93,13 +93,13 @@ context 保存规则版本、实际汇率与来源,以及系统汇率记录 ID
| DELETE | `/pricing-policies/{id}` | Idempotency-Key;归档并清除默认标志,返回 `{id,archived:true}` | | DELETE | `/pricing-policies/{id}` | Idempotency-Key;归档并清除默认标志,返回 `{id,archived:true}` |
写入 `{name,note,parameters,allow_commission_override}`。只保存费用、加价、折扣、固定/系统汇率与运输偏好;采购价、包装、手动售价及临时佣金不属于方案持久字段。 写入 `{name,note,parameters,allow_commission_override}`。只保存费用、加价、折扣、固定/系统汇率与运输偏好;采购价、包装、手动售价及临时佣金不属于方案持久字段。
方案的默认和比较运输必须属于同一可用费率卡;正式环境仅允许已审核卡。历史快照不会被方案修改;尚未确认的预览因方案版本改变要求重算。 方案的默认和比较运输必须属于同一可用费率卡;正式环境默认允许有效草稿卡;可通过 `PRICING_ALLOW_DRAFT_RATES=false` 仅使用已审核卡。历史快照不会被方案修改;尚未确认的预览因方案版本改变要求重算。
## 5. 数据启用与错误 ## 5. 数据启用与错误
迁移中的 CEL 目录为 `draft`,覆盖六种产品类型、三种运输方式,香港空运不纳入。真实账单复核、有效日期/审核人以及平台类目佣金映射仍是正式使用前置条件,接口不会回退到示例费率或固定佣金。 迁移中的 CEL 目录为 `draft`,覆盖六种产品类型、三种运输方式,香港空运不纳入。平台类目佣金映射仍是正式试算所需数据;草稿物流费率可按上述配置参与报价,页面保留费率来源标识,接口不会回退到未选定的固定佣金。物流费率审核用于形成有证据的已审核版本,不再是草稿报价上架预检的前置条件。
商品编辑联调期间,API 在 `NODE_ENV=development` 时默认允许 draft 费率卡参与方案保存、预览与草稿报价确认;可设 `PRICING_ALLOW_DRAFT_RATES=false` 关闭。测试环境只有显式设 `PRICING_ALLOW_DRAFT_RATES=true` 才开启;生产环境始终关闭。该模式不修改费率卡状态、不生成审核记录。目录的 `rate_cards[].development_only` 和预览的 `context.development_only` 标识试算性质;基于此类预览确认的 SKU 报价可在编辑页查看,但首次上架校验必须返回 `PRICING_RATE_REVIEW_REQUIRED`,直至使用已审核费率重新试算确认。佣金和汇率仍按现有输入规则处理。 API 在开发与生产环境默认允许 draft 费率卡参与方案保存、预览和 SKU 报价确认;`PRICING_ALLOW_DRAFT_RATES=false` 可显式关闭。测试环境默认关闭,仅显式设为 `true` 才开启。使用草稿卡不会修改费率卡状态或生成审核记录;目录的 `rate_cards[].development_only` 和预览的 `context.development_only` 保留来源标识,供页面和报价快照展示。已确认的草稿卡报价满足定价确认要求,首次上架预检不再因卡片未审核返回 `PRICING_RATE_REVIEW_REQUIRED`;其他报价输入、利润和发布字段校验照常执行。佣金和汇率仍按现有输入规则处理。此变更由维护者于 2026-09-29 要求,影响 Web、API、现有响应说明和发布预检,不改变审核端点。
### 系统管理员审核 CEL 费率 ### 系统管理员审核 CEL 费率
@@ -121,7 +121,7 @@ context 保存规则版本、实际汇率与来源,以及系统汇率记录 ID
|---|---| |---|---|
| 422 VALIDATION_ERROR | 修正输入格式或缺项 | | 422 VALIDATION_ERROR | 修正输入格式或缺项 |
| 422 PRICING_RULES_UNAVAILABLE | 配置并审核物流费率 | | 422 PRICING_RULES_UNAVAILABLE | 配置并审核物流费率 |
| 422 PRICING_COMMISSION_UNAVAILABLE | 配置正式映射,或使用允许临时佣金的方案 | | 422 PRICING_COMMISSION_UNAVAILABLE | 配置正式映射;缺少正式规则时,可在允许临时佣金的方案或本次自定义参数中填写已核实的临时佣金率 |
| 422 PRICING_FX_UNAVAILABLE | 等待 CBR 同步,或明确采用临时/方案固定汇率 | | 422 PRICING_FX_UNAVAILABLE | 等待 CBR 同步,或明确采用临时/方案固定汇率 |
| 409 PRICING_PREVIEW_STALE | 重算、比较并重新应用,或显式仅保存草稿 | | 409 PRICING_PREVIEW_STALE | 重算、比较并重新应用,或显式仅保存草稿 |
| 409 VERSION_CONFLICT | 重新读取版本后修改 | | 409 VERSION_CONFLICT | 重新读取版本后修改 |
+11 -4
View File
@@ -13,6 +13,7 @@
Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空。服务端验证采集记录归属、平台可用,并取得或创建该商品在当前平台的 `product_platform_profile`: Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空。服务端验证采集记录归属、平台可用,并取得或创建该商品在当前平台的 `product_platform_profile`:
- 首次创建时使用该平台默认店铺作为 `target_store_id`,并为所有未删除 SKU 建立本平台 `product_variant_platform_listing`; - 首次创建时使用该平台默认店铺作为 `target_store_id`,并为所有未删除 SKU 建立本平台 `product_variant_platform_listing`;
- 首期新建 Ozon profile 的履约方式为 `rfbs`,与当前定价费率卡及报价接口一致;其他平台保持 `unknown`,由后续平台适配确定;
- 当前平台没有默认店铺时返回 `422 DEFAULT_STORE_REQUIRED`,只给出店铺管理引导,不创建缺少目标门店的 profile; - 当前平台没有默认店铺时返回 `422 DEFAULT_STORE_REQUIRED`,只给出店铺管理引导,不创建缺少目标门店的 profile;
- 已存在 profile 时只读取,绝不因再次进入而覆盖用户已选门店、类目、属性或价格; - 已存在 profile 时只读取,绝不因再次进入而覆盖用户已选门店、类目、属性或价格;
- 不创建跨平台 profile,也不改变其他平台的上架状态。 - 不创建跨平台 profile,也不改变其他平台的上架状态。
@@ -38,7 +39,8 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
"id": "uuid", "revision": 12, "id": "uuid", "revision": 12,
"common": { "source_title": "来源标题", "brand": null, "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null }, "common": { "source_title": "来源标题", "brand": null, "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null },
"default_physical_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "net_weight_g": null, "status": "missing" }, "default_physical_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "net_weight_g": null, "status": "missing" },
"default_package_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "shipping_weight_g": null, "status": "missing" } "default_package_spec": { "length_mm": null, "width_mm": null, "height_mm": null, "shipping_weight_g": null, "status": "missing" },
"package_weight_mode": "common", "package_dimensions_mode": "common"
}, },
"profile": { "profile": {
"id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs", "id": "uuid", "revision": 8, "platform_id": "uuid", "market_code": "RU", "fulfillment": "rfbs",
@@ -78,6 +80,10 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
`default_package_spec` 是 SPU 的存储事实,`variants[].package_spec` 仅保存该 SKU 的覆盖值;`effective_package_spec` 按“SKU 覆盖值优先、SPU 默认值回退”解析。Ozon 发布适配器在创建 job 时才将每个目标 SKU 的有效值物化为独立 `items[]`:`length_mm → depth`、`width_mm → width`、`height_mm → height`、`shipping_weight_g → weight`,并固定带上 `dimension_unit: "mm"` 与 `weight_unit: "g"`。不得为了 payload 预先将 SPU 默认值复制到所有 SKU 草稿。商品净重/尺寸不是这组固定物流字段;它们的 SPU/SKU 展示归属由当前 Ozon schema 的 `is_aspect` 决定。 `default_package_spec` 是 SPU 的存储事实,`variants[].package_spec` 仅保存该 SKU 的覆盖值;`effective_package_spec` 按“SKU 覆盖值优先、SPU 默认值回退”解析。Ozon 发布适配器在创建 job 时才将每个目标 SKU 的有效值物化为独立 `items[]`:`length_mm → depth`、`width_mm → width`、`height_mm → height`、`shipping_weight_g → weight`,并固定带上 `dimension_unit: "mm"` 与 `weight_unit: "g"`。不得为了 payload 预先将 SPU 默认值复制到所有 SKU 草稿。商品净重/尺寸不是这组固定物流字段;它们的 SPU/SKU 展示归属由当前 Ozon schema 的 `is_aspect` 决定。
编辑快照的 `product.package_weight_mode` 和 `product.package_dimensions_mode` 分别保存包装重量、包装尺寸的编辑模式(`common` / `variant`);保存草稿的 `common` 中可传同名字段,旧客户端省略时保留当前模式,新商品默认 `common`。模式独立于数值保存,使逐 SKU 表单尚未填写时重新打开仍保持原模式。切到 `variant` 时客户端清空并禁用对应 SPU 输入;切回 `common` 时经确认清空所有 SKU 对应覆盖值并恢复 SPU 输入。两项切换互不影响,模式不改变有效包装规格的“SKU 覆盖优先、SPU 回退”解析规则。
商品编辑页的包装重量和长宽高仅允许手工输入正整数(g / mm),定价详情弹窗采用相同输入规则。已有小数值保留原样供人工核对,不自动取整;API 暂保留三位小数的历史数据兼容能力。
`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。 `source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。
`captured_category_match` 仅对 Ozon 来源返回自动类目判定结果。服务端只使用公开详情页的**一级**面包屑和结构化 `Type_0` 商品类型,在当前平台目录的活动可发布叶子中精确匹配;公开页后二、三级导航不参与判定。`matched` 时服务端已自动写入 `source_capture`;`ambiguous` 时前端展示 `candidates` 的完整 Atlas 路径,用户确认后按普通手选类目保存为 `manual`;`unmatched`、`unavailable` 均不得自动选择。 `captured_category_match` 仅对 Ozon 来源返回自动类目判定结果。服务端只使用公开详情页的**一级**面包屑和结构化 `Type_0` 商品类型,在当前平台目录的活动可发布叶子中精确匹配;公开页后二、三级导航不参与判定。`matched` 时服务端已自动写入 `source_capture`;`ambiguous` 时前端展示 `candidates` 的完整 Atlas 路径,用户确认后按普通手选类目保存为 `manual`;`unmatched`、`unavailable` 均不得自动选择。
@@ -112,7 +118,8 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
"ai_source_overridden_fields": ["title", "description"], "ai_source_overridden_fields": ["title", "description"],
"common": { "source_title": "商品名称", "brand": "品牌", "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null, "common": { "source_title": "商品名称", "brand": "品牌", "model": null, "barcode": null, "purchase_url": "https://…", "supply_note": null,
"default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" }, "default_physical_spec": { "length_mm": "100", "width_mm": "50", "height_mm": "20", "net_weight_g": "300", "status": "confirmed" },
"default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" } }, "default_package_spec": { "length_mm": "120", "width_mm": "60", "height_mm": "30", "shipping_weight_g": "400", "status": "confirmed" },
"package_weight_mode": "common", "package_dimensions_mode": "common" },
"profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"], "profile": { "target_store_id": "uuid", "fulfillment": "rfbs", "title": "Название", "description": "…", "tags": ["tag"],
"platform_product_category_id": "uuid", "category_source": "manual" }, "platform_product_category_id": "uuid", "category_source": "manual" },
"variants": [{ "variants": [{
@@ -142,7 +149,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。 - 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;当前商品成功的水印与 3:4 处理结果也可选择。采集图片仍须先转存,且来源变体须匹配。 - `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;当前商品成功的水印与 3:4 处理结果也可选择。采集图片仍须先转存,且来源变体须匹配。
- `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。 - `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。
- `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。 - `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”。编辑页保存草稿时允许中文商品名、俄文发布标题、型号、品牌、产品类目及类目必填属性留空,必填标记只提示首次上架所需资料;首次上架预检统一检查缺失项,`profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。草稿仍须携带有效的 `target_store_id` 作为店铺上下文,并遵守已填写字段的格式和长度约束。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。
成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。 成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。
@@ -254,7 +261,7 @@ Body:`{ "variant_ids": ["uuid", "uuid"] }`,最多 100 个且不能重复。
没有报价时仍可保存商品,但首次发布预检返回 `PRICING_REQUIRED`。已确认报价不会随预览到期而失效;采购、包装、目标门店/类目、履约或门店币种改变后须重新确认。调整默认方案、汇率或费率不会反向改写历史快照。详见[定价 API](./pricing.md)。 没有报价时仍可保存商品,但首次发布预检返回 `PRICING_REQUIRED`。已确认报价不会随预览到期而失效;采购、包装、目标门店/类目、履约或门店币种改变后须重新确认。调整默认方案、汇率或费率不会反向改写历史快照。详见[定价 API](./pricing.md)。
开发环境可按定价 API 的草稿费率模式完成商品编辑联调;这种报价即使已保存,也会在首次发布预检返回 `PRICING_RATE_REVIEW_REQUIRED`。费率完成正式审核后,须用已审核卡重新试算并确认 SKU。 经维护者 2026-09-29 确认,使用草稿物流费率卡完成的报价可以保留到首次发布预检;未审核状态只作为来源信息展示,不再生成 `PRICING_RATE_REVIEW_REQUIRED`。当前仍要求 SKU 已确认有效报价、售价、包装和其他发布必填数据。生产环境默认可使用草稿卡,管理员可通过 `PRICING_ALLOW_DRAFT_RATES=false` 限制其进入新试算;审核流程仍可用于核实费率。
## 5. 素材选择与水印派生 ## 5. 素材选择与水印派生
@@ -0,0 +1,72 @@
# 类目佣金 JSON 导入与定价接入方案
> 2026-09-29:维护者已批准实施。目标是通过「系统管理 → 类目佣金」导入、核对并启用分档规则,让商品编辑页和即时试算自动读取佣金。
## 1. 范围与责任
本任务由当前会话负责跨领域集成,涉及 `apps/web`、`apps/api`、`packages/contracts`、`packages/database` 和文档。原因是导入页面、管理员鉴权、数据库原子启用和报价规则读取必须形成一个可验收能力。新增接口采用独立契约,不破坏现有报价请求。
本次只支持 Ozon、RU、rFBS、RUB 阶梯佣金。数据是平台共享参考数据,沿用平台目录的系统管理员权限;普通用户仅通过已授权门店查询有效规则。导入来源为 `competitor_capture`,保留来源信息与版本。
## 2. 已核对的数据
用户附件包含 26 个一级类目、542 个二级类目、1,626 条规则。每个二级类目都是三档:`0 < 售价 ≤ 1500₽`、`1500₽ < 售价 ≤ 5000₽`、`售价 > 5000₽`。84 个二级类目的三档费率相同。标签百分比与 `value` 一致。
2026-09-29 只读核对本地 Ozon 平台目录:542 个二级 `cate_id` 全部匹配,一级父类目 ID 全部一致,覆盖 7,365 个有效商品类型。目录最近同步于 2026-09-28。该结果证明本次可建立映射,不代表任意未来附件的 ID 都正确。
来源 `id` 是树节点编号,不能用作平台 ID。`cate_id` 保存为来源键,并与 Ozon `external_category_id` 核对;同名类目不能合并。第三层是价格档,不是商品类型。
`200001728`(管材、桩材、型材)和 `17028768`(Ozon Fresh 新鲜及冷冻肉类、家禽和鱼类)的三档都是 0%。维护者明确指出 0% 可能是平台的真实优惠费率;导入按有效佣金处理并保留原值,不由系统推断为占位值。
## 3. 页面与流程
系统管理增加「类目佣金」列表,展示平台规则版本、来源、启用时间及覆盖数量,选中版本查看各类目的三档费率。
导入弹窗:
1. 上传 JSON(也可粘贴文本),填写来源说明、采集日期、版本名称。
2. 服务端校验格式、ID 唯一性、档位、百分比一致性;与当前平台目录匹配,生成预览。
3. 预览列出新增、变更、未变类目和目录异常(未匹配、父子关系不符、无有效商品类型);显示成功类目及商品类型覆盖数量。异常类目不进入可启用集合。
4. 管理员确认来源适用于 RU/rFBS;预览明确展示无法映射数与上一版本被移除的覆盖数,但不要求额外勾选排除确认。
5. 点击「确认并启用」,后端重算预览指纹,若目录、当前版本或输入变化则返回冲突并要求重新预览。成功后立即生效。
首版只支持立即启用,不做定时发布、逐条在线编辑、后台抓取或自动猜测缺失费率。0% 佣金直接按来源导入。无法映射的类目保留在导入版本及预览中,但无法建立定价映射;只要存在可映射类目,就可启用其余规则。
## 4. 存储与原子启用
复用 `commission_categories`、`commission_category_rules`、`platform_product_category_commission_mappings`;增加导入版本表保存原始 JSON、内容指纹、来源、采集日期、操作者、统计和启用时间。规则及映射关联导入版本。
费率按比例字符串存储,例如 12% 为 `0.120000`,20.50% 为 `0.205000`。金额边界为精确十进制,第三档上界为 null。一级类目仅用于分组,规则挂在二级佣金类目下;商品类型叶子通过显式映射共享规则。
启用在单个数据库事务内完成:检查系统管理员权限、锁定平台、检查预览指纹、保存版本、写入类目与规则、建立有效商品类型映射、结束被替代规则/映射的有效期。失败整体回滚。所有关键写操作要求 Idempotency-Key;重复提交同一批次不产生重复版本。
同平台、市场、履约模式只保留一个当前导入版本。使用新的规则行保留历史引用,禁止修改已启用的费率、区间和来源。报价保存时仍重新读取规则并检查指纹,旧预览在换版后不能确认;已确认报价保留历史快照。
## 5. 规则查询与算价
增加卖家查询接口:按 `store_id` 和 Atlas 商品类目 UUID,或者按已核对的 Ozon `description_category_id` 查询,二者只能指定一个;返回匹配类目、币种、全部价格档、来源、版本、生效日期。门店必须属于当前工作空间。
查询接口与报价共用正式规则解析逻辑;报价仍由后端选择规则,前端不能用传入佣金替换有效正式规则。现有定价引擎已经逐档反算售价、舍入并检查区间,继续复用。规则的区间币种为 RUB,CNY 门店按当前预览锁定的汇率换算判断。
现有试算结果增加佣金类目、档位、百分比、来源版本展示;有正式规则时无需填写临时佣金。未覆盖类目继续给出缺失规则提示。前端展示缺失状态不能把它误写成 0%。
## 6. 验收
- 原始附件可预览 542 类、1,626 档,包括两个全档 0% 类目,均可映射。
- 错误 JSON、重复 ID、缺失档位、标签和值不一致的费率被拒绝。
- 同名不同 ID 保持独立;父类目不符和未匹配项不会自动发布。
- 普通用户不能预览或启用管理员导入;跨租户门店查询失败。
- 启用重放、并发换版、陈旧预览、完整回滚及历史规则保护有数据库/API 测试。
- `1500`、`1500.01`、`5000`、`5000.01` 正确命中档位,CNY/RUB 试算和目标利润反算使用正式规则。
- 新版启用后旧试算预览失效,已有确认报价保持原快照。
- 受影响工作区通过类型检查、构建和相关测试;浏览器验证上传、预览、异常提示与启用条件。
## 7. 实施顺序
先补充 API 契约与共享 DTO,再实现导入解析器、数据库迁移和原子启用,随后实现管理页面与菜单、查询及试算展示,最后完成测试与交接记录。开发验证使用测试数据,不自动把用户附件启用为正式业务费率;正式启用需由管理员在页面确认其来源与适用范围。
## 8. 实施结果(2026-09-29)
已实现以上主流程,迁移为 `052_category_commission_imports.sql`。最初附件预览为 540 类 / 7,358 类型;维护者纠正 0% 处理后,新版浏览器实测 542 类 / 7,365 类型均可映射。修正后 38 项相关测试、API/Web 类型检查与构建、contracts/database 构建通过;浏览器曾核对上传、预览、异常筛选、确认条件和预览失效;0% 处理修改后已重新验证全部 542 类均可用,且不需要额外排除确认。数据库启用/历史查询/失败回滚使用事务测试验证,未启用用户附件,未做多连接并发压力测试。
首版映射为发布时快照,不自动补齐后续同步新增类型;同内容重复导入被拒绝,映射单独刷新能力留待后续。正式使用前需管理员在页面确认数据适用范围。
+18 -1
View File
@@ -1,6 +1,6 @@
# Atlas Seller Kit 交接说明 # Atlas Seller Kit 交接说明
> 更新:2026-09-28。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.47 节为准。 > 更新:2026-09-29。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节、2026-09-29 类目佣金记录及最新的 1.47 节为准。
## 1. 项目定位 ## 1. 项目定位
@@ -20,6 +20,23 @@ SKU 定价已打通试算、表单应用与原子保存确认,在线商品同
接手前请先阅读 `git status --short`,确认当前工作区状态;本轮图片选择的 Web、API、contracts、迁移、测试和文档随本报告一起提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。 接手前请先阅读 `git status --short`,确认当前工作区状态;本轮图片选择的 Web、API、contracts、迁移、测试和文档随本报告一起提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。
### 2026-09-29:类目佣金 JSON 导入与定价接入
- 已先编写[实施方案](../product/category-commission-import-plan.md),并完成[API 契约](../api/category-commissions.md)、迁移 `052`、导入解析器、原子启用、管理页面及试算展示。影响 Web、API、contracts、database;未涉及 Worker 和扩展。
- 入口为「系统管理 → 类目佣金」(`/system/commissions`):上传 JSON/TXT 或粘贴 JSON,填写版本、来源、采集日期,预览差异与异常,确认适用范围后启用。支持搜索、只看异常、查看历史版本;百分比显示两位小数。
- 当前限定 Ozon RU / rFBS / RUB 三档佣金;通过已同步平台目录核对二级 `cate_id` 和父类目,映射到 Atlas 商品类型 UUID。查询接口支持内部 UUID 或外部 `description_category_id`,正式报价按最终售价及汇率命中分档,结果展示佣金类目、档位和版本。
- 原始附件共 542 类 / 1,626 档。最初预览错误地排除了两个全档 0% 类目。维护者确认 0% 可能是有效优惠佣金后,已修正为可导入;新版浏览器实测 542 类全部可用,覆盖 7,365 个商品类型。**仅预览,尚未把用户附件启用为正式费率**;管理员需核对 RU/rFBS 适用范围并在页面确认。
- 迁移 `052` 已在本地数据库应用,再次运行迁移无变化。修正后 38 项相关测试通过:解析、数据库真实角色权限、启用/幂等/旧预览、目录异常提示、授权撤销、跨工作空间、后半程写入失败回滚、历史不可变,以及 1500/1500.01/5000/5000.01 ₽ 边界与 CNY 换算。API/Web 类型检查及构建、contracts/database 构建通过;Web 仍有大 bundle 提示。
- 浏览器验证了实际附件上传、预览、异常筛选、确认项使按钮可用、修改来源后预览失效;未在浏览器提交真实费率。启用及历史查询由事务回滚集成测试验证;未做多连接并发压力测试。没有手动启动/停止维护者的 API/Web/Worker,使用现有 8100/8200 服务;浏览器验证窗口已关闭。
- 首版每次启用完整替换上一导入版本,不支持增量或定时发布;同 JSON 内容和同平台版本名称禁止重复导入。映射是启用时目录的快照,后续新增商品类型的映射刷新仍需完善,不能承诺目录同步自动扩展佣金覆盖。正式使用仍须核对附件适用范围及其他物流/结算费用。
### 2026-09-29:草稿物流费率用于报价与发布预检
- 维护者取消“草稿物流费率报价不能首次上架”的业务限制。开发和生产环境默认允许草稿卡参与方案、试算与 SKU 确认,`PRICING_ALLOW_DRAFT_RATES=false` 可限制新报价;测试环境仍默认关闭。既有 `development_only` 字段继续标记费率尚未审核。
- 首次发布预检不再因草稿卡报价产生 `PRICING_RATE_REVIEW_REQUIRED`;已确认报价、售价、包装、素材与平台属性等校验保持有效。定价工具移除误导性告警,SKU 显示定价自身状态,费率选择仍标明“待审核费率”。
- 系统管理「物流费率审核」仍可用真实账单样本复核 Ozon CEL 费率;草稿卡不会因可试算而自动变为 active。商品编辑页的“选择 SKU 并首次上架”按钮仍为占位,真正 Ozon 首次发布 job 尚未接入。
- 验证:草稿费率配置、定价 HTTP/PostgreSQL 和商品编辑 HTTP/PostgreSQL 共 26 项通过;contracts/API/Web 类型检查和构建通过,`git diff --check` 通过。隔离浏览器打开现有门店的定价方案页,确认旧“当前使用开发草稿费率”提示已消失。未重启维护者服务,未进行真实上架。
### 2026-09-28:SKU 定价试算与确认 ### 2026-09-28:SKU 定价试算与确认
- SKU 采购价失焦/回车自动计算、手动算价、批量计算和保留手动售价;明细弹窗比较运输方案、调整参数并应用当前 SKU。 - SKU 采购价失焦/回车自动计算、手动算价、批量计算和保留手动售价;明细弹窗比较运输方案、调整参数并应用当前 SKU。
@@ -0,0 +1,81 @@
import { z } from 'zod';
export const commissionImportRequestSchema = z
.object({
platform_id: z.uuid(),
version: z.string().trim().min(1).max(100),
source_reference: z.string().trim().min(3).max(500),
captured_on: z.iso.date(),
document: z.unknown().refine((v) => v !== undefined, '请上传 JSON'),
})
.strict();
export const commissionImportPublishSchema =
commissionImportRequestSchema.extend({
preview_hash: z.string().regex(/^[a-f0-9]{64}$/),
confirm_scope: z.literal(true),
});
export type CommissionImportRequest = z.infer<
typeof commissionImportRequestSchema
>;
export type CommissionImportPublish = z.infer<
typeof commissionImportPublishSchema
>;
export type CommissionBand = {
min_price: string | null;
max_price: string | null;
min_inclusive: boolean;
max_inclusive: boolean;
commission_rate: string;
};
export type CommissionImportCategory = {
source_category_id: string;
parent_source_id: string;
name: string;
parent_name: string;
bands: CommissionBand[];
status: 'eligible' | 'excluded';
issues: string[];
change: 'added' | 'changed' | 'unchanged';
mapped_types: number;
};
export type CommissionImportSummary = {
categories: number;
rules: number;
eligible: number;
excluded: number;
mapped_types: number;
removed: number;
};
export type CommissionImportPreview = {
preview_hash: string;
previous_import_id: string | null;
categories: CommissionImportCategory[];
summary: CommissionImportSummary;
};
export type CommissionImportVersion = {
id: string;
platform_id: string;
version: string;
source_reference: string;
captured_on: string;
activated_at: string;
is_current: boolean;
summary: CommissionImportSummary;
};
export type CommissionImportDetail = CommissionImportVersion & {
categories: CommissionImportCategory[];
};
export type CommissionRulesResult = {
category_name: string;
currency_code: string;
version: string;
source: string;
effective_from: string;
bands: Array<CommissionBand & { id: string }>;
};
export type CommissionDetails = CommissionBand & {
category_name: string;
currency_code: string;
version: string;
source: string;
};
+1
View File
@@ -9,3 +9,4 @@ export * from './online-products';
export * from './pricing'; export * from './pricing';
export * from './pricing-admin'; export * from './pricing-admin';
export * from './category-commissions';
+2
View File
@@ -1,3 +1,4 @@
import type { CommissionDetails } from './category-commissions';
import { z } from 'zod'; import { z } from 'zod';
const decimal = z.string().regex(/^\d{1,9}(\.\d{1,8})?$/); const decimal = z.string().regex(/^\d{1,9}(\.\d{1,8})?$/);
@@ -132,6 +133,7 @@ export type PricingPreviewRequest = z.infer<typeof pricingPreviewRequestSchema>;
export type PricingSelection = z.infer<typeof pricingSelectionSchema>; export type PricingSelection = z.infer<typeof pricingSelectionSchema>;
export type PricingPolicyRequest = z.infer<typeof pricingPolicyRequestSchema>; export type PricingPolicyRequest = z.infer<typeof pricingPolicyRequestSchema>;
export type PricingOption = { export type PricingOption = {
commission_details?: CommissionDetails;
id: string; id: string;
transport_method_id: string; transport_method_id: string;
transport_name: string; transport_name: string;
+4
View File
@@ -202,6 +202,8 @@ export type ProductEditorSnapshot = {
}; };
default_physical_spec: PhysicalSpec; default_physical_spec: PhysicalSpec;
default_package_spec: PackageSpec; default_package_spec: PackageSpec;
package_weight_mode: 'common' | 'variant';
package_dimensions_mode: 'common' | 'variant';
}; };
profile: { profile: {
id: string; id: string;
@@ -507,6 +509,8 @@ export const saveProductEditorDraftRequestSchema = z
supply_note: nullableText(4_000), supply_note: nullableText(4_000),
default_physical_spec: physicalSpec, default_physical_spec: physicalSpec,
default_package_spec: packageSpec, default_package_spec: packageSpec,
package_weight_mode: z.enum(['common', 'variant']).optional(),
package_dimensions_mode: z.enum(['common', 'variant']).optional(),
}) })
.strict(), .strict(),
profile: z profile: z
@@ -0,0 +1,59 @@
SET search_path TO atlas, public;
-- 平台共享参考规则;所有写入口均要求系统管理员,批次保存原始来源以供追溯。
CREATE TABLE commission_imports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
platform_id UUID NOT NULL REFERENCES platforms(id) ON DELETE RESTRICT,
version TEXT NOT NULL CHECK(length(version) BETWEEN 1 AND 100),
source_reference TEXT NOT NULL,
captured_on DATE NOT NULL,
document JSONB NOT NULL,
content_hash CHAR(64) NOT NULL,
categories JSONB NOT NULL,
summary JSONB NOT NULL,
activated_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
activated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_current BOOLEAN NOT NULL DEFAULT true,
UNIQUE(platform_id,version),
UNIQUE(platform_id,content_hash),
UNIQUE(platform_id,id)
);
CREATE UNIQUE INDEX commission_import_current ON commission_imports(platform_id) WHERE is_current;
ALTER TABLE commission_category_rules ADD COLUMN import_id UUID REFERENCES commission_imports(id) ON DELETE RESTRICT;
ALTER TABLE platform_product_category_commission_mappings ADD COLUMN import_id UUID;
ALTER TABLE platform_product_category_commission_mappings ADD CONSTRAINT commission_mapping_import_platform_fk
FOREIGN KEY(platform_id,import_id) REFERENCES commission_imports(platform_id,id) ON DELETE RESTRICT;
CREATE UNIQUE INDEX commission_import_category_key ON commission_categories(platform_id,source_category_key)
WHERE source_category_key LIKE 'maozierp:%';
CREATE INDEX commission_rules_import ON commission_category_rules(import_id);
CREATE INDEX commission_mapping_import ON platform_product_category_commission_mappings(import_id);
-- 已发布版本及数值不可改写;换版仅允许结束有效期,仍保留历史报价引用。
CREATE FUNCTION guard_commission_import_history() RETURNS trigger LANGUAGE plpgsql SET search_path=atlas,public AS $$
BEGIN
IF TG_TABLE_NAME='commission_imports' THEN
IF TG_OP='DELETE' OR (to_jsonb(NEW)-'is_current') IS DISTINCT FROM (to_jsonb(OLD)-'is_current') OR NOT OLD.is_current OR NEW.is_current THEN
RAISE EXCEPTION '佣金版本不可改写' USING ERRCODE='23514';
END IF;
ELSIF OLD.import_id IS NOT NULL THEN
IF TG_OP='DELETE' OR (to_jsonb(NEW)-'effective_to'-'status') IS DISTINCT FROM (to_jsonb(OLD)-'effective_to'-'status') OR OLD.effective_to IS NOT NULL OR NEW.effective_to IS NULL OR NEW.status<>'archived' THEN
RAISE EXCEPTION '已导入的佣金规则不可改写,请导入新版本' USING ERRCODE='23514';
END IF;
END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER commission_import_history BEFORE UPDATE OR DELETE ON commission_imports FOR EACH ROW EXECUTE FUNCTION guard_commission_import_history();
CREATE TRIGGER commission_rule_history BEFORE UPDATE OR DELETE ON commission_category_rules FOR EACH ROW EXECUTE FUNCTION guard_commission_import_history();
CREATE TRIGGER commission_mapping_history BEFORE UPDATE OR DELETE ON platform_product_category_commission_mappings FOR EACH ROW EXECUTE FUNCTION guard_commission_import_history();
-- 规则与导入版本必须同平台;应用代码不能通过关联写入跨平台规则。
CREATE FUNCTION check_commission_rule_import_platform() RETURNS trigger LANGUAGE plpgsql SET search_path=atlas,public AS $$
BEGIN
IF NEW.import_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM commission_imports i JOIN commission_categories c ON c.platform_id=i.platform_id
WHERE i.id=NEW.import_id AND c.id=NEW.commission_category_id
) THEN RAISE EXCEPTION '佣金规则与导入平台不一致' USING ERRCODE='23514'; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER commission_rule_import_platform BEFORE INSERT OR UPDATE ON commission_category_rules FOR EACH ROW EXECUTE FUNCTION check_commission_rule_import_platform();
@@ -0,0 +1,30 @@
SET search_path TO atlas, public;
-- 包装重量与尺寸可分别由商品通用值或逐 SKU 值填写;空草稿也需要保存模式。
ALTER TABLE products
ADD COLUMN package_weight_mode TEXT NOT NULL DEFAULT 'common'
CHECK (package_weight_mode IN ('common', 'variant')),
ADD COLUMN package_dimensions_mode TEXT NOT NULL DEFAULT 'common'
CHECK (package_dimensions_mode IN ('common', 'variant'));
-- 旧编辑器在每个 SKU 都有对应覆盖值时即展示逐 SKU 表单,迁移时保留该显示模式。
UPDATE products p
SET package_weight_mode = 'variant'
WHERE EXISTS (
SELECT 1 FROM product_variants v WHERE v.product_id = p.id AND v.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM product_variants v
WHERE v.product_id = p.id AND v.deleted_at IS NULL AND v.shipping_weight_g IS NULL
);
UPDATE products p
SET package_dimensions_mode = 'variant'
WHERE EXISTS (
SELECT 1 FROM product_variants v WHERE v.product_id = p.id AND v.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM product_variants v
WHERE v.product_id = p.id AND v.deleted_at IS NULL
AND (v.package_length_mm IS NULL OR v.package_width_mm IS NULL OR v.package_height_mm IS NULL)
);