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'],
setting: Environment['PRICING_ALLOW_DRAFT_RATES'],
): boolean {
return (
nodeEnvironment !== 'production' &&
(setting === 'true' ||
(nodeEnvironment === 'development' && setting === 'auto'))
);
return setting === 'true' || (setting === 'auto' && nodeEnvironment !== 'test');
}
export type AppConfig = Omit<
Environment,
@@ -152,7 +148,7 @@ function createAppConfig(environment: Environment): AppConfig {
objectStorageSecretKey,
objectStorageForcePathStyle:
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
// 草稿费率仅用于本地商品编辑联调,生产配置无法开启。
// 草稿费率默认可参与报价;仍可通过环境配置限制新试算。
pricingAllowDraftRates: draftPricingEnabled(
environment.NODE_ENV,
environment.PRICING_ALLOW_DRAFT_RATES,
+5
View File
@@ -58,6 +58,11 @@ export async function createApplication() {
});
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
app.use(helmet({ contentSecurityPolicy: false }));
// 批量佣金文件可达 2 MiB,专用入口预留元数据空间,其他 API 保留原限制。
app.useBodyParser('json', {
limit: 3 * 1024 * 1024,
type: (req) => (req as Request).path.startsWith('/api/system/pricing/commissions') && Boolean((req as Request).is('application/json')),
});
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
app.useBodyParser('urlencoded', {
limit: config.API_BODY_LIMIT_BYTES,
@@ -0,0 +1,106 @@
import { z } from 'zod';
import type { CommissionImportCategory } from '@atlas/contracts';
import { ApiError } from '../common/api-error.js';
import { D } from './pricing-engine.js';
const sourceId = z
.union([
z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
z.string().regex(/^[1-9]\d{0,17}$/),
])
.transform(String);
const leaf = z
.object({
id: z.union([z.string(), z.number()]),
label: z.string().max(200),
value: z.string().max(50),
})
.strict();
const category = z
.object({
id: z.union([z.string(), z.number()]),
label: z.string().trim().min(1).max(200),
value: z.string().max(200),
cate_id: sourceId,
children: z.array(leaf).length(3),
})
.strict();
const root = category
.omit({ children: true })
.extend({ children: z.array(category).min(1).max(2000) });
const documentSchema = z
.object({
code: z.literal(1),
msg: z.string(),
data: z.array(root).min(1).max(100),
})
.strict();
const fail = (message: string): never => {
throw new ApiError(422, 'COMMISSION_IMPORT_INVALID', message);
};
/** 仅在导入时解析来源文字;报价读取结构化区间,不解析展示标签。 */
export function parseCommissionDocument(
document: unknown,
): CommissionImportCategory[] {
if (Buffer.byteLength(JSON.stringify(document) ?? '') > 2 * 1024 * 1024)
fail('JSON 不能超过 2 MiB');
const parsed = documentSchema.safeParse(document);
if (!parsed.success)
return fail(
'JSON 格式不正确:应包含一级类目、二级类目和每类完整的三个价格档位',
);
const ids = new Set<string>();
const result: CommissionImportCategory[] = [];
for (const group of parsed.data.data) {
if (ids.has(group.cate_id)) fail(`类目 ID 重复:${group.cate_id}`);
ids.add(group.cate_id);
for (const item of group.children) {
if (ids.has(item.cate_id)) fail(`类目 ID 重复:${item.cate_id}`);
ids.add(item.cate_id);
const bands = item.children.map((tier, index) => {
const value = /^([123]),(\d{1,2}(?:\.\d{1,2})?)$/.exec(
tier.value,
);
const prefix = [
'售价 ≤ 1500₽',
'1500₽ < 售价 ≤ 5000₽',
'售价 > 5000₽',
][index];
const label = /^(.*?)\s*\((\d{1,2}(?:\.\d{1,2})?)%\)$/.exec(
tier.label,
);
if (
!value ||
!label ||
value[1] !== String(index + 1) ||
label[1].trim() !== prefix ||
!D(value[2]).eq(label[2])
)
return fail(
`${item.label}:档位或标签百分比与 value 不一致`,
);
return {
min_price: ['0', '1500', '5000'][index],
max_price: ['1500', '5000', null][index],
min_inclusive: false,
max_inclusive: index < 2,
commission_rate: D(value[2]).div(100).toFixed(6),
};
});
result.push({
source_category_id: item.cate_id,
parent_source_id: group.cate_id,
name: item.label,
parent_name: group.label,
bands,
status: 'eligible',
issues: [],
change: 'added',
mapped_types: 0,
});
}
}
if (result.length > 2000) fail('二级类目不能超过 2,000 个');
return result;
}
@@ -0,0 +1,84 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
commissionImportRequestSchema,
commissionImportPublishSchema,
type Workspace,
} from '@atlas/contracts';
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
import { ApiError } from '../common/api-error.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
import { CommissionImportService } from './commission-import.service.js';
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const r = schema.safeParse(value);
if (!r.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查平台、版本名称、来源、采集日期及确认项',
r.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
);
return r.data;
}
@ApiTags('System commissions')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/pricing/commissions')
export class CommissionImportController {
constructor(
@Inject(CommissionImportService)
private readonly service: CommissionImportService,
) {}
@Get() async list(@Query('platform_id') id: string) {
return { data: await this.service.list(parse(z.uuid(), id)) };
}
@Get(':id') async detail(@Param('id') id: string) {
return { data: await this.service.detail(parse(z.uuid(), id)) };
}
@Post('preview') async preview(@Body() body: unknown) {
return {
data: await this.service.preview(
parse(commissionImportRequestSchema, body),
),
};
}
@Post('imports') async publish(
@Body() body: unknown,
@Req() req: AuthenticatedRequest,
@CurrentWorkspace() w: Workspace,
@Headers('idempotency-key') key: string | undefined,
@Res({ passthrough: true }) res: { status: (n: number) => unknown },
) {
if (!key)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
const r = await this.service.publish(
w.id,
req.auth.userId,
parse(z.uuid(), key),
parse(commissionImportPublishSchema, body),
);
res.status(r.status);
return r.body;
}
}
@@ -0,0 +1,347 @@
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
CommissionImportRequest,
CommissionImportPublish,
CommissionImportCategory,
CommissionImportPreview,
CommissionImportVersion,
CommissionImportDetail,
} from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { ApiError } from '../common/api-error.js';
import { fingerprint } from './pricing-engine.js';
import { parseCommissionDocument } from './commission-import-parser.js';
type Tx = DatabaseTransaction;
type CatalogRow = {
id: string;
external_category_id: string;
external_type_id: string;
parent_external_id: string | null;
is_active: boolean;
};
const columns =
'id,platform_id,version,source_reference,captured_on::text,activated_at,is_current,summary';
const inputFields = (v: CommissionImportRequest): CommissionImportRequest => ({
platform_id: v.platform_id,
version: v.version,
source_reference: v.source_reference,
captured_on: v.captured_on,
document: v.document,
});
@Injectable()
export class CommissionImportService {
constructor(
@Inject(DATABASE) private readonly db: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
) {}
async list(platformId: string): Promise<CommissionImportVersion[]> {
return (
await this.db.query<CommissionImportVersion>(
`SELECT ${columns} FROM atlas.commission_imports WHERE platform_id=$1 ORDER BY activated_at DESC,id`,
[platformId],
)
).rows;
}
async detail(id: string): Promise<CommissionImportDetail> {
const row = (
await this.db.query<CommissionImportDetail>(
`SELECT ${columns},categories FROM atlas.commission_imports WHERE id=$1`,
[id],
)
).rows[0];
if (!row)
throw new ApiError(
404,
'COMMISSION_IMPORT_NOT_FOUND',
'佣金版本不存在',
);
return row;
}
async preview(input: CommissionImportRequest) {
return this.db.transaction(
async (tx) => (await this.inspect(tx, input)).preview,
);
}
private async inspect(
tx: Tx,
input: CommissionImportRequest,
lock = false,
) {
const platform = (
await tx.query<{ code: string }>(
`SELECT code FROM atlas.platforms WHERE id=$1 ${lock ? 'FOR UPDATE' : ''}`,
[input.platform_id],
)
).rows[0];
if (platform?.code.toUpperCase() !== 'OZON')
throw new ApiError(
422,
'COMMISSION_PLATFORM_UNSUPPORTED',
'当前仅支持 Ozon 类目佣金',
);
if (input.captured_on > new Date().toISOString().slice(0, 10))
throw new ApiError(
422,
'COMMISSION_IMPORT_INVALID',
'采集日期不能晚于今天',
);
const categories = parseCommissionDocument(input.document);
const rows = (
await tx.query<CatalogRow>(
`SELECT c.id,c.external_category_id,c.external_type_id,c.is_active,p.external_category_id AS parent_external_id
FROM atlas.platform_product_categories c LEFT JOIN atlas.platform_product_categories p ON p.id=c.parent_id
WHERE c.platform_id=$1 ORDER BY c.id ${lock ? 'FOR SHARE OF c' : ''}`,
[input.platform_id],
)
).rows;
const previous = (
await tx.query<{
id: string;
categories: CommissionImportCategory[];
}>(
`SELECT id,categories FROM atlas.commission_imports WHERE platform_id=$1 AND is_current`,
[input.platform_id],
)
).rows[0];
const old = new Map(
(previous?.categories ?? [])
.filter((c) => c.status === 'eligible')
.map((c) => [c.source_category_id, c]),
);
const mappings = new Map<string, string[]>();
for (const c of categories) {
const matching = rows.filter(
(r) => r.external_category_id === c.source_category_id,
);
const parent = matching.filter(
(r) => !r.external_type_id || r.external_type_id === '0',
);
const types = matching.filter(
(r) =>
r.is_active &&
r.external_type_id &&
r.external_type_id !== '0',
);
if (parent.length !== 1)
c.issues.push('平台目录中没有唯一对应的类目');
else if (parent[0].parent_external_id !== c.parent_source_id)
c.issues.push('来源一级父类目与平台目录不一致');
if (!types.length) c.issues.push('没有可映射的有效商品类型');
c.status = c.issues.length ? 'excluded' : 'eligible';
c.mapped_types = c.status === 'eligible' ? types.length : 0;
const before = old.get(c.source_category_id);
c.change = !before
? 'added'
: fingerprint(before.bands) === fingerprint(c.bands)
? 'unchanged'
: 'changed';
if (c.status === 'eligible')
mappings.set(
c.source_category_id,
types.map((t) => t.id),
);
}
const eligible = categories.filter((c) => c.status === 'eligible');
const summary = {
categories: categories.length,
rules: categories.reduce((n, c) => n + c.bands.length, 0),
eligible: eligible.length,
excluded: categories.length - eligible.length,
mapped_types: eligible.reduce((n, c) => n + c.mapped_types, 0),
removed: [...old.keys()].filter((id) => !mappings.has(id)).length,
};
const preview: CommissionImportPreview = {
categories,
summary,
previous_import_id: previous?.id ?? null,
preview_hash: fingerprint({
input: inputFields(input),
categories,
mappings: [...mappings],
previous: previous?.id ?? null,
}),
};
return { preview, mappings };
}
async publish(
workspaceId: string,
userId: string,
key: string,
input: CommissionImportPublish,
) {
return this.idempotency.run(
{
workspaceId,
userId,
key,
method: 'POST',
path: '/api/system/pricing/commissions/imports',
request: input,
},
async (tx) => {
// 发布时重新查询有效管理员授权,避免仅信任旧 JWT。
const grant = await tx.query(
`SELECT g.user_id FROM atlas.system_admin_grants g JOIN atlas.users u ON u.id=g.user_id WHERE g.user_id=$1 AND g.revoked_at IS NULL AND u.status='active' FOR SHARE OF u`,
[userId],
);
if (!grant.rowCount)
throw new ApiError(
403,
'SYSTEM_ADMIN_REQUIRED',
'需要系统管理员权限',
);
const { preview, mappings } = await this.inspect(
tx,
input,
true,
);
if (preview.preview_hash !== input.preview_hash)
throw new ApiError(
409,
'COMMISSION_PREVIEW_STALE',
'目录、当前版本或输入已变化,请重新预览',
);
if (!preview.summary.eligible)
throw new ApiError(
422,
'COMMISSION_IMPORT_INVALID',
'没有可启用的类目,请核对异常项',
);
const contentHash = fingerprint(input.document);
if (
(
await tx.query(
`SELECT id FROM atlas.commission_imports WHERE platform_id=$1 AND (content_hash=$2 OR version=$3)`,
[input.platform_id, contentHash, input.version],
)
).rowCount
)
throw new ApiError(
409,
'COMMISSION_IMPORT_EXISTS',
'此版本名称或 JSON 内容已导入',
);
const typeIds = [...mappings.values()].flat();
if (
(
await tx.query(
`SELECT id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND (effective_to IS NULL OR effective_to>now()) AND import_id IS NULL FOR SHARE`,
[input.platform_id, typeIds],
)
).rowCount
)
throw new ApiError(
409,
'COMMISSION_MAPPING_CONFLICT',
'部分类型已有其他来源的有效佣金映射,请先核对',
);
if (preview.previous_import_id) {
await tx.query(
`UPDATE atlas.commission_category_rules SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
[preview.previous_import_id],
);
await tx.query(
`UPDATE atlas.platform_product_category_commission_mappings SET status='archived',effective_to=now() WHERE import_id=$1 AND effective_to IS NULL`,
[preview.previous_import_id],
);
await tx.query(
`UPDATE atlas.commission_imports SET is_current=false WHERE id=$1`,
[preview.previous_import_id],
);
}
const id = randomUUID();
const saved = (
await tx.query<CommissionImportVersion>(
`INSERT INTO atlas.commission_imports(id,platform_id,version,source_reference,captured_on,document,content_hash,categories,summary,activated_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING ${columns}`,
[
id,
input.platform_id,
input.version,
input.source_reference,
input.captured_on,
JSON.stringify(input.document),
contentHash,
JSON.stringify(preview.categories),
JSON.stringify(preview.summary),
userId,
],
)
).rows[0];
const roots = new Map<string, string>();
for (const c of preview.categories.filter(
(c) => c.status === 'eligible',
)) {
let rootId = roots.get(c.parent_source_id);
if (!rootId) {
rootId = await this.category(
tx,
input.platform_id,
c.parent_source_id,
c.parent_name,
`毛子ERP / ${c.parent_name} (${c.parent_source_id})`,
null,
);
roots.set(c.parent_source_id, rootId);
}
const categoryId = await this.category(
tx,
input.platform_id,
c.source_category_id,
c.name,
`毛子ERP / ${c.parent_name} / ${c.name} (${c.source_category_id})`,
rootId,
);
await tx.query(
`INSERT INTO atlas.commission_category_rules(commission_category_id,fulfillment,market_code,currency_code,min_price,max_price,min_inclusive,max_inclusive,commission_rate,source,version,status,effective_from,import_id)
SELECT $1,'rfbs','RU','RUB',min_price::numeric,max_price::numeric,min_inclusive,max_inclusive,commission_rate::numeric,'competitor_capture',$2,'active',now(),$3 FROM jsonb_to_recordset($4::jsonb) AS b(min_price text,max_price text,min_inclusive boolean,max_inclusive boolean,commission_rate text)`,
[
categoryId,
input.version,
id,
JSON.stringify(c.bands),
],
);
await tx.query(
`INSERT INTO atlas.platform_product_category_commission_mappings(platform_id,platform_product_category_id,commission_category_id,fulfillment,market_code,priority,source,status,effective_from,import_id)
SELECT $1,unnest($2::uuid[]),$3,'rfbs','RU',100,'competitor_capture','active',now(),$4`,
[
input.platform_id,
mappings.get(c.source_category_id),
categoryId,
id,
],
);
}
return { status: 201, body: { data: saved } };
},
);
}
private async category(
tx: Tx,
platform: string,
key: string,
name: string,
path: string,
parent: string | null,
): Promise<string> {
const existing = (
await tx.query<{ id: string }>(
`SELECT id FROM atlas.commission_categories WHERE platform_id=$1 AND source_category_key=$2`,
[platform, `maozierp:${key}`],
)
).rows[0];
if (existing) return existing.id;
return (
await tx.query<{ id: string }>(
`INSERT INTO atlas.commission_categories(platform_id,parent_id,source_category_key,name,path,source,status) VALUES($1,$2,$3,$4,$5,'competitor_capture','active') RETURNING id`,
[platform, parent, `maozierp:${key}`, name, path],
)
).rows[0].id;
}
}
+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;
};
export type CommissionRule = {
category_name?: string;
source?: string;
effective_from?: string | Date;
id: string | null;
currency_code: string;
min_price: string | null;
@@ -254,6 +257,10 @@ export function calculatePricing(
logistics_rate_rule_id: r.id,
commission_rule_id: c.id,
commission_rate: c.commission_rate,
...(c.id ? {commission_details: {
category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule',
min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate,
}} : {}),
selling_price: rounded.toFixed(2),
list_price: list.toFixed(2),
currency_code: currency,
@@ -54,6 +54,15 @@ export class PricingController {
constructor(
@Inject(PricingService) private readonly service: PricingService,
) {}
@Get('pricing/commission-rules') async commissionRules(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('platform_product_category_id') category: string | undefined,
@Query('description_category_id') external: string | undefined,
) {
if (Boolean(category) === Boolean(external)) throw new ApiError(422,'VALIDATION_ERROR','请指定一种类目 ID');
return {data:await this.service.commissionRules(w.id,parse(z.uuid(),store),category ? parse(z.uuid(),category) : undefined,external ? parse(z.string().regex(/^[1-9]\d{0,17}$/),external) : undefined)};
}
@Get('pricing/categories') async categories(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
+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 { IdempotencyService } from '../common/idempotency.service.js';
import { PricingController } from './pricing.controller.js';
@@ -6,9 +8,10 @@ import { PricingAdminController } from './pricing-admin.controller.js';
import { PricingAdminService } from './pricing-admin.service.js';
import { SystemAdminGuard } from '../platform-catalog-admin/system-admin.guard.js';
@Module({
controllers: [PricingController, PricingAdminController],
controllers: [PricingController, PricingAdminController, CommissionImportController],
providers: [
PricingService,
CommissionImportService,
PricingAdminService,
SystemAdminGuard,
IdempotencyService,
+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 { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database';
@@ -121,6 +123,24 @@ export class PricingService {
},
}));
}
async commissionRules(workspaceId: string, storeId: string, categoryId?: string, externalId?: string): Promise<CommissionRulesResult> {
return this.db.workspaceTransaction(workspaceId, async tx => {
const store = await this.store(tx, storeId);
const categories = (await tx.query<{id:string}>(`SELECT id FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND ($2::uuid IS NULL OR id=$2) AND ($3::text IS NULL OR external_category_id=$3) ORDER BY id`, [store.platform_id, categoryId ?? null, externalId ?? null])).rows;
if (!categories.length) throw new ApiError(422, 'PRICING_CATEGORY_REQUIRED', '请选择当前平台的有效类目');
let resolvedCategoryId = categories[0].id;
// 外部类目下可以有多个类型,但不能将相互冲突的映射折叠成一个费率。
if (externalId) {
const mapped = await tx.query<{id:string; platform_product_category_id:string}>(`SELECT DISTINCT commission_category_id AS id,platform_product_category_id FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=ANY($2::uuid[]) AND fulfillment='rfbs' AND market_code='RU' AND status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`,[store.platform_id,categories.map(c=>c.id)]);
if (new Set(mapped.rows.map(row => row.id)).size>1) throw new ApiError(422,'COMMISSION_MAPPING_CONFLICT','此类目下的商品类型使用不同规则,请指定具体商品类型');
resolvedCategoryId = mapped.rows[0]?.platform_product_category_id ?? resolvedCategoryId;
}
const {commissions} = await readCommissionRules(tx,store.platform_id,resolvedCategoryId,'rfbs');
if (!commissions.length) throw new ApiError(422,'PRICING_COMMISSION_UNAVAILABLE','该类目没有有效佣金规则');
const first=commissions[0];
return {category_name:first.category_name ?? '',currency_code:first.currency_code,version:first.version,source:first.source ?? 'manual_rule',effective_from:new Date(first.effective_from!).toISOString(),bands:commissions.map(c=>({id:c.id!,min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,commission_rate:c.commission_rate}))};
});
}
async categories(workspaceId: string, storeId: string, q: string) {
return this.db.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.store(tx, storeId);
@@ -174,7 +194,7 @@ export class PricingService {
};
});
}
/** 开发模式可复算草稿;保存确认时复读规则,正式模式仍只接受已审核版本。 */
/** 保存确认时复读规则;草稿卡是否参与新报价由配置控制。 */
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
const card = (
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
@@ -190,7 +210,7 @@ export class PricingService {
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'没有已审核且有效的物流费率,请先配置正式费率',
'没有可用的物流费率,请检查费率卡状态和草稿卡使用配置',
);
const category = await tx.query(
`SELECT id FROM atlas.platform_product_categories WHERE id=$1 AND platform_id=$2 AND is_active`,
@@ -227,24 +247,9 @@ export class PricingService {
[card.id, input.pricing.transport_method_ids, card.status],
)
).rows;
const mapping = (
await tx.query<{ commission_category_id: string }>(
`SELECT * FROM atlas.platform_product_category_commission_mappings WHERE platform_id=$1 AND platform_product_category_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${active} ORDER BY priority,effective_from DESC,id LIMIT 1 FOR SHARE`,
[
store.platform_id,
input.platform_product_category_id,
input.fulfillment,
],
)
).rows[0];
let commissions: CommissionRule[] = mapping
? (
await tx.query<CommissionRule>(
`SELECT * FROM atlas.commission_category_rules WHERE commission_category_id=$1 AND fulfillment=$2 AND market_code='RU' AND currency_code IN ('CNY','RUB') AND ${active} ORDER BY id FOR SHARE`,
[mapping.commission_category_id, input.fulfillment],
)
).rows
: [];
const official = await readCommissionRules(tx, store.platform_id, input.platform_product_category_id, input.fulfillment);
const mapping = official.mapping;
let commissions = official.commissions;
if (
!commissions.length &&
input.pricing.temporary_commission_rate !== null &&
@@ -266,7 +271,7 @@ export class PricingService {
throw new ApiError(
422,
'PRICING_COMMISSION_UNAVAILABLE',
'该类目尚无有效佣金规则;允许临时佣金的方案可在详情中补充',
'该类目尚无有效佣金规则;请补充已核实的临时佣金率,并使用允许临时佣金的方案或本次自定义参数',
);
if (
commissions.some((c) => c.id) &&
@@ -17,6 +17,8 @@ export type ProductRow = {
default_package_width_mm: string | null;
default_package_height_mm: string | null;
default_shipping_weight_g: string | null;
package_weight_mode: 'common' | 'variant';
package_dimensions_mode: 'common' | 'variant';
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
ai_source_material_overrides: unknown;
@@ -140,9 +140,10 @@ export class ProductEditorService {
);
const created = await transaction.query<{ id: string }>(
`INSERT INTO atlas.product_platform_profiles
(workspace_id, product_id, platform_id, target_store_id, market_code)
(workspace_id, product_id, platform_id, target_store_id, market_code, fulfillment)
SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3,
nullif(p.capabilities->>'default_market_code', '')
nullif(p.capabilities->>'default_market_code', ''),
CASE WHEN upper(p.code) = 'OZON' THEN 'rfbs' ELSE 'unknown' END
FROM atlas.platforms p WHERE p.id = $2
ON CONFLICT (workspace_id, product_id, platform_id) DO NOTHING
RETURNING id`,
@@ -434,6 +435,8 @@ export class ProductEditorService {
},
default_physical_spec: defaultPhysicalSpec,
default_package_spec: defaultPackageSpec,
package_weight_mode: product.package_weight_mode,
package_dimensions_mode: product.package_dimensions_mode,
},
profile: {
id: profile.id,
@@ -822,6 +825,7 @@ export class ProductEditorService {
default_package_length_mm = $11, default_package_width_mm = $12,
default_package_height_mm = $13, default_shipping_weight_g = $14,
physical_spec_status = $15, package_spec_status = $16,
package_weight_mode = $21, package_dimensions_mode = $22,
ai_source_material_overrides = $17::jsonb,
ai_source_material_base_revision = $18,
revision = revision + 1, updated_at = now()
@@ -847,6 +851,8 @@ export class ProductEditorService {
input.ai_source_material.source_snapshot_revision,
productId,
expected.product,
input.common.package_weight_mode ?? product.package_weight_mode,
input.common.package_dimensions_mode ?? product.package_dimensions_mode,
],
);
if (!updatedProduct.rows[0])
@@ -2301,10 +2307,11 @@ export class ProductEditorService {
check(
variant.package_spec.status,
[
variant.package_spec.length_mm,
variant.package_spec.width_mm,
variant.package_spec.height_mm,
variant.package_spec.shipping_weight_g,
variant.package_spec.length_mm ?? input.common.default_package_spec.length_mm,
variant.package_spec.width_mm ?? input.common.default_package_spec.width_mm,
variant.package_spec.height_mm ?? input.common.default_package_spec.height_mm,
variant.package_spec.shipping_weight_g ??
input.common.default_package_spec.shipping_weight_g,
],
`variants.${index}.package_spec`,
);
@@ -2370,6 +2377,7 @@ export class ProductEditorService {
p.default_package_length_mm::text, p.default_package_width_mm::text,
p.default_package_height_mm::text, p.default_shipping_weight_g::text,
p.physical_spec_status, p.package_spec_status,
p.package_weight_mode, p.package_dimensions_mode,
p.ai_source_material_overrides, p.ai_source_material_base_revision,
c.normalized_payload, c.source_snapshot_revision
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
@@ -2395,6 +2403,7 @@ export class ProductEditorService {
p.default_package_length_mm::text, p.default_package_width_mm::text,
p.default_package_height_mm::text, p.default_shipping_weight_g::text,
p.physical_spec_status, p.package_spec_status,
p.package_weight_mode, p.package_dimensions_mode,
p.ai_source_material_overrides, p.ai_source_material_base_revision,
c.normalized_payload, c.source_snapshot_revision
FROM atlas.products p JOIN atlas.collection_items c ON c.id = p.collection_item_id
@@ -30,7 +30,6 @@ type VariantValidationRow = {
offer_id: string | null;
selling_price: string | null;
pricing_valid: boolean;
pricing_development_only: boolean;
package_length_mm: string | null;
package_width_mm: string | null;
package_height_mm: string | null;
@@ -291,12 +290,6 @@ export class ProductEditorValidationOperations {
field: `variants.${variant.id}.pricing_selection`,
message: '请按当前采购价和包装确认报价',
});
if (variant.pricing_development_only)
issues.push({
code: 'PRICING_RATE_REVIEW_REQUIRED',
field: `variants.${variant.id}.pricing_selection`,
message: '当前报价使用开发草稿费率;请在费率正式审核后重新试算确认',
});
if (!variant.selling_price)
issues.push({
code: 'PRICE_REQUIRED',
@@ -363,7 +356,6 @@ export class ProductEditorValidationOperations {
const variants = await transaction.query<VariantValidationRow>(
`SELECT v.id, v.display_name, listing.status AS listing_status,
listing.offer_id, listing.selling_price::text, listing.pricing_valid,
bool_or(coalesce(quote.input_snapshot->'context'->>'development_only','false')='true') AS pricing_development_only,
coalesce(v.package_length_mm, product.default_package_length_mm)::text AS package_length_mm,
coalesce(v.package_width_mm, product.default_package_width_mm)::text AS package_width_mm,
coalesce(v.package_height_mm, product.default_package_height_mm)::text AS package_height_mm,
@@ -374,7 +366,6 @@ export class ProductEditorValidationOperations {
JOIN atlas.products product ON product.id = v.product_id
JOIN atlas.product_variant_platform_listings listing
ON listing.product_variant_id = v.id AND listing.profile_id = $1
LEFT JOIN atlas.pricing_quotes quote ON quote.id = listing.pricing_quote_id
LEFT JOIN atlas.product_variant_listing_media media ON media.listing_id = listing.id
WHERE v.product_id = $2 AND v.deleted_at IS NULL
AND ($3::uuid[] IS NULL OR v.id = ANY($3::uuid[]))
+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 { draftPricingEnabled } from '../src/config/app-config.module.js';
describe('草稿费率开发模式边界', () => {
describe('草稿费率配置', () => {
it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => {
assert.equal(draftPricingEnabled('development', 'auto'), true);
assert.equal(draftPricingEnabled('development', 'false'), false);
@@ -10,8 +10,9 @@ describe('草稿费率开发模式边界', () => {
assert.equal(draftPricingEnabled('test', 'true'), true);
});
it('生产环境即使误设开关也不能使用草稿费率', () => {
assert.equal(draftPricingEnabled('production', 'true'), false);
assert.equal(draftPricingEnabled('production', 'auto'), false);
it('生产环境默认允许草稿卡,可显式关闭', () => {
assert.equal(draftPricingEnabled('production', 'true'), true);
assert.equal(draftPricingEnabled('production', 'auto'), true);
assert.equal(draftPricingEnabled('production', 'false'), false);
});
});
+82
View File
@@ -99,6 +99,17 @@ describe('十进制定价引擎', () => {
expect(o.can_confirm).toBe(false);
expect(Number(o.profit_cny)).toBeLessThan(0);
});
it('零佣金仍生成正式报价,平台佣金费用为零', () => {
const o = calculatePricing(
input(),
'RUB',
'12.3',
[rule],
[{ ...commission, commission_rate: '0.000000' }],
).options[0];
expect(o.commission_rate).toBe('0.000000');
expect(o.fees.commission_cny).toBe('0.0000');
});
it('实际重、尺寸和开区间边界精确生效', () => {
const i = input();
i.package.shipping_weight_g = '500';
@@ -241,3 +252,74 @@ describe('十进制定价引擎', () => {
expect(option.can_confirm).toBe(false);
});
});
describe('导入佣金的卢布分档边界', () => {
const bands: CommissionRule[] = [
{
...commission,
min_price: '0',
max_price: '1500',
min_inclusive: false,
commission_rate: '0.12',
},
{
...commission,
min_price: '1500',
max_price: '5000',
min_inclusive: false,
commission_rate: '0.14',
},
{
...commission,
min_price: '5000',
max_price: null,
min_inclusive: false,
commission_rate: '0.205',
},
];
it.each([
['1500', '0.12'],
['1500.01', '0.14'],
['5000', '0.14'],
['5000.01', '0.205'],
])('售价 %s 只命中对应佣金档', (price, rate) => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = price;
const result = calculatePricing(
i,
'RUB',
'12.3',
[{ ...rule, max_merchandise_value: null }],
bands,
);
expect(result.options).toHaveLength(1);
expect(Number(result.options[0].commission_rate)).toBe(Number(rate));
});
it('人民币售价换算卢布后匹配费率', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '150';
const result = calculatePricing(
i,
'CNY',
'10',
[{ ...rule, max_merchandise_value: null }],
bands,
);
expect(result.options).toHaveLength(1);
expect(Number(result.options[0].commission_rate)).toBe(0.12);
i.pricing.selling_price = '150.01';
expect(
Number(
calculatePricing(
i,
'CNY',
'10',
[{ ...rule, max_merchandise_value: null }],
bands,
).options[0].commission_rate,
),
).toBe(0.14);
});
});
+11 -7
View File
@@ -577,11 +577,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
);
const input = quote();
input.pricing.temporary_commission_rate = null;
assert.equal(
(await request('/pricing/quote-previews', 'POST', input)).error
.code,
'PRICING_COMMISSION_UNAVAILABLE',
const withoutCommission = await request(
'/pricing/quote-previews',
'POST',
input,
);
assert.equal(withoutCommission.error.code, 'PRICING_COMMISSION_UNAVAILABLE');
assert.match(withoutCommission.error.message, /本次自定义参数/);
input.pricing.temporary_commission_rate = '0.12';
input.pricing.mode = 'fixed_sale';
input.pricing.selling_price = '10';
@@ -598,7 +600,7 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
};
assert.equal((await save(state, payload)).status, 409);
});
it('开发草稿可试算并确认到商品编辑页,但不能通过首次上架校验', async () => {
it('草稿费率可试算、确认且不阻断首次上架预检', async () => {
await db.query(
`UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`,
[card],
@@ -637,11 +639,13 @@ describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(validation.status, 200, JSON.stringify(validation));
assert.ok(
assert.equal(
validation.data.variants[0].issues.some(
(issue: { code: string }) =>
issue.code === 'PRICING_RATE_REVIEW_REQUIRED',
issue.code === 'PRICING_RATE_REVIEW_REQUIRED' ||
issue.code === 'PRICING_REQUIRED',
),
false,
);
const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>(
'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1',
+126
View File
@@ -276,6 +276,7 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
assert.equal(editor.response.status, 200);
assert.equal(editor.body.data.product.id, first.productId);
assert.equal(editor.body.data.profile.id, first.profileId);
assert.equal(editor.body.data.profile.fulfillment, 'rfbs');
assert.equal(editor.body.data.variants.length, 1);
assert.equal(editor.body.data.variants[0].listing.status, 'not_listed');
});
@@ -2189,4 +2190,129 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
),
);
});
it('包装重量模式独立保存,并允许 SKU 已确认重量继承通用尺寸', async () => {
await database.workspaceTransaction(workspaceId, async (tx) => {
const store = await tx.query<{ id: string }>(
`SELECT id FROM atlas.stores
WHERE workspace_id = current_setting('app.workspace_id')::uuid
AND platform_id = $1 AND is_default = true LIMIT 1`,
[platformId],
);
if (!store.rows.length)
await tx.query(
`INSERT INTO atlas.stores
(workspace_id, platform_id, name, platform_client_id,
currency_code, is_default, status, credential_status)
VALUES (current_setting('app.workspace_id')::uuid, $1,
'包装规格模式测试店铺', $2, 'RUB', true, 'active', 'valid')`,
[platformId, `editor-package-${randomUUID()}`],
);
});
const created = await createCollectionItem('包装规格模式测试商品');
const entered = await request(
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
{
method: 'POST',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
}),
},
);
assert.equal(entered.response.status, 201);
const before = await request(`/api/products/${created.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(before.response.status, 200);
const snapshot = before.body.data;
const variant = snapshot.variants[0];
const payload = {
ai_source_material: snapshot.source_material,
ai_source_overridden_fields: snapshot.source_material_state.overridden_fields,
common: {
...snapshot.product.common,
default_physical_spec: snapshot.product.default_physical_spec,
default_package_spec: {
length_mm: '120',
width_mm: '60',
height_mm: '30',
shipping_weight_g: null,
status: 'missing',
},
package_weight_mode: 'variant',
package_dimensions_mode: 'common',
},
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: 'rfbs',
title: snapshot.profile.title,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
platform_product_category_id: snapshot.profile.category.id,
category_source: snapshot.profile.category.source,
},
variants: [
{
id: variant.id,
client_key: null,
display_name: variant.display_name,
source_sku: variant.source_sku,
specification_values: variant.specification_values,
purchase_cost_cny: variant.purchase_cost_cny,
title: variant.title,
tags: variant.tags,
description: variant.description,
physical_spec: variant.physical_spec,
package_spec: {
length_mm: null,
width_mm: null,
height_mm: null,
shipping_weight_g: '400',
status: 'confirmed',
},
offer_id: variant.listing.offer_id,
media_asset_ids: variant.media.map((item: { asset_id: string }) => item.asset_id),
},
],
};
const saved = await request(`/api/products/${created.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${snapshot.product.revision}:${snapshot.profile.revision}"`,
}),
body: JSON.stringify(payload),
});
assert.equal(saved.response.status, 200, JSON.stringify(saved.body));
const after = await request(`/api/products/${created.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(after.body.data.product.package_weight_mode, 'variant');
assert.equal(after.body.data.product.package_dimensions_mode, 'common');
assert.equal(after.body.data.product.default_package_spec.shipping_weight_g, null);
assert.equal(after.body.data.variants[0].package_spec.shipping_weight_g, '400.000');
assert.equal(after.body.data.variants[0].effective_package_spec.length_mm, '120.000');
assert.equal(after.body.data.variants[0].effective_package_spec.status, 'confirmed');
const {
package_weight_mode: _weightMode,
package_dimensions_mode: _dimensionsMode,
...legacyCommon
} = payload.common;
const legacySave = await request(`/api/products/${created.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${after.body.data.product.revision}:${after.body.data.profile.revision}"`,
}),
body: JSON.stringify({ ...payload, common: legacyCommon }),
});
assert.equal(legacySave.response.status, 200, JSON.stringify(legacySave.body));
const unchangedMode = await request(`/api/products/${created.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(unchangedMode.body.data.product.package_weight_mode, 'variant');
});
});