feat(pricing): 开发定价方案

This commit is contained in:
R524809
2026-09-28 17:34:33 +08:00
parent a29dd92676
commit 84b230e69a
54 changed files with 6264 additions and 236 deletions
+3
View File
@@ -20,6 +20,7 @@
"@nestjs/core": "12.0.1",
"@nestjs/platform-express": "12.0.1",
"@nestjs/swagger": "12.0.1",
"decimal.js": "10.6.0",
"helmet": "8.3.0",
"ioredis": "6.0.0",
"reflect-metadata": "0.2.2",
@@ -30,6 +31,8 @@
"@nestjs/testing": "12.0.1",
"@types/express": "5.0.6",
"@types/node": "26.3.0",
"@types/pg": "8.23.1",
"pg": "8.23.0",
"tsx": "4.23.13",
"typescript": "7.0.2",
"vitest": "^4.1.11"
+2
View File
@@ -1,3 +1,4 @@
import { PricingModule } from './pricing/pricing.module.js';
import { Controller, Get, Inject, Module } from '@nestjs/common';
import type { Database } from '@atlas/database';
import { AuthModule } from './auth/auth.module.js';
@@ -55,6 +56,7 @@ class HealthController {
ProductEditorModule,
PlatformCatalogAdminModule,
OnlineProductsModule,
PricingModule,
],
controllers: [HealthController],
})
+18 -1
View File
@@ -24,6 +24,7 @@ const environmentSchema = z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
PRICING_ALLOW_DRAFT_RATES: z.enum(['true', 'false', 'auto']).default('auto'),
API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200),
API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'),
WEB_ORIGIN: z.url().default('http://localhost:8100'),
@@ -96,15 +97,26 @@ const environmentSchema = z.object({
});
type Environment = z.infer<typeof environmentSchema>;
export function draftPricingEnabled(
nodeEnvironment: Environment['NODE_ENV'],
setting: Environment['PRICING_ALLOW_DRAFT_RATES'],
): boolean {
return (
nodeEnvironment !== 'production' &&
(setting === 'true' ||
(nodeEnvironment === 'development' && setting === 'auto'))
);
}
export type AppConfig = Omit<
Environment,
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED' | 'PRICING_ALLOW_DRAFT_RATES'
> & {
trustedProxyCidrs: string[];
swaggerEnabled: boolean;
objectStorageAccessKey: string;
objectStorageSecretKey: string;
objectStorageForcePathStyle: boolean;
pricingAllowDraftRates: boolean;
};
function validateEnvironment(
@@ -140,6 +152,11 @@ function createAppConfig(environment: Environment): AppConfig {
objectStorageSecretKey,
objectStorageForcePathStyle:
environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true',
// 草稿费率仅用于本地商品编辑联调,生产配置无法开启。
pricingAllowDraftRates: draftPricingEnabled(
environment.NODE_ENV,
environment.PRICING_ALLOW_DRAFT_RATES,
),
};
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Get,
Headers,
Inject,
Param,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { pricingRateReviewRequestSchema } from '@atlas/contracts';
import { z } from 'zod';
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 { PricingAdminService } from './pricing-admin.service.js';
function uuid(value: string, field: string): string {
if (!z.uuid().safeParse(value).success)
throw new ApiError(400, 'VALIDATION_ERROR', `${field} 无效`);
return value;
}
@ApiTags('System pricing')
@ApiBearerAuth('access-token')
@UseGuards(SystemAdminGuard)
@Controller('system/pricing/rate-cards')
export class PricingAdminController {
constructor(
@Inject(PricingAdminService)
private readonly service: PricingAdminService,
) {}
@Get()
async list(@Query('platform_id') platformId: string) {
return {
data: await this.service.list(uuid(platformId, 'platform_id')),
};
}
@Get(':id')
async detail(@Param('id') id: string) {
return { data: await this.service.detail(uuid(id, 'rate_card_id')) };
}
@Post(':id/reviews')
async approve(
@Param('id') id: string,
@Headers('idempotency-key') key: string | undefined,
@Body() body: unknown,
@Req() request: AuthenticatedRequest,
) {
if (!key)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
const parsed = pricingRateReviewRequestSchema.safeParse(body);
if (!parsed.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查审核证据与账单样本',
parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
})),
);
return {
data: await this.service.approve(
uuid(id, 'rate_card_id'),
request.auth.userId,
uuid(key, 'Idempotency-Key'),
parsed.data,
),
};
}
}
@@ -0,0 +1,352 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingRateCardAdmin,
PricingRateReviewRequest,
} from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { ApiError } from '../common/api-error.js';
import {
calculateLogisticsFee,
D,
fingerprint,
logisticsMerchandiseFits,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type CardRow = {
id: string;
platform_id: string;
provider_code: string;
version: string;
source_name: string;
source_uri: string | null;
status: string;
fulfillment: string;
market_code: string;
effective_from: Date;
reviewed_at: Date | null;
reviewer_name: string | null;
};
type TypeRow = PricingRateCardAdmin['types'][number] & Record<string, unknown>;
type RuleRow = LogisticsRule & {
product_type_code: string;
transport_method_name: string;
status: string;
product_type_name: string;
transport_name: string;
};
@Injectable()
export class PricingAdminService {
constructor(@Inject(DATABASE) private readonly db: Database) {}
async list(platformId: string): Promise<PricingRateCardAdmin[]> {
const ids = await this.db.query<{ id: string }>(
`SELECT id FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' ORDER BY created_at DESC`,
[platformId],
);
const cards: PricingRateCardAdmin[] = [];
for (const row of ids.rows) cards.push(await this.detail(row.id));
return cards;
}
async detail(id: string): Promise<PricingRateCardAdmin> {
return this.db.transaction((tx) => this.read(tx, id));
}
private async read(tx: Tx, id: string): Promise<PricingRateCardAdmin> {
const card = (
await tx.query<CardRow>(
`SELECT c.id,c.platform_id,p.code AS provider_code,c.version,c.source_name,c.source_uri,c.status,c.fulfillment,c.market_code,c.effective_from,c.reviewed_at,u.display_name AS reviewer_name
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
LEFT JOIN atlas.users u ON u.id=c.reviewed_by_user_id WHERE c.id=$1`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(404, 'RATE_CARD_NOT_FOUND', '物流费率卡不存在');
const types = (
await tx.query<TypeRow>(
`SELECT id,code::text,min_merchandise_value::text,max_merchandise_value::text,min_actual_weight_kg::text,max_actual_weight_kg::text,billing_basis::text,
merchandise_currency_code,min_value_inclusive,max_value_inclusive,min_actual_weight_inclusive,max_sum_dimensions_cm::text,max_longest_side_cm::text,max_second_longest_side_cm::text,volume_divisor::text,volume_condition_sum_gt_cm::text,volume_round_scale,billing_round_scale,max_volume_weight_kg::text,volume_limit_round_scale
FROM atlas.logistics_product_types WHERE rate_card_id=$1 ORDER BY code`,
[id],
)
).rows;
const rules = (
await tx.query<RuleRow>(
`SELECT r.id,r.product_type_id,t.code::text AS product_type_code,t.name AS product_type_name,r.transport_method_id,m.name AS transport_method_name,m.name AS transport_name,
r.fixed_fee_cny::text,r.per_billable_kg_fee_cny::text,r.min_charge_cny::text,r.surcharge_rules,r.status::text,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,
r.updated_at AS rule_updated_at,t.updated_at AS type_updated_at,m.updated_at AS transport_updated_at,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id
WHERE t.rate_card_id=$1 ORDER BY t.code,m.code`,
[id],
)
).rows;
const review =
(
await tx.query<{
source_reference: string;
review_note: string;
samples: PricingRateReviewRequest['samples'];
reviewed_at: Date;
}>(
`SELECT source_reference,review_note,samples,reviewed_at FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
[id],
)
).rows[0] ?? null;
const { reviewer_name: reviewerName, ...publicCard } = card;
return {
...publicCard,
effective_from: card.effective_from.toISOString(),
reviewed_at: card.reviewed_at?.toISOString() ?? null,
reviewer_name: reviewerName,
rules_hash: fingerprint({
card: {
id: card.id,
version: card.version,
source_name: card.source_name,
source_uri: card.source_uri,
},
types,
rules,
}),
types,
rules: rules.map((r) => ({
id: r.id,
product_type_id: r.product_type_id,
product_type_code: r.product_type_code,
transport_method_id: r.transport_method_id,
transport_method_name: r.transport_method_name,
fixed_fee_cny: r.fixed_fee_cny,
per_billable_kg_fee_cny: r.per_billable_kg_fee_cny,
min_charge_cny: r.min_charge_cny,
status: r.status,
})),
review: review && {
...review,
reviewed_at: review.reviewed_at.toISOString(),
},
};
}
async approve(
id: string,
userId: string,
key: string,
input: PricingRateReviewRequest,
) {
return this.db.transaction(async (tx) => {
await tx.query(
`SELECT pg_advisory_xact_lock(hashtextextended($1,0))`,
[`pricing-review:${userId}:${key}`],
);
const card = (
await tx.query<{
status: string;
provider_code: string;
platform_code: string;
}>(
`SELECT c.status,p.code AS provider_code,platform.code AS platform_code
FROM atlas.logistics_rate_cards c JOIN atlas.logistics_providers p ON p.id=c.provider_id
JOIN atlas.platforms platform ON platform.id=c.platform_id WHERE c.id=$1 FOR UPDATE OF c`,
[id],
)
).rows[0];
if (!card)
throw new ApiError(
404,
'RATE_CARD_NOT_FOUND',
'物流费率卡不存在',
);
const requestHash = fingerprint(input);
const replay = (
await tx.query<{ rate_card_id: string; request_hash: string }>(
`SELECT rate_card_id,request_hash FROM atlas.logistics_rate_card_reviews WHERE reviewer_user_id=$1 AND idempotency_key=$2`,
[userId, key],
)
).rows[0];
if (replay) {
if (
replay.rate_card_id !== id ||
replay.request_hash !== requestHash
)
throw new ApiError(
409,
'IDEMPOTENCY_KEY_CONFLICT',
'幂等键已用于其他审核请求',
);
return this.read(tx, id);
}
if (card.status !== 'draft')
throw new ApiError(
409,
'RATE_CARD_NOT_DRAFT',
'费率卡已审核或不可审核,请刷新',
);
if (
card.provider_code.toUpperCase() !== 'CEL' ||
card.platform_code.toUpperCase() !== 'OZON'
)
throw new ApiError(
422,
'RATE_CARD_REVIEW_UNSUPPORTED',
'当前审核流程仅支持 Ozon CEL 费率',
);
const detail = await this.read(tx, id);
if (detail.rules_hash !== input.rules_hash)
throw new ApiError(
409,
'RATE_CARD_RULES_CHANGED',
'费率规则已变化,请重新查看并复核',
);
const types = new Set(detail.types.map((type) => type.id));
const typeCodes = new Set(detail.types.map((type) => type.code));
if (
types.size !== 6 ||
[
'ExtraSmall',
'Budget',
'Small',
'Big',
'PremiumSmall',
'PremiumBig',
].some((code) => !typeCodes.has(code)) ||
detail.rules.length !== 16 ||
detail.rules.some((r) => r.status !== 'draft')
)
throw new ApiError(
422,
'RATE_CARD_INCOMPLETE',
'CEL 费率应包含六种产品类型和十六条待审核规则',
);
if (
new Date(input.effective_from).getTime() >
Date.now() + 365 * 86400000
)
throw new ApiError(
422,
'RATE_CARD_EFFECTIVE_DATE_INVALID',
'生效日期不能晚于一年后',
);
const fullRules = (
await tx.query<RuleRow>(
`SELECT r.*,t.code::text AS product_type_code,t.name AS product_type_name,m.name AS transport_name,
t.merchandise_currency_code,t.min_merchandise_value::text,t.max_merchandise_value::text,t.min_value_inclusive,t.max_value_inclusive,
t.min_actual_weight_kg::text,t.min_actual_weight_inclusive,t.max_actual_weight_kg::text,t.max_sum_dimensions_cm::text,t.max_longest_side_cm::text,t.max_second_longest_side_cm::text,
t.billing_basis::text,t.volume_divisor::text,t.volume_condition_sum_gt_cm::text,t.volume_round_scale,t.billing_round_scale,t.max_volume_weight_kg::text,t.volume_limit_round_scale,m.status::text AS transport_status
FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id
JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id WHERE t.rate_card_id=$1 FOR SHARE OF r,t,m`,
[id],
)
).rows;
if (
fullRules.some(
(rule) =>
rule.surcharge_rules.length > 0 ||
(rule as RuleRow & { transport_status: string })
.transport_status !== 'active',
)
)
throw new ApiError(
422,
'RATE_CARD_UNSUPPORTED_RULE',
'存在尚未支持的附加费或停用运输方式',
);
const covered = new Set<string>();
const invoiceReferences = new Set<string>();
for (const [index, sample] of input.samples.entries()) {
const invoiceReference = sample.invoice_reference
.trim()
.toLocaleLowerCase();
if (invoiceReferences.has(invoiceReference))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_DUPLICATE',
'账单编号不能重复使用',
);
invoiceReferences.add(invoiceReference);
const rule = fullRules.find((r) => r.id === sample.rule_id);
if (!rule)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单不属于当前费率卡`,
);
covered.add(rule.product_type_id);
if (
!logisticsMerchandiseFits(
rule,
sample.merchandise_value_cny,
)
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单货值不在该产品类型范围`,
);
if (D(sample.shipping_weight_g).lt(1))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INVALID',
`第 ${index + 1} 条账单重量至少 1 g`,
);
const fee = calculateLogisticsFee(rule, {
shipping_weight_g: sample.shipping_weight_g,
length_mm: sample.length_mm,
width_mm: sample.width_mm,
height_mm: sample.height_mm,
status: 'confirmed',
});
if (
'reason' in fee ||
fee.logistics
.sub(sample.billed_logistics_cny)
.abs()
.gt('0.01')
)
throw new ApiError(
422,
'RATE_CARD_SAMPLE_MISMATCH',
`第 ${index + 1} 条账单与费率计算不一致`,
);
}
if ([...types].some((type) => !covered.has(type)))
throw new ApiError(
422,
'RATE_CARD_SAMPLE_INCOMPLETE',
'每种 CEL 产品类型至少需要一条真实账单样本',
);
await tx.query(
`UPDATE atlas.logistics_rate_rules r SET status='active',updated_at=now() FROM atlas.logistics_product_types t WHERE r.product_type_id=t.id AND t.rate_card_id=$1`,
[id],
);
await tx.query(
`UPDATE atlas.logistics_rate_cards SET status='active',effective_from=$2,reviewed_by_user_id=$3,reviewed_at=now(),updated_at=now() WHERE id=$1`,
[id, input.effective_from, userId],
);
await tx.query(
`INSERT INTO atlas.logistics_rate_card_reviews(rate_card_id,reviewer_user_id,idempotency_key,request_hash,rules_hash,source_reference,review_note,samples,effective_from)
VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)`,
[
id,
userId,
key,
requestHash,
input.rules_hash,
input.source_reference,
input.review_note,
JSON.stringify(input.samples),
input.effective_from,
],
);
return this.read(tx, id);
});
}
}
+302
View File
@@ -0,0 +1,302 @@
import { normalizePricingInput } from '@atlas/contracts';
import { randomUUID, createHash } from 'node:crypto';
import { Decimal } from 'decimal.js';
import type { PricingOption, PricingPreviewRequest } from '@atlas/contracts';
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
export const D = (value: Decimal.Value) => new Decimal(value);
export function fingerprint(value: unknown): string {
const sort = (v: unknown): unknown =>
v instanceof Date
? v.toISOString()
: Array.isArray(v)
? v.map(sort)
: v && typeof v === 'object'
? Object.fromEntries(
Object.entries(v)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, x]) => [k, sort(x)]),
)
: v;
return createHash('sha256')
.update(JSON.stringify(sort(value)))
.digest('hex');
}
export type LogisticsRule = {
id: string;
product_type_id: string;
transport_method_id: string;
transport_name: string;
product_type_name: string;
fixed_fee_cny: string;
per_billable_kg_fee_cny: string;
min_charge_cny: string | null;
surcharge_rules: unknown[];
merchandise_currency_code: string;
min_merchandise_value: string | null;
max_merchandise_value: string | null;
min_value_inclusive: boolean;
max_value_inclusive: boolean;
min_actual_weight_kg: string;
min_actual_weight_inclusive: boolean;
max_actual_weight_kg: string | null;
max_sum_dimensions_cm: string | null;
max_longest_side_cm: string | null;
max_second_longest_side_cm: string | null;
billing_basis: string;
volume_divisor: string | null;
volume_condition_sum_gt_cm: string | null;
volume_round_scale: number | null;
billing_round_scale: number | null;
max_volume_weight_kg: string | null;
volume_limit_round_scale: number | null;
};
export type CommissionRule = {
id: string | null;
currency_code: string;
min_price: string | null;
max_price: string | null;
min_inclusive: boolean;
max_inclusive: boolean;
commission_rate: string;
version: string;
};
const inRange = (
n: Decimal,
min: string | null,
max: string | null,
includeMin: boolean,
includeMax: boolean,
) =>
(min === null || (includeMin ? n.gte(min) : n.gt(min))) &&
(max === null || (includeMax ? n.lte(max) : n.lt(max)));
const ceil = (n: Decimal, scale: number | null) =>
scale === null ? n : n.toDecimalPlaces(scale, Decimal.ROUND_CEIL);
export const normalizeInput = normalizePricingInput;
/** 审核样本与正式报价共用包装适用性和运费计算,避免两套规则产生不同结果。 */
export function calculateLogisticsFee(
r: LogisticsRule,
packageSpec: PricingPreviewRequest['package'],
): { billable: Decimal; logistics: Decimal } | { reason: string } {
if (r.surcharge_rules.length)
return { reason: '此规则含尚未支持的附加费,需完成审核后使用' };
const weight = D(packageSpec.shipping_weight_g).div(1000);
const sides = [
packageSpec.length_mm,
packageSpec.width_mm,
packageSpec.height_mm,
]
.map((v) => D(v).div(10))
.sort((a, b) => b.cmp(a));
const sum = sides.reduce((a, b) => a.add(b), D(0));
if (
!inRange(
weight,
r.min_actual_weight_kg,
r.max_actual_weight_kg,
r.min_actual_weight_inclusive,
true,
) ||
(r.max_sum_dimensions_cm && sum.gt(r.max_sum_dimensions_cm)) ||
(r.max_longest_side_cm && sides[0].gt(r.max_longest_side_cm)) ||
(r.max_second_longest_side_cm &&
sides[1].gt(r.max_second_longest_side_cm))
)
return { reason: '包装重量或尺寸超出物流范围' };
let billable = weight;
if (r.billing_basis !== 'actual_only') {
if (!r.volume_divisor || D(r.volume_divisor).lte(0))
return { reason: '体积重规则无效' };
const volume = sides
.reduce((a, b) => a.mul(b), D(1))
.div(r.volume_divisor);
if (
r.max_volume_weight_kg &&
ceil(volume, r.volume_limit_round_scale).gt(r.max_volume_weight_kg)
)
return { reason: '体积重超限' };
if (
r.billing_basis === 'max_actual_volume' ||
(r.volume_condition_sum_gt_cm &&
sum.gt(r.volume_condition_sum_gt_cm))
)
billable = Decimal.max(weight, ceil(volume, r.volume_round_scale));
}
billable = ceil(billable, r.billing_round_scale);
const logistics = Decimal.max(
billable.mul(r.per_billable_kg_fee_cny).add(r.fixed_fee_cny),
r.min_charge_cny ?? '0',
);
return { billable, logistics };
}
export function logisticsMerchandiseFits(
r: LogisticsRule,
valueCny: string,
): boolean {
return (
r.merchandise_currency_code === 'CNY' &&
inRange(
D(valueCny),
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
);
}
/** 枚举自洽候选;先舍入目标币种售价,再回验佣金档与货值档,避免跨档误报。 */
export function calculatePricing(
input: PricingPreviewRequest,
currency: string,
fx: string,
rules: LogisticsRule[],
commissions: CommissionRule[],
) {
if (!['CNY', 'RUB'].includes(currency))
throw new Error('门店币种仅支持 CNY / RUB');
const p = input.pricing,
exchange = D(fx),
purchase = D(input.purchase_cost_cny),
domestic = D(p.domestic_shipping_and_label_fee_cny);
const options: PricingOption[] = [],
unavailable_options: Array<{
transport_method_id: string;
reason: string;
}> = [];
for (const transport of p.transport_method_ids) {
const reasons = new Set<string>();
for (const r of rules.filter(
(r) => r.transport_method_id === transport,
)) {
const fee = calculateLogisticsFee(r, input.package);
if ('reason' in fee) {
reasons.add(fee.reason);
continue;
}
const { billable, logistics } = fee;
for (const c of commissions) {
const totalRate = D(c.commission_rate)
.add(p.ad_rate)
.add(p.last_mile_rate)
.add(p.withdrawal_rate)
.add(p.other_rate);
if (totalRate.gte(1)) {
reasons.add('总扣费率达到或超过 100%');
continue;
}
const factor = currency === 'RUB' ? exchange : D(1);
const sale =
p.mode === 'fixed_sale'
? D(p.selling_price!)
: purchase
.mul(D(1).add(p.desired_markup_rate))
.add(logistics)
.add(domestic)
.div(D(1).sub(totalRate))
.mul(factor);
const rounded = sale.toDecimalPlaces(
2,
p.mode === 'fixed_sale'
? Decimal.ROUND_HALF_UP
: Decimal.ROUND_CEIL,
),
cny = rounded.div(factor),
rub = cny.mul(exchange);
if (
rounded.lte(0) ||
!inRange(
c.currency_code === 'RUB' ? rub : cny,
c.min_price,
c.max_price,
c.min_inclusive,
c.max_inclusive,
) ||
!inRange(
r.merchandise_currency_code === 'RUB' ? rub : cny,
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
) {
reasons.add('售价不在该物流货值区间或佣金档内');
continue;
}
const profit = cny
.mul(D(1).sub(totalRate))
.sub(purchase)
.sub(logistics)
.sub(domestic),
list = rounded
.div(D(1).sub(p.list_price_discount_rate))
.toDecimalPlaces(2, Decimal.ROUND_CEIL);
if (
[
rounded,
list,
rub,
list.div(factor).mul(exchange),
profit.mul(exchange),
logistics,
].some((v) => v.abs().gte('100000000000000'))
) {
reasons.add('金额超过可保存范围,请调整费率或输入');
continue;
}
options.push({
id: randomUUID(),
can_confirm: profit.gte(0),
transport_method_id: transport,
transport_name: r.transport_name,
product_type_id: r.product_type_id,
product_type_name: r.product_type_name,
logistics_rate_rule_id: r.id,
commission_rule_id: c.id,
commission_rate: c.commission_rate,
selling_price: rounded.toFixed(2),
list_price: list.toFixed(2),
currency_code: currency,
selling_price_rub: rub.toFixed(4),
list_price_rub: list.div(factor).mul(exchange).toFixed(4),
merchandise_value_cny: cny.toFixed(4),
profit_cny: profit.toFixed(2),
profit_rub: profit.mul(exchange).toFixed(4),
margin_rate: profit.div(cny).toFixed(6),
billable_weight_kg: billable.toFixed(3),
fees: {
logistics_cny: logistics.toFixed(4),
domestic_cny: domestic.toFixed(4),
commission_cny: cny.mul(c.commission_rate).toFixed(4),
ad_cny: cny.mul(p.ad_rate).toFixed(4),
last_mile_cny: cny.mul(p.last_mile_rate).toFixed(4),
withdrawal_cny: cny.mul(p.withdrawal_rate).toFixed(4),
other_cny: cny.mul(p.other_rate).toFixed(4),
},
warnings: [
...(profit.lt(0) ? ['亏损报价不能确认'] : []),
...(profit.lt(purchase.mul(p.desired_markup_rate))
? ['未达到目标加价利润']
: []),
...(!c.id ? ['使用临时佣金率'] : []),
],
});
}
}
if (!options.some((o) => o.transport_method_id === transport))
unavailable_options.push({
transport_method_id: transport,
reason: [...reasons].join(';') || '没有可用规则',
});
}
options.sort((a, b) => D(a.selling_price).cmp(b.selling_price));
const recommended =
options.find(
(o) => o.transport_method_id === p.default_transport_method_id,
) ?? (!p.default_transport_method_id ? options[0] : undefined);
return {
options,
unavailable_options,
recommended_option_id: recommended?.id ?? null,
};
}
+190
View File
@@ -0,0 +1,190 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
Inject,
Param,
Patch,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import {
pricingPreviewRequestSchema,
pricingPolicyRequestSchema,
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 { PricingService } from './pricing.service.js';
type Response = { status: (status: number) => unknown };
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (!result.success)
throw new ApiError(
422,
'VALIDATION_ERROR',
'请检查定价输入',
result.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
);
return result.data;
}
function key(value: string | undefined) {
if (!value || value.length > 200)
throw new ApiError(
400,
'IDEMPOTENCY_KEY_REQUIRED',
'需要 Idempotency-Key',
);
return value;
}
@ApiTags('Pricing')
@ApiBearerAuth('access-token')
@Controller()
export class PricingController {
constructor(
@Inject(PricingService) private readonly service: PricingService,
) {}
@Get('pricing/categories') async categories(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
@Query('q') q: string | undefined,
) {
return {
data: await this.service.categories(
w.id,
parse(z.uuid(), store),
parse(z.string().max(200), q ?? ''),
),
};
}
@Get('pricing/catalog') async catalog(
@CurrentWorkspace() w: Workspace,
@Query('store_id') store: string,
) {
return {
data: await this.service.catalog(w.id, parse(z.uuid(), store)),
};
}
@Get('stores/:id/pricing-policies') async policies(
@CurrentWorkspace() w: Workspace,
@Param('id') id: string,
) {
return {
data: (await this.service.catalog(w.id, parse(z.uuid(), id)))
.policies,
};
}
@Post('pricing/quote-previews') async preview(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.preview(
w.id,
req.auth.userId,
key(k),
parse(pricingPreviewRequestSchema, body),
);
res.status(r.status);
return r.body;
}
@Post('stores/:id/pricing-policies') async create(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Body() body: unknown,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
parse(z.uuid(), id),
null,
'create',
parse(pricingPolicyRequestSchema, body),
null,
);
res.status(r.status);
return r.body;
}
@Patch('pricing-policies/:id') async update(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Headers('if-match') match: string | undefined,
@Body() body: unknown,
) {
if (!match || !/^(?:[1-9]\d*|"[1-9]\d*")$/.test(match))
throw new ApiError(
428,
'PRECONDITION_REQUIRED',
'需要方案版本 If-Match',
);
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'update',
parse(pricingPolicyRequestSchema, body),
Number(match.replaceAll('"', '')),
)
).body;
}
@Post('pricing-policies/:id/set-default') async defaultPolicy(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const r = await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'default',
null,
null,
);
res.status(r.status);
return r.body;
}
@Delete('pricing-policies/:id') async archive(
@CurrentWorkspace() w: Workspace,
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Headers('idempotency-key') k: string | undefined,
) {
return (
await this.service.writePolicy(
w.id,
req.auth.userId,
key(k),
null,
parse(z.uuid(), id),
'archive',
null,
null,
)
).body;
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { IdempotencyService } from '../common/idempotency.service.js';
import { PricingController } from './pricing.controller.js';
import { PricingService } from './pricing.service.js';
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],
providers: [
PricingService,
PricingAdminService,
SystemAdminGuard,
IdempotencyService,
],
exports: [PricingService],
})
export class PricingModule {}
+850
View File
@@ -0,0 +1,850 @@
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Database, DatabaseTransaction } from '@atlas/database';
import type {
PricingCatalog,
PricingPolicy,
PricingPolicyRequest,
PricingPreview,
PricingPreviewRequest,
PricingParameters,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { DATABASE } from '../database/database.module.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import {
calculatePricing,
D,
fingerprint,
normalizeInput,
type CommissionRule,
type LogisticsRule,
} from './pricing-engine.js';
type Tx = DatabaseTransaction;
type Store = {
id: string;
name: string;
platform_id: string;
currency_code: string;
};
type PolicyRow = {
id: string;
store_id: string;
name: string;
note: string | null;
revision: number;
is_default: boolean;
rate_card_id: string;
default_transport_method_id: string | null;
domestic_shipping_and_label_fee_cny: string;
ad_rate: string;
last_mile_rate: string;
withdrawal_rate: string;
other_rate: string;
desired_markup_rate: string;
list_price_discount_rate: string;
allow_commission_override: boolean;
fx_strategy: string;
fixed_cny_to_rub_rate: string | null;
transport_ids: string[];
};
const active = `status='active' AND effective_from<=now() AND (effective_to IS NULL OR effective_to>now())`;
@Injectable()
export class PricingService {
constructor(
@Inject(DATABASE) private readonly db: Database,
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
private get usableCardCondition() {
const reviewed = `(${active} AND reviewed_at IS NOT NULL AND reviewed_by_user_id IS NOT NULL)`;
return this.config.pricingAllowDraftRates
? `(${reviewed} OR status='draft')`
: reviewed;
}
async store(tx: Tx, id: string): Promise<Store> {
const r = await tx.query<Store>(
`SELECT id,name,platform_id,currency_code FROM atlas.stores WHERE id=$1 AND deleted_at IS NULL`,
[id],
);
if (!r.rows[0])
throw new ApiError(404, 'STORE_NOT_FOUND', '门店不存在');
if (!['CNY', 'RUB'].includes(r.rows[0].currency_code))
throw new ApiError(
422,
'PRICING_CURRENCY_UNSUPPORTED',
'定价仅支持 CNY / RUB 门店',
);
return r.rows[0];
}
async policies(tx: Tx, storeId: string): Promise<PricingPolicy[]> {
const result = await tx.query<PolicyRow>(
`SELECT p.*,ARRAY(SELECT transport_method_id FROM atlas.pricing_policy_transport_methods t WHERE t.pricing_policy_id=p.id AND is_enabled ORDER BY sort_order,transport_method_id) AS transport_ids FROM atlas.pricing_policies p WHERE p.store_id=$1 AND p.status='active' ORDER BY p.is_default DESC,p.name`,
[storeId],
);
return result.rows.map((p) => ({
id: p.id,
store_id: p.store_id,
name: p.name,
note: p.note,
revision: p.revision,
is_default: p.is_default,
allow_commission_override: p.allow_commission_override,
parameters: {
pricing_policy_id: p.id,
rate_card_id: p.rate_card_id,
transport_method_ids: p.transport_ids,
default_transport_method_id: p.default_transport_method_id,
domestic_shipping_and_label_fee_cny:
p.domestic_shipping_and_label_fee_cny,
ad_rate: p.ad_rate,
last_mile_rate: p.last_mile_rate,
withdrawal_rate: p.withdrawal_rate,
other_rate: p.other_rate,
desired_markup_rate: p.desired_markup_rate,
list_price_discount_rate: p.list_price_discount_rate,
mode: 'target_markup',
selling_price: null,
temporary_commission_rate: null,
fx:
p.fx_strategy === 'fixed'
? {
mode: 'policy_fixed',
rate: p.fixed_cny_to_rub_rate!,
}
: { mode: 'system' },
},
}));
}
async categories(workspaceId: string, storeId: string, q: string) {
return this.db.workspaceTransaction(workspaceId, async (tx) => {
const store = await this.store(tx, storeId);
return (
await tx.query<{ id: string; name: string; path: string }>(
`SELECT id,name,path FROM atlas.platform_product_categories WHERE platform_id=$1 AND is_active AND (name ILIKE $2 OR path ILIKE $2) ORDER BY path LIMIT 50`,
[store.platform_id, `%${q}%`],
)
).rows;
});
}
async exchange(tx: Tx) {
const r = await tx.query<{
id: string;
rate: string;
effective_on: string;
stale: boolean;
}>(
`SELECT id,rate::text,effective_on::text,(effective_on::timestamp AT TIME ZONE 'Europe/Moscow')<now()-interval '72 hours' AS stale FROM atlas.exchange_rates WHERE base_currency_code='CNY' AND quote_currency_code='RUB' AND source='cbr' AND kind='official_reference' ORDER BY effective_on DESC,fetched_at DESC LIMIT 1`,
);
return r.rows[0] ?? null;
}
async catalog(workspace: string, storeId: string): Promise<PricingCatalog> {
return this.db.workspaceTransaction(workspace, async (tx) => {
const store = await this.store(tx, storeId);
const cards = await tx.query<{
id: string;
name: string;
version: string;
development_only: boolean;
transports: Array<{ id: string; name: string }>;
}>(
`SELECT c.id,c.source_name AS name,c.version,(c.status='draft') AS development_only,coalesce((SELECT jsonb_agg(jsonb_build_object('id',t.id,'name',t.name) ORDER BY t.name) FROM atlas.logistics_transport_methods t WHERE t.rate_card_id=c.id AND t.status='active'),'[]') AS transports FROM atlas.logistics_rate_cards c WHERE c.platform_id=$1 AND c.fulfillment='rfbs' AND c.market_code='RU' AND ${this.usableCardCondition} ORDER BY c.status='active' DESC,c.effective_from DESC`,
[store.platform_id],
);
const pendingCards = await tx.query<{
id: string;
name: string;
version: string;
status: 'draft';
}>(
`SELECT id,source_name AS name,version,status FROM atlas.logistics_rate_cards WHERE platform_id=$1 AND fulfillment='rfbs' AND market_code='RU' AND status='draft' ORDER BY created_at DESC`,
[store.platform_id],
);
return {
store,
rate_cards: cards.rows,
pending_rate_cards: pendingCards.rows,
policies: await this.policies(tx, storeId),
exchange_rate: await this.exchange(tx),
};
});
}
/** 开发模式可复算草稿;保存确认时复读规则,正式模式仍只接受已审核版本。 */
async rules(tx: Tx, input: PricingPreviewRequest, store: Store) {
const card = (
await tx.query<{ id: string; version: string; status: string; updated_at: Date }>(
`SELECT * FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment=$3 AND market_code='RU' AND ${this.usableCardCondition} FOR SHARE`,
[
input.pricing.rate_card_id,
store.platform_id,
input.fulfillment,
],
)
).rows[0];
if (!card)
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`,
[input.platform_product_category_id, store.platform_id],
);
if (!category.rowCount)
throw new ApiError(
422,
'PRICING_CATEGORY_REQUIRED',
'请选择当前平台的有效类目',
);
if (input.pricing.pricing_policy_id)
await tx.query(
'SELECT id FROM atlas.pricing_policies WHERE id=$1 FOR SHARE',
[input.pricing.pricing_policy_id],
);
const policy = input.pricing.pricing_policy_id
? (await this.policies(tx, store.id)).find(
(p) => p.id === input.pricing.pricing_policy_id,
)
: null;
if (
input.pricing.pricing_policy_id &&
(!policy || policy.parameters.rate_card_id !== card.id)
)
throw new ApiError(
422,
'PRICING_POLICY_UNAVAILABLE',
'方案不可用或费率卡不匹配',
);
const logistics = (
await tx.query<LogisticsRule>(
`SELECT t.*,r.id,r.product_type_id,r.transport_method_id,m.name AS transport_name,t.name AS product_type_name,r.fixed_fee_cny,r.per_billable_kg_fee_cny,r.min_charge_cny,r.surcharge_rules,r.updated_at AS rule_updated_at,m.updated_at AS transport_updated_at FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id JOIN atlas.logistics_transport_methods m ON m.id=r.transport_method_id AND m.rate_card_id=t.rate_card_id WHERE t.rate_card_id=$1 AND r.status=$3 AND m.status='active' AND m.id=ANY($2::uuid[]) ORDER BY r.id FOR SHARE OF r,t,m`,
[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
: [];
if (
!commissions.length &&
input.pricing.temporary_commission_rate !== null &&
(!policy || policy.allow_commission_override)
)
commissions = [
{
id: null,
currency_code: 'RUB',
min_price: null,
max_price: null,
min_inclusive: true,
max_inclusive: true,
commission_rate: input.pricing.temporary_commission_rate,
version: 'temporary',
},
];
if (!commissions.length)
throw new ApiError(
422,
'PRICING_COMMISSION_UNAVAILABLE',
'该类目尚无有效佣金规则;允许临时佣金的方案可在详情中补充',
);
if (
commissions.some((c) => c.id) &&
input.pricing.temporary_commission_rate !== null
)
throw new ApiError(
422,
'PRICING_COMMISSION_OVERRIDE_FORBIDDEN',
'已有正式佣金规则,不能临时覆盖',
);
return {
card,
policy,
logistics,
commissions,
hash: fingerprint({
card,
policy,
logistics,
commissions,
mapping,
}),
};
}
async preview(
workspaceId: string,
userId: string,
key: string,
input: PricingPreviewRequest,
) {
return this.idempotency.run(
{
workspaceId,
userId,
key,
method: 'POST',
path: '/api/pricing/quote-previews',
request: input,
},
async (tx) => {
const store = await this.store(tx, input.store_id);
if (input.binding) {
const binding = input.binding;
const profile = (
await tx.query(
`SELECT id FROM atlas.product_platform_profiles WHERE id=$1 AND platform_id=$2`,
[binding.profile_id, store.platform_id],
)
).rows[0];
if (!profile)
throw new ApiError(
404,
'PROFILE_NOT_FOUND',
'商品发布配置不存在',
);
if (
binding.variant_id &&
!(
await tx.query(
`SELECT l.id FROM atlas.product_variant_platform_listings l JOIN atlas.product_variants v ON v.id=l.product_variant_id WHERE l.profile_id=$1 AND l.product_variant_id=$2 AND l.status<>'imported' AND v.deleted_at IS NULL`,
[binding.profile_id, binding.variant_id],
)
).rowCount
)
throw new ApiError(
422,
'PRICING_VARIANT_UNAVAILABLE',
'SKU 不存在或已经上架',
);
}
const rules = await this.rules(tx, input, store),
exchange = await this.exchange(tx);
let fx: string, origin: string;
if (input.pricing.fx.mode === 'policy_fixed') {
const fixed = rules.policy?.parameters.fx;
if (fixed?.mode !== 'policy_fixed' || !fixed.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'方案没有固定汇率',
);
fx = fixed.rate;
origin = 'policy_fixed';
} else if (input.pricing.fx.mode === 'one_time_override') {
fx = input.pricing.fx.rate!;
origin = 'quote_override';
} else {
if (!exchange)
throw new ApiError(
422,
'PRICING_FX_UNAVAILABLE',
'尚无 CBR 汇率,请稍后重试或明确填写本次汇率',
);
fx = D(exchange.rate).toDecimalPlaces(8).toString();
origin = 'system_rate';
}
const normalized = normalizeInput(input),
id = randomUUID(),
expires = new Date(Date.now() + 15 * 60_000).toISOString();
const result: PricingPreview = {
preview_id: id,
expires_at: expires,
input_hash: fingerprint(normalized),
...calculatePricing(
normalized,
store.currency_code,
fx,
rules.logistics,
rules.commissions,
),
context: {
currency_code: store.currency_code,
fx_rate: fx,
fx_origin: origin,
rule_version: rules.card.version,
development_only: rules.card.status === 'draft',
exchange_rate:
origin === 'system_rate' ? exchange : null,
warnings:
origin === 'system_rate' && exchange?.stale
? ['CBR 汇率超过 72 小时,请确认参考日期']
: [],
},
};
await tx.query(
`INSERT INTO atlas.pricing_previews(id,workspace_id,user_id,store_id,platform_id,input,input_hash,rules_hash,result,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[
id,
workspaceId,
userId,
store.id,
store.platform_id,
JSON.stringify(normalized),
result.input_hash,
rules.hash,
JSON.stringify(result),
expires,
],
);
return { status: 201, body: { data: result } };
},
);
}
async writePolicy(
workspaceId: string,
userId: string,
key: string,
storeId: string | null,
id: string | null,
action: 'create' | 'update' | 'default' | 'archive',
input: PricingPolicyRequest | null,
revision: number | null,
) {
return this.idempotency
.run(
{
workspaceId,
userId,
key,
method:
action === 'update'
? 'PATCH'
: action === 'archive'
? 'DELETE'
: 'POST',
path: id
? `/api/pricing-policies/${id}/${action}`
: `/api/stores/${storeId}/pricing-policies`,
request: { input, revision },
},
async (tx) => {
let current:
{ store_id: string; revision: number } | undefined;
if (id) {
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active'`,
[id],
)
).rows[0];
if (!current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
storeId = current.store_id;
}
const store = await this.store(tx, storeId!);
await tx.query(
`SELECT id FROM atlas.stores WHERE id=$1 FOR UPDATE`,
[store.id],
);
if (id)
current = (
await tx.query<{
store_id: string;
revision: number;
}>(
`SELECT store_id,revision FROM atlas.pricing_policies WHERE id=$1 AND status='active' FOR UPDATE`,
[id],
)
).rows[0];
if (id && !current)
throw new ApiError(
404,
'PRICING_POLICY_NOT_FOUND',
'方案不存在',
);
if (action === 'update' && current?.revision !== revision)
throw new ApiError(
409,
'VERSION_CONFLICT',
'方案已更新,请刷新后重试',
);
if (action === 'default') {
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=false,revision=revision+1,updated_at=now() WHERE store_id=$1 AND is_default AND id<>$2`,
[store.id, id],
);
await tx.query(
`UPDATE atlas.pricing_policies SET is_default=true,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
} else if (action === 'archive')
await tx.query(
`UPDATE atlas.pricing_policies SET status='archived',is_default=false,revision=revision+1,updated_at=now() WHERE id=$1`,
[id],
);
else {
const p = input!.parameters;
if (p.fx.mode === 'policy_fixed' && !p.fx.rate)
throw new ApiError(
422,
'PRICING_FX_REQUIRED',
'固定汇率不能为空',
);
if (
p.default_transport_method_id &&
!p.transport_method_ids.includes(
p.default_transport_method_id,
)
)
throw new ApiError(
422,
'PRICING_TRANSPORT_INVALID',
'默认运输方式必须包含在比较列表中',
);
const card = (
await tx.query(
`SELECT id FROM atlas.logistics_rate_cards WHERE id=$1 AND platform_id=$2 AND fulfillment='rfbs' AND market_code='RU' AND ${this.usableCardCondition}`,
[p.rate_card_id, store.platform_id],
)
).rowCount;
const transports = (
await tx.query(
`SELECT id FROM atlas.logistics_transport_methods WHERE rate_card_id=$1 AND status='active' AND id=ANY($2::uuid[])`,
[p.rate_card_id, p.transport_method_ids],
)
).rowCount;
if (
!card ||
transports !== new Set(p.transport_method_ids).size
)
throw new ApiError(
422,
'PRICING_RULES_UNAVAILABLE',
'费率卡或运输方式不可用',
);
const values = [
input!.name,
input!.note,
p.rate_card_id,
p.default_transport_method_id,
p.domestic_shipping_and_label_fee_cny,
p.ad_rate,
p.last_mile_rate,
p.withdrawal_rate,
p.other_rate,
p.desired_markup_rate,
p.list_price_discount_rate,
input!.allow_commission_override,
p.fx.mode === 'policy_fixed' ? 'fixed' : 'system',
p.fx.mode === 'policy_fixed' ? p.fx.rate : null,
];
if (action === 'create') {
id = randomUUID();
await tx.query(
`INSERT INTO atlas.pricing_policies(name,note,rate_card_id,default_transport_method_id,domestic_shipping_and_label_fee_cny,ad_rate,last_mile_rate,withdrawal_rate,other_rate,desired_markup_rate,list_price_discount_rate,allow_commission_override,fx_strategy,fixed_cny_to_rub_rate,id,workspace_id,store_id,platform_id) VALUES(${values.map((_, i) => `$${i + 1}`).join(',')},$15,$16,$17,$18)`,
[
...values,
id,
workspaceId,
store.id,
store.platform_id,
],
);
} else
await tx.query(
`UPDATE atlas.pricing_policies SET name=$1,note=$2,rate_card_id=$3,default_transport_method_id=$4,domestic_shipping_and_label_fee_cny=$5,ad_rate=$6,last_mile_rate=$7,withdrawal_rate=$8,other_rate=$9,desired_markup_rate=$10,list_price_discount_rate=$11,allow_commission_override=$12,fx_strategy=$13,fixed_cny_to_rub_rate=$14,revision=revision+1,updated_at=now() WHERE id=$15`,
[...values, id],
);
await tx.query(
`DELETE FROM atlas.pricing_policy_transport_methods WHERE pricing_policy_id=$1`,
[id],
);
for (const [i, t] of [
...new Set(p.transport_method_ids),
].entries())
await tx.query(
`INSERT INTO atlas.pricing_policy_transport_methods(workspace_id,pricing_policy_id,transport_method_id,sort_order) VALUES($1,$2,$3,$4)`,
[workspaceId, id, t, i],
);
}
return {
status: action === 'create' ? 201 : 200,
body: {
data:
action === 'archive'
? { id, archived: true }
: (await this.policies(tx, store.id)).find(
(p) => p.id === id,
),
},
};
},
)
.catch((error: unknown) => {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === '23505'
)
throw new ApiError(
409,
'PRICING_POLICY_NAME_CONFLICT',
'此门店已存在同名方案,请使用其他名称',
);
throw error;
});
}
/** 必须在 editor-draft 的现有事务中调用,任何确认失败使商品修改一起回滚。 */
async persistDraft(
tx: Tx,
workspaceId: string,
userId: string,
profileId: string,
input: SaveProductEditorDraftRequest,
created: Array<{ client_key: string; id: string }>,
) {
for (const v of input.variants) {
const variantId =
v.id ?? created.find((c) => c.client_key === v.client_key)!.id;
const listing = (
await tx.query<{
id: string;
status: string;
pricing_draft: PricingParameters | null;
pricing_quote_id: string | null;
}>(
`SELECT id,status,pricing_draft,pricing_quote_id FROM atlas.product_variant_platform_listings WHERE profile_id=$1 AND product_variant_id=$2 FOR UPDATE`,
[profileId, variantId],
)
).rows[0];
if (listing.status === 'imported') continue;
const state = (
await tx.query<{
product_id: string;
platform_id: string;
store_id: string;
currency_code: string;
category_id: string;
fulfillment: 'rfbs';
purchase_cost_cny: string;
package: PricingPreviewRequest['package'];
}>(
`SELECT v.product_id,p.platform_id,p.target_store_id AS store_id,s.currency_code,p.platform_product_category_id AS category_id,p.fulfillment,v.purchase_cost_cny::text,jsonb_build_object('shipping_weight_g',coalesce(v.shipping_weight_g,g.default_shipping_weight_g)::text,'length_mm',coalesce(v.package_length_mm,g.default_package_length_mm)::text,'width_mm',coalesce(v.package_width_mm,g.default_package_width_mm)::text,'height_mm',coalesce(v.package_height_mm,g.default_package_height_mm)::text,'status',coalesce(v.package_spec_status,g.package_spec_status)) AS package FROM atlas.product_variants v JOIN atlas.products g ON g.id=v.product_id JOIN atlas.product_platform_profiles p ON p.id=$1 JOIN atlas.stores s ON s.id=p.target_store_id WHERE v.id=$2`,
[profileId, variantId],
)
).rows[0];
const params =
v.pricing_draft === undefined
? listing.pricing_draft
: v.pricing_draft;
const parsed = pricingPreviewRequestSchema.safeParse({
store_id: state.store_id,
platform_product_category_id: state.category_id,
fulfillment: state.fulfillment,
purchase_cost_cny:
state.purchase_cost_cny === null
? null
: D(state.purchase_cost_cny).toFixed(2),
package: state.package,
pricing: params,
binding: {
profile_id: profileId,
variant_id: v.id,
client_key: v.id ? null : v.client_key,
},
});
const normalized = parsed.success
? normalizeInput(parsed.data)
: null;
const fail = () => {
throw new ApiError(
409,
'PRICING_PREVIEW_STALE',
`${v.display_name ?? 'SKU'} 的报价已失效,请重新试算或仅保存草稿`,
[
{
field: `variants.${variantId}.pricing_selection`,
code: 'PRICING_PREVIEW_STALE',
message: '报价输入、规则或有效期已变化',
},
],
);
};
if (!v.pricing_selection) {
// 旧报价保留供审计,当前引用仅在输入和归属仍一致时保持有效。
const old = listing.pricing_quote_id
? (
await tx.query<{
input_snapshot: {
input: PricingPreviewRequest;
context: PricingPreview['context'];
};
}>(
`SELECT input_snapshot FROM atlas.pricing_quotes WHERE id=$1`,
[listing.pricing_quote_id],
)
).rows[0]
: null;
const same =
normalized &&
old &&
old.input_snapshot.context.currency_code ===
state.currency_code &&
fingerprint({ ...normalized, binding: null }) ===
fingerprint({
...old.input_snapshot.input,
binding: null,
});
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_draft=$2,pricing_valid=$3,currency_code=$4,pricing_quote_id=CASE WHEN store_id=$5 THEN pricing_quote_id ELSE NULL END WHERE id=$1`,
[
listing.id,
params ? JSON.stringify(params) : null,
Boolean(same),
state.currency_code,
old?.input_snapshot.input.store_id ?? state.store_id,
],
);
continue;
}
if (!normalized) fail();
const selection = v.pricing_selection;
const preview = (
await tx.query<{
input: PricingPreviewRequest;
input_hash: string;
rules_hash: string;
result: PricingPreview;
valid: boolean;
}>(
`SELECT input,input_hash,rules_hash,result,expires_at>now() AS valid FROM atlas.pricing_previews WHERE id=$1 AND user_id=$2 FOR SHARE`,
[selection.preview_id, userId],
)
).rows[0];
if (
!preview ||
!preview.valid ||
selection.input_hash !== preview.input_hash ||
fingerprint(normalized) !== preview.input_hash
)
fail();
const store = await this.store(tx, state.store_id);
let currentRules: Awaited<ReturnType<PricingService['rules']>>;
try {
currentRules = await this.rules(tx, normalized!, store);
} catch (error) {
if (error instanceof ApiError && error.getStatus() === 422)
return fail();
throw error;
}
if (
currentRules.hash !== preview.rules_hash ||
preview.result.context.currency_code !== state.currency_code
)
fail();
const option = preview.result.options.find(
(o) => o.id === selection.option_id,
);
if (!option || !option.can_confirm) fail();
const o = option!,
quoteId = randomUUID(),
selectedId = randomUUID();
const savedInput = {
...normalized!,
binding: {
profile_id: profileId,
variant_id: variantId,
client_key: null,
},
};
await tx.query(
`INSERT INTO atlas.pricing_quotes(id,workspace_id,store_id,platform_id,pricing_policy_id,product_variant_id,product_id,profile_id,platform_product_category_id,fulfillment,purchase_cost_cny,package_weight_kg,package_length_cm,package_width_cm,package_height_cm,fx_cny_to_rub,fx_rate_origin,input_snapshot,status,selected_option_id,calculated_at,confirmed_at,created_by_user_id,input_hash) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,'confirmed',$19,now(),now(),$20,$21)`,
[
quoteId,
workspaceId,
state.store_id,
state.platform_id,
params!.pricing_policy_id,
variantId,
state.product_id,
profileId,
state.category_id,
state.fulfillment,
state.purchase_cost_cny,
D(state.package.shipping_weight_g).div(1000).toString(),
D(state.package.length_mm).div(10).toString(),
D(state.package.width_mm).div(10).toString(),
D(state.package.height_mm).div(10).toString(),
preview.result.context.fx_rate,
preview.result.context.fx_origin,
JSON.stringify({
input: savedInput,
context: preview.result.context,
rules: currentRules,
}),
selectedId,
userId,
fingerprint(savedInput),
],
);
for (const candidate of preview.result.options) {
const f = candidate.fees,
fx = D(preview.result.context.fx_rate);
await tx.query(
`INSERT INTO atlas.pricing_quote_options(id,workspace_id,quote_id,logistics_rate_rule_id,commission_rule_id,logistics_product_type_id,transport_method_id,merchandise_value_cny,selling_price_rub,list_price_rub,billable_weight_kg,logistics_fee_cny,commission_fee_rub,domestic_fee_cny,ad_fee_rub,last_mile_fee_rub,withdrawal_fee_rub,other_fee_rub,profit_rub,calculation_snapshot) VALUES(${Array.from({ length: 20 }, (_, i) => `$${i + 1}`).join(',')})`,
[
candidate.id === o.id ? selectedId : randomUUID(),
workspaceId,
quoteId,
candidate.logistics_rate_rule_id,
candidate.commission_rule_id,
candidate.product_type_id,
candidate.transport_method_id,
candidate.merchandise_value_cny,
candidate.selling_price_rub,
candidate.list_price_rub,
candidate.billable_weight_kg,
f.logistics_cny,
fx.mul(f.commission_cny).toFixed(4),
f.domestic_cny,
fx.mul(f.ad_cny).toFixed(4),
fx.mul(f.last_mile_cny).toFixed(4),
fx.mul(f.withdrawal_cny).toFixed(4),
fx.mul(f.other_cny).toFixed(4),
candidate.profit_rub,
JSON.stringify(candidate),
],
);
}
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=$2,pricing_draft=$3,pricing_valid=true,selling_price=$4,list_price=$5,currency_code=$6 WHERE id=$1`,
[
listing.id,
quoteId,
JSON.stringify(params),
o.selling_price,
o.list_price,
o.currency_code,
],
);
}
}
}
@@ -1,3 +1,5 @@
import { PricingService } from '../pricing/pricing.service.js';
import type { PricingParameters, PricingOption } from '@atlas/contracts';
import { Inject, Injectable } from '@nestjs/common';
import type {
ProductEditorContext,
@@ -10,6 +12,7 @@ import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { DATABASE } from '../database/database.module.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import {
type ProductRow,
type ProfileRow,
@@ -37,6 +40,7 @@ import {
@Injectable()
export class ProductEditorService {
private readonly pricing: PricingService;
private readonly catalog: ProductEditorCatalogOperations;
private readonly validationOperations: ProductEditorValidationOperations;
private readonly publishPreflightOperations: ProductEditorPublishPreflightOperations;
@@ -46,7 +50,9 @@ export class ProductEditorService {
@Inject(IdempotencyService)
private readonly idempotency: IdempotencyService,
@Inject(DATABASE) private readonly database: Database,
@Inject(APP_CONFIG) config: AppConfig,
) {
this.pricing = new PricingService(database, idempotency, config);
this.catalog = new ProductEditorCatalogOperations(this.database, {
requireProfileForPlatform: (...args) =>
this.requireProfileForPlatform(...args),
@@ -219,6 +225,9 @@ export class ProductEditorService {
description: string | null;
tags: unknown;
status: string;
pricing_draft: PricingParameters | null;
pricing_result: PricingOption | null;
pricing_valid: boolean;
selling_price: string | null;
list_price: string | null;
currency_code: string | null;
@@ -239,7 +248,8 @@ export class ProductEditorService {
`SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order,
v.purchase_cost_cny::numeric(18, 2)::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id,
l.title, l.description, l.tags, l.status, l.selling_price::text,
l.list_price::text, l.currency_code, v.specification_values,
l.list_price::text, l.currency_code, l.pricing_draft, l.pricing_valid,
(SELECT calculation_snapshot FROM atlas.pricing_quote_options o JOIN atlas.pricing_quotes q ON q.id=o.quote_id AND q.selected_option_id=o.id WHERE q.id=l.pricing_quote_id) AS pricing_result, v.specification_values,
v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text,
v.net_weight_g::text, v.physical_spec_status,
v.package_length_mm::text, v.package_width_mm::text, v.package_height_mm::text,
@@ -400,6 +410,9 @@ export class ProductEditorService {
id: variant.listing_id,
offer_id: variant.offer_id,
status: variant.status,
pricing_draft: variant.pricing_draft,
pricing_result: variant.pricing_result,
pricing_valid: variant.pricing_valid,
selling_price: variant.selling_price,
list_price: variant.list_price,
currency_code: variant.currency_code,
@@ -1025,6 +1038,7 @@ export class ProductEditorService {
WHERE product_id = $1 AND deleted_at IS NULL AND NOT (id = ANY($2::uuid[]))`,
[productId, retainedIds],
);
await this.pricing.persistDraft(transaction, workspaceId, userId, profile.id, input, createdVariants);
await this.persistDynamicAttributes(transaction, {
productId,
profile,
@@ -29,6 +29,8 @@ type VariantValidationRow = {
listing_status: string;
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;
@@ -283,6 +285,18 @@ export class ProductEditorValidationOperations {
field: `variants.${variant.id}.offer_id`,
message: '请填写卖家货号',
});
if (!variant.pricing_valid)
issues.push({
code: 'PRICING_REQUIRED',
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',
@@ -348,7 +362,8 @@ 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.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,
@@ -359,11 +374,12 @@ 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[]))
GROUP BY v.id, v.display_name, listing.status, listing.offer_id,
listing.selling_price, product.default_package_length_mm,
listing.selling_price, listing.pricing_valid, product.default_package_length_mm,
product.default_package_width_mm, product.default_package_height_mm,
product.default_shipping_weight_g, product.package_spec_status
ORDER BY v.sort_order, v.id`,
+294
View File
@@ -0,0 +1,294 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import { Pool } from 'pg';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication;
let db: Database;
let migrator: Pool;
let base = '',
token = '',
user = '',
workspace = '',
card = '',
platform = '';
const request = async (
path: string,
method = 'GET',
body?: unknown,
key?: string,
) => {
const response = await fetch(`${base}/api${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(key ? { 'Idempotency-Key': key } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: response.status, body: (await response.json()) as any };
};
describe('系统物流费率审核', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
app = (await createApplication()).app;
db = app.get(DATABASE);
migrator = new Pool({
connectionString: process.env.MIGRATOR_DATABASE_URL,
max: 1,
});
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const registered = await request('/auth/register', 'POST', {
email: `pricing-review-${randomUUID()}@example.test`,
password: 'pricing-review-test-password-123',
password_confirmation: 'pricing-review-test-password-123',
});
assert.equal(registered.status, 201, JSON.stringify(registered.body));
token = registered.body.data.access_token;
user = registered.body.data.user.id;
workspace = registered.body.data.workspace.id;
const scope = await db.query<{
platform_id: string;
provider_id: string;
}>(
`SELECT platform.id AS platform_id,provider.id AS provider_id FROM atlas.platforms platform CROSS JOIN atlas.logistics_providers provider WHERE upper(platform.code::text)='OZON' AND upper(provider.code::text)='CEL' LIMIT 1`,
);
platform = scope.rows[0].platform_id;
const provider = scope.rows[0].provider_id;
card = (
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from)
VALUES($1,$2,'rfbs','RU',$3,'自动化审核测试,未来生效','draft',now()) RETURNING id`,
[provider, platform, `qa-${randomUUID()}`],
)
).rows[0].id;
const methods: string[] = [];
for (const code of ['express', 'standard', 'economy']) {
methods.push(
(
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,$2::text,$2::text) RETURNING id`,
[card, code],
)
).rows[0].id,
);
}
for (const [index, code] of [
'ExtraSmall',
'Budget',
'Small',
'Big',
'PremiumSmall',
'PremiumBig',
].entries()) {
const type = (
await db.query<{ id: string }>(
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_actual_weight_kg,max_actual_weight_kg,billing_basis)
VALUES($1,$2::text,$2::text,'CNY',0,10000,0.001,30,'actual_only') RETURNING id`,
[card, code],
)
).rows[0].id;
for (const method of methods.slice(0, [3, 3, 3, 2, 3, 2][index]))
await db.query(
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status)
VALUES($1,$2,3.12,10,'draft')`,
[type, method],
);
}
});
afterAll(async () => {
if (migrator) {
if (card)
await migrator.query(
`DELETE FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
[card],
);
if (user)
await migrator.query(
`DELETE FROM atlas.system_admin_grants WHERE user_id=$1`,
[user],
);
}
if (db) {
if (card)
await db.query(
`DELETE FROM atlas.logistics_rate_cards WHERE id=$1`,
[card],
);
if (user) {
await db.query(
`DELETE FROM atlas.workspace_memberships WHERE user_id=$1`,
[user],
);
await db.query(`DELETE FROM atlas.workspaces WHERE id=$1`, [
workspace,
]);
await db.query(`DELETE FROM atlas.users WHERE id=$1`, [user]);
}
}
if (migrator) await migrator.end();
if (app) await app.close();
});
it('仅系统管理员可读取和启用;六类账单复算、重放与过期版本保护', async () => {
assert.equal(
(
await request(
`/system/pricing/rate-cards?platform_id=${platform}`,
)
).status,
403,
);
await migrator.query(
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
[user],
);
const listed = await request(
`/system/pricing/rate-cards?platform_id=${platform}`,
);
assert.equal(listed.status, 200, JSON.stringify(listed.body));
const item = listed.body.data.find((row: any) => row.id === card);
assert.equal(item.status, 'draft');
assert.equal(item.types.length, 6);
assert.equal(item.rules.length, 16);
const input = {
rules_hash: item.rules_hash,
source_reference: 'CEL 真实账单 QA 批次-2026-09',
review_note:
'逐类型核对实际包装、货值、计费重量与物流账单费用;本测试版本仅在未来生效。',
effective_from: new Date(Date.now() + 86400000).toISOString(),
samples: item.types.map((type: any, index: number) => ({
rule_id: item.rules.find(
(rule: any) => rule.product_type_id === type.id,
).id,
invoice_reference: `QA-INVOICE-${index + 1}`,
merchandise_value_cny: '100',
shipping_weight_g: '1000',
length_mm: '100',
width_mm: '100',
height_mm: '100',
billed_logistics_cny: '13.12',
})),
};
const key = randomUUID();
const mismatch = structuredClone(input);
mismatch.samples[0].billed_logistics_cny = '12.00';
const rejected = await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
mismatch,
randomUUID(),
);
assert.equal(rejected.status, 422, JSON.stringify(rejected.body));
const repeatedInvoice = structuredClone(input);
repeatedInvoice.samples[1].invoice_reference =
repeatedInvoice.samples[0].invoice_reference;
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
repeatedInvoice,
randomUUID(),
)
).status,
422,
);
assert.equal(
(await request(`/system/pricing/rate-cards/${card}`)).body.data
.status,
'draft',
);
const [accepted, concurrentReplay] = await Promise.all([
request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
),
request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
),
]);
assert.equal(accepted.status, 201, JSON.stringify(accepted.body));
assert.equal(
concurrentReplay.status,
201,
JSON.stringify(concurrentReplay.body),
);
assert.equal(accepted.body.data.status, 'active');
assert.equal(accepted.body.data.review.samples.length, 6);
const replay = await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
key,
);
assert.equal(replay.status, 201, JSON.stringify(replay.body));
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
mismatch,
key,
)
).status,
409,
);
assert.equal(
(
await request(
`/system/pricing/rate-cards/${card}/reviews`,
'POST',
input,
randomUUID(),
)
).status,
409,
);
const activeRuleCount = await db.query<{ count: string }>(
`SELECT count(*) FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id WHERE t.rate_card_id=$1 AND r.status='active'`,
[card],
);
assert.equal(Number(activeRuleCount.rows[0].count), 16);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_rate_rules SET fixed_fee_cny=4 WHERE id=$1`,
[item.rules[0].id],
),
);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_rate_cards SET source_name='错误改写' WHERE id=$1`,
[card],
),
);
const draftCard = await db.query<{ id: string }>(
`SELECT id FROM atlas.logistics_rate_cards WHERE version='cel-rfbs-review-2026-09' LIMIT 1`,
);
await assert.rejects(() =>
db.query(
`UPDATE atlas.logistics_product_types SET rate_card_id=$1 WHERE id=$2`,
[draftCard.rows[0].id, item.types[0].id],
),
);
});
});
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { describe, it } from 'vitest';
import { draftPricingEnabled } from '../src/config/app-config.module.js';
describe('草稿费率开发模式边界', () => {
it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => {
assert.equal(draftPricingEnabled('development', 'auto'), true);
assert.equal(draftPricingEnabled('development', 'false'), false);
assert.equal(draftPricingEnabled('test', 'auto'), false);
assert.equal(draftPricingEnabled('test', 'true'), true);
});
it('生产环境即使误设开关也不能使用草稿费率', () => {
assert.equal(draftPricingEnabled('production', 'true'), false);
assert.equal(draftPricingEnabled('production', 'auto'), false);
});
});
+243
View File
@@ -0,0 +1,243 @@
import { describe, it, expect } from 'vitest';
import {
calculatePricing,
type LogisticsRule,
type CommissionRule,
} from '../src/pricing/pricing-engine.js';
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
const id = 'ad877f74-36d0-4ef9-a711-cd904b37912f';
const input = () =>
pricingPreviewRequestSchema.parse({
store_id: id,
platform_product_category_id: id,
fulfillment: 'rfbs',
purchase_cost_cny: '28',
package: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
pricing: {
rate_card_id: id,
transport_method_ids: [id],
default_transport_method_id: id,
domestic_shipping_and_label_fee_cny: '3',
ad_rate: '0.03',
last_mile_rate: '0.02',
withdrawal_rate: '0.01',
other_rate: '0.02',
desired_markup_rate: '0.5',
list_price_discount_rate: '0.2',
},
});
const rule: LogisticsRule = {
id,
product_type_id: id,
transport_method_id: id,
transport_name: '标准',
product_type_name: 'ExtraSmall',
fixed_fee_cny: '3.12',
per_billable_kg_fee_cny: '36.4',
min_charge_cny: null,
surcharge_rules: [],
merchandise_currency_code: 'CNY',
min_merchandise_value: null,
max_merchandise_value: '135',
min_value_inclusive: true,
max_value_inclusive: true,
min_actual_weight_kg: '0.001',
min_actual_weight_inclusive: true,
max_actual_weight_kg: '0.5',
max_sum_dimensions_cm: '90',
max_longest_side_cm: '60',
max_second_longest_side_cm: null,
billing_basis: 'actual_only',
volume_divisor: null,
volume_condition_sum_gt_cm: null,
volume_round_scale: null,
billing_round_scale: null,
max_volume_weight_kg: null,
volume_limit_round_scale: null,
};
const commission: CommissionRule = {
id,
currency_code: 'RUB',
min_price: null,
max_price: null,
min_inclusive: true,
max_inclusive: true,
commission_rate: '0.12',
version: 'test',
};
describe('十进制定价引擎', () => {
it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => {
const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission])
.options[0];
expect(c.selling_price).toBe('77.44');
expect(c.profit_cny).toBe('14.00');
expect(c.list_price).toBe('96.80');
expect(c.fees.logistics_cny).toBe('16.9520');
const rub = calculatePricing(
input(),
'RUB',
'12.3',
[rule],
[commission],
).options[0];
expect(rub.selling_price).toBe('952.52');
expect(rub.can_confirm).toBe(true);
});
it('固定售价保持原价并识别亏损', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '10';
const o = calculatePricing(i, 'CNY', '12.3', [rule], [commission])
.options[0];
expect(o.selling_price).toBe('10.00');
expect(o.can_confirm).toBe(false);
expect(Number(o.profit_cny)).toBeLessThan(0);
});
it('实际重、尺寸和开区间边界精确生效', () => {
const i = input();
i.package.shipping_weight_g = '500';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(1);
i.package.shipping_weight_g = '500.001';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(0);
i.package.shipping_weight_g = '380';
expect(
calculatePricing(
i,
'CNY',
'12.3',
[
{
...rule,
min_actual_weight_kg: '0.380',
min_actual_weight_inclusive: false,
},
],
[commission],
).options,
).toHaveLength(0);
});
it('先舍入售价再检查货值及佣金档,不跨档蒙混', () => {
const i = input();
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...rule, max_merchandise_value: '77.439' }],
[commission],
).options,
).toHaveLength(0);
expect(
calculatePricing(
i,
'CNY',
'12.3',
[rule],
[{ ...commission, max_price: '952.51' }],
).options,
).toHaveLength(0);
});
it('总费率达到 100% 拒绝,未知附加费不能漏算', () => {
const i = input();
i.pricing.ad_rate = '0.9';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
).toHaveLength(0);
expect(
calculatePricing(
input(),
'CNY',
'12.3',
[{ ...rule, surcharge_rules: [{ unknown: true }] }],
[commission],
).options,
).toHaveLength(0);
});
it('体积重按指定位数进位并检查进位后的上限', () => {
const i = input();
i.package = {
shipping_weight_g: '5000',
length_mm: '500',
width_mm: '400',
height_mm: '301',
status: 'confirmed',
};
const volume = {
...rule,
max_actual_weight_kg: '30',
max_sum_dimensions_cm: '310',
billing_basis: 'max_actual_volume',
volume_divisor: '12000',
volume_round_scale: 3,
max_volume_weight_kg: '6',
volume_limit_round_scale: 0,
};
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...volume, max_merchandise_value: null }],
[commission],
).options[0].billable_weight_kg,
).toBe('5.017');
expect(
calculatePricing(
i,
'CNY',
'12.3',
[{ ...volume, max_volume_weight_kg: '5' }],
[commission],
).options,
).toHaveLength(0);
});
it('默认运输失效时不偷偷选择其他方式', () => {
const i = input();
i.pricing.default_transport_method_id =
'8d6df67c-1fa3-45b9-9766-d4f5c813b903';
expect(
calculatePricing(i, 'CNY', '12.3', [rule], [commission])
.recommended_option_id,
).toBeNull();
});
it('格式化金额与 JSONB 字段重排不会使同一输入失效', () => {
const original = input();
const reordered = JSON.parse(
JSON.stringify(original, (_key, value) =>
value && typeof value === 'object' && !Array.isArray(value)
? Object.fromEntries(Object.entries(value).reverse())
: value,
),
);
reordered.purchase_cost_cny = '28.00';
reordered.package.shipping_weight_g = '380.000';
reordered.pricing.ad_rate = '0.030000';
expect(pricingInputKey(original)).toBe(pricingInputKey(reordered));
reordered.package.length_mm = '221';
expect(pricingInputKey(original)).not.toBe(pricingInputKey(reordered));
});
it('显示利润舍入为零时,真实微亏仍禁止确认', () => {
const i = input();
i.pricing.mode = 'fixed_sale';
i.pricing.selling_price = '59.94';
const option = calculatePricing(
i,
'CNY',
'12.3',
[{ ...rule, fixed_fee_cny: '3.121' }],
[commission],
).options[0];
expect(option.profit_cny).toBe('-0.00');
expect(option.can_confirm).toBe(false);
});
});
+653
View File
@@ -0,0 +1,653 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import type { AddressInfo } from 'node:net';
import type { Database } from '@atlas/database';
import type { NestExpressApplication } from '@nestjs/platform-express';
import type {
PricingPreviewRequest,
ProductEditorSnapshot,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { beforeAll, afterAll, it, describe } from 'vitest';
import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js';
let app: NestExpressApplication,
db: Database,
base = '',
workspace = '',
user = '',
token = '',
platform = '',
store = '',
category = '',
product = '',
profile = '',
provider = '',
card = '',
transport = '',
variant = '';
async function request(
path: string,
method = 'GET',
body?: unknown,
extra: Record<string, string> = {},
) {
const r = await fetch(base + '/api' + path, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(method !== 'GET' ? { 'Idempotency-Key': randomUUID() } : {}),
...extra,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await r.json()) as any;
return { status: r.status, ...data };
}
async function read(): Promise<ProductEditorSnapshot> {
const r = await request(`/products/${product}/editor`, 'GET', undefined, {
'X-Atlas-Platform-Id': platform,
});
assert.equal(r.status, 200, JSON.stringify(r));
return r.data;
}
function draft(s: ProductEditorSnapshot): SaveProductEditorDraftRequest {
return {
ai_source_material: s.source_material,
ai_source_overridden_fields: s.source_material_state.overridden_fields,
common: {
...s.product.common,
default_physical_spec: s.product.default_physical_spec,
default_package_spec: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
},
profile: {
target_store_id: store,
fulfillment: 'rfbs',
title: s.profile.title,
description: s.profile.description,
tags: s.profile.tags,
platform_product_category_id: category,
category_source: 'manual',
},
variants: s.variants.map((v) => ({
id: v.id,
client_key: null,
display_name: v.display_name,
source_sku: v.source_sku,
purchase_cost_cny: '28.00',
specification_values: v.specification_values,
title: v.title,
tags: v.tags,
description: v.description,
physical_spec: v.physical_spec,
package_spec: v.package_spec,
offer_id: v.listing.offer_id,
media_asset_ids: [],
pricing_draft: v.listing.pricing_draft ?? null,
})),
};
}
function quote(
variantId: string | null = variant,
clientKey: string | null = null,
): PricingPreviewRequest {
return pricingPreviewRequestSchema.parse({
store_id: store,
platform_product_category_id: category,
fulfillment: 'rfbs',
binding: {
profile_id: profile,
variant_id: variantId,
client_key: clientKey,
},
purchase_cost_cny: '28',
package: {
shipping_weight_g: '380',
length_mm: '220',
width_mm: '160',
height_mm: '90',
status: 'confirmed',
},
pricing: {
rate_card_id: card,
transport_method_ids: [transport],
default_transport_method_id: transport,
domestic_shipping_and_label_fee_cny: '3',
ad_rate: '0.03',
last_mile_rate: '0.02',
withdrawal_rate: '0.01',
other_rate: '0.02',
desired_markup_rate: '0.5',
list_price_discount_rate: '0.2',
temporary_commission_rate: '0.12',
fx: {
mode: 'one_time_override',
rate: '12.3',
reason: '仅集成测试',
},
},
});
}
async function save(
s: ProductEditorSnapshot,
d: SaveProductEditorDraftRequest,
key = randomUUID(),
) {
return request(`/products/${product}/editor-draft`, 'PUT', d, {
'X-Atlas-Platform-Id': platform,
'If-Match': `"${s.product.revision}:${s.profile.revision}"`,
'Idempotency-Key': key,
});
}
describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
beforeAll(async () => {
Object.assign(process.env, {
NODE_ENV: 'test',
PRICING_ALLOW_DRAFT_RATES: 'true',
SWAGGER_ENABLED: 'false',
AUTH_REGISTER_IP_LIMIT: '100',
AUTH_REGISTER_EMAIL_LIMIT: '100',
});
app = (await createApplication()).app;
db = app.get(DATABASE);
await app.listen(0, '127.0.0.1');
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
const reg = await request('/auth/register', 'POST', {
email: `pricing-${randomUUID()}@example.test`,
password: 'pricing-test-password-123',
password_confirmation: 'pricing-test-password-123',
});
assert.equal(reg.status, 201, JSON.stringify(reg));
workspace = reg.data.workspace.id;
user = reg.data.user.id;
token = reg.data.access_token;
platform = (await request('/platforms')).data[0].id;
await db.workspaceTransaction(workspace, async (tx) => {
store = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'定价集成测试',$3,'CNY',true,'active','valid') RETURNING id`,
[workspace, platform, randomUUID()],
)
).rows[0].id;
category = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'test','测试类目','定价测试') RETURNING id`,
[platform, randomUUID()],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope,scope_key,category_id,last_success_at,is_complete) VALUES($1,'attributes','attributes:' || $2::uuid,$2,now(),true)`,
[platform, category],
);
provider = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_providers(code,name) VALUES($1,'仅自动化测试') RETURNING id`,
[randomUUID()],
)
).rows[0].id;
card = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,version,source_name,status,effective_from,reviewed_at,reviewed_by_user_id) VALUES($1,$2,'rfbs','test-1','测试规则,不用于业务','active',now()-interval '1 day',now(),$3) RETURNING id`,
[provider, platform, user],
)
).rows[0].id;
transport = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,'standard','标准测试') RETURNING id`,
[card],
)
).rows[0].id;
const type = (
await tx.query<{ id: string }>(
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,max_merchandise_value,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,billing_basis) VALUES($1,'ExtraSmall','小包测试','CNY',135,0.5,90,60,'actual_only') RETURNING id`,
[card],
)
).rows[0].id;
await tx.query(
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny) VALUES($1,$2,3.12,36.4)`,
[type, transport],
);
});
const collection = await request('/collection-items', 'POST', {
title: '定价集成测试商品',
});
assert.equal(collection.status, 201);
product = collection.data.product_id;
const context = await request(
`/collection-items/${collection.data.collection_item.id}/editor-contexts`,
'POST',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(context.status, 201, JSON.stringify(context));
profile = context.data.profile_id;
variant = (await read()).variants[0].id;
});
afterAll(async () => {
if (db && workspace) {
await db.workspaceTransaction(workspace, async (tx) => {
// 仅清理本测试创建的租户,按外键依赖顺序删除。
for (const table of [
'idempotency_requests',
'pricing_previews',
'product_variant_listing_media',
'product_variant_platform_listings',
'pricing_quote_options',
'pricing_quotes',
'pricing_policy_transport_methods',
'pricing_policies',
'product_unmapped_attributes',
'product_attributes',
'product_variants',
'product_platform_profiles',
'products',
'collection_items',
'outbox_events',
'operations',
'stores',
])
await tx.query(
`DELETE FROM atlas.${table} WHERE workspace_id=$1`,
[workspace],
);
});
if (card)
await db.query(
'DELETE FROM atlas.logistics_rate_cards WHERE id=$1',
[card],
);
if (provider)
await db.query(
'DELETE FROM atlas.logistics_providers WHERE id=$1',
[provider],
);
if (category) {
await db.query(
'DELETE FROM atlas.platform_catalog_resource_states WHERE category_id=$1',
[category],
);
await db.query(
'DELETE FROM atlas.platform_product_categories WHERE id=$1',
[category],
);
}
await db.query(
'DELETE FROM atlas.workspace_memberships WHERE user_id=$1',
[user],
);
await db.query('DELETE FROM atlas.workspaces WHERE id=$1', [
workspace,
]);
await db.query('DELETE FROM atlas.users WHERE id=$1', [user]);
}
if (app) await app.close();
});
it('目录只返回审核规则,预览幂等且独立试算不写商品', async () => {
const catalog = await request(`/pricing/catalog?store_id=${store}`);
assert.equal(catalog.status, 200, JSON.stringify(catalog));
assert.ok(catalog.data.rate_cards.some((c: any) => c.id === card));
const categories = await request(
`/pricing/categories?store_id=${store}&q=定价测试`,
);
assert.equal(categories.status, 200, JSON.stringify(categories));
const input = quote(),
key = randomUUID(),
a = await request('/pricing/quote-previews', 'POST', input, {
'Idempotency-Key': key,
}),
b = await request('/pricing/quote-previews', 'POST', input, {
'Idempotency-Key': key,
});
assert.equal(a.status, 201, JSON.stringify(a));
assert.deepEqual(b.data, a.data);
assert.equal(a.data.options[0].selling_price, '77.44');
assert.equal((await read()).variants[0].listing.selling_price, null);
});
it('原子确认、重放、重读与同输入再保存保持快照', async () => {
const s = await read(),
d = draft(s),
input = quote(),
p = await request('/pricing/quote-previews', 'POST', input);
d.variants[0].pricing_draft = input.pricing;
d.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
};
const key = randomUUID(),
saved = await save(s, d, key);
assert.equal(saved.status, 200, JSON.stringify(saved));
assert.deepEqual((await save(s, d, key)).data, saved.data);
const after = await read();
assert.equal(after.variants[0].listing.pricing_valid, true);
assert.equal(
after.variants[0].listing.pricing_result?.profit_cny,
'14.00',
);
assert.equal(Number(after.variants[0].listing.selling_price), 77.44);
const again = await save(after, draft(after));
assert.equal(again.status, 200, JSON.stringify(again));
assert.equal((await read()).variants[0].listing.pricing_valid, true);
});
it('输入/有效期变化返回 SKU 错误,整笔商品保存回滚;无报价允许保存', async () => {
const s = await read(),
d = draft(s),
input = quote(),
p = await request('/pricing/quote-previews', 'POST', input);
d.common.source_title = '不应被保存';
d.variants[0].pricing_draft = input.pricing;
d.variants[0].purchase_cost_cny = '29';
d.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
};
const failed = await save(s, d);
assert.equal(failed.status, 409, JSON.stringify(failed));
assert.equal(failed.error.code, 'PRICING_PREVIEW_STALE');
assert.equal((await read()).product.revision, s.product.revision);
d.variants[0].purchase_cost_cny = '28';
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 second' WHERE id=$1`,
[p.data.preview_id],
),
);
assert.equal((await save(s, d)).status, 409);
d.variants[0].pricing_selection = null;
d.variants[0].purchase_cost_cny = '29';
assert.equal((await save(s, d)).status, 200);
assert.equal((await read()).variants[0].listing.pricing_valid, false);
});
it('新 SKU client_key 与真实 ID 在同一事务映射', async () => {
const s = await read(),
d = draft(s),
input = quote(null, 'new-pricing'),
p = await request('/pricing/quote-previews', 'POST', input);
d.variants.push({
...d.variants[0],
id: null,
client_key: 'new-pricing',
display_name: '新报价 SKU',
pricing_draft: input.pricing,
pricing_selection: {
preview_id: p.data.preview_id,
option_id: p.data.recommended_option_id,
input_hash: p.data.input_hash,
},
});
const saved = await save(s, d);
assert.equal(saved.status, 200, JSON.stringify(saved));
const after = await read(),
newId = saved.data.created_variants[0].id;
assert.equal(
after.variants.find((v) => v.id === newId)?.listing.pricing_valid,
true,
);
});
it('其他租户不能读取预览或使用门店;报价复合外键拒绝跨 SKU 引用', async () => {
const outsider = randomUUID();
await db.workspaceTransaction(outsider, async (tx) => {
assert.equal(
(
await tx.query(
'SELECT id FROM atlas.pricing_previews WHERE store_id=$1',
[store],
)
).rowCount,
0,
);
assert.equal(
(
await tx.query(
'SELECT id FROM atlas.pricing_quotes WHERE store_id=$1',
[store],
)
).rowCount,
0,
);
});
const s = await read();
await assert.rejects(() =>
db.workspaceTransaction(workspace, async (tx) => {
await tx.query(
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=(SELECT pricing_quote_id FROM atlas.product_variant_platform_listings WHERE product_variant_id=$1) WHERE product_variant_id=$2`,
[s.variants[1].id, s.variants[0].id],
);
}),
);
});
it('方案 CRUD、版本冲突、默认与归档不改变已确认报价', async () => {
const body = {
name: '测试默认方案',
parameters: {
...quote().pricing,
fx: { mode: 'policy_fixed', rate: '12.3' },
},
allow_commission_override: true,
};
const c = await request(
`/stores/${store}/pricing-policies`,
'POST',
body,
);
assert.equal(c.status, 201, JSON.stringify(c));
const id = c.data.id;
assert.equal(
(await request(`/pricing-policies/${id}/set-default`, 'POST'))
.status,
200,
);
assert.equal(
(
await request(`/pricing-policies/${id}`, 'PATCH', body, {
'If-Match': '"1"',
})
).status,
409,
);
const before = await read();
assert.equal(
(await request(`/pricing-policies/${id}`, 'DELETE')).status,
200,
);
assert.deepEqual(
(await read()).variants[1].listing.pricing_result,
before.variants[1].listing.pricing_result,
);
});
it('规则更新、独立预览和其他 SKU 预览不能被旧确认意图接受', async () => {
const state = await read(),
payload = draft(state),
input = quote();
const preview = await request('/pricing/quote-previews', 'POST', input);
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
await db.query(
`UPDATE atlas.logistics_rate_cards SET version='test-2',updated_at=now() WHERE id=$1`,
[card],
);
assert.equal(
(await save(state, payload)).error.code,
'PRICING_PREVIEW_STALE',
);
const standalone = await request('/pricing/quote-previews', 'POST', {
...input,
binding: null,
});
payload.variants[0].pricing_selection = {
preview_id: standalone.data.preview_id,
option_id: standalone.data.recommended_option_id,
input_hash: standalone.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
const other = await request('/pricing/quote-previews', 'POST', {
...input,
binding: { ...input.binding, variant_id: state.variants[1].id },
});
payload.variants[0].pricing_selection = {
preview_id: other.data.preview_id,
option_id: other.data.recommended_option_id,
input_hash: other.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
assert.equal((await read()).product.revision, state.product.revision);
});
it('确认快照不可更新;历史预览过期不使确认失效', async () => {
const before = await read();
await assert.rejects(() =>
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_quotes SET purchase_cost_cny=0 WHERE workspace_id=$1 AND status='confirmed'`,
[workspace],
),
),
);
await assert.rejects(() =>
db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_quote_options SET profit_rub=0 WHERE workspace_id=$1`,
[workspace],
),
),
);
await db.workspaceTransaction(workspace, (tx) =>
tx.query(
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 day' WHERE workspace_id=$1`,
[workspace],
),
);
assert.deepEqual(
(await read()).variants[1].listing,
before.variants[1].listing,
);
});
it('没有当前报价的草稿保存成功,但发布校验给出定价问题', async () => {
const state = await read(),
payload = draft(state);
payload.variants[0].purchase_cost_cny = '41.00';
assert.equal((await save(state, payload)).status, 200);
const validation = await request(
`/product-platform-profiles/${profile}/validation`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(validation.status, 200, JSON.stringify(validation));
assert.ok(
validation.data.variants[0].issues.some(
(i: { code: string }) => i.code === 'PRICING_REQUIRED',
),
);
});
it('并发商品保存只有一个版本成功', async () => {
const state = await read(),
a = draft(state),
b = draft(state);
a.common.source_title = '并发 A';
b.common.source_title = '并发 B';
const results = await Promise.all([save(state, a), save(state, b)]);
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]);
});
it('未审核费率、缺失正式佣金以及亏损候选不会变成发布报价', async () => {
await db.query(
`UPDATE atlas.logistics_rate_cards SET reviewed_at=NULL WHERE id=$1`,
[card],
);
assert.equal(
(await request('/pricing/quote-previews', 'POST', quote())).error
.code,
'PRICING_RULES_UNAVAILABLE',
);
await db.query(
`UPDATE atlas.logistics_rate_cards SET reviewed_at=now() WHERE id=$1`,
[card],
);
const input = quote();
input.pricing.temporary_commission_rate = null;
assert.equal(
(await request('/pricing/quote-previews', 'POST', input)).error
.code,
'PRICING_COMMISSION_UNAVAILABLE',
);
input.pricing.temporary_commission_rate = '0.12';
input.pricing.mode = 'fixed_sale';
input.pricing.selling_price = '10';
const p = await request('/pricing/quote-previews', 'POST', input);
assert.equal(p.status, 201);
assert.equal(p.data.options[0].can_confirm, false);
const state = await read(),
payload = draft(state);
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: p.data.preview_id,
option_id: p.data.options[0].id,
input_hash: p.data.input_hash,
};
assert.equal((await save(state, payload)).status, 409);
});
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],
);
await db.query(
`UPDATE atlas.logistics_rate_rules SET status='draft' WHERE product_type_id IN (SELECT id FROM atlas.logistics_product_types WHERE rate_card_id=$1)`,
[card],
);
const catalog = await request(`/pricing/catalog?store_id=${store}`);
assert.equal(catalog.status, 200, JSON.stringify(catalog));
assert.equal(
catalog.data.rate_cards.find((item: { id: string }) => item.id === card)
?.development_only,
true,
);
const input = quote();
input.purchase_cost_cny = '41.00';
const preview = await request('/pricing/quote-previews', 'POST', input);
assert.equal(preview.status, 201, JSON.stringify(preview));
assert.equal(preview.data.context.development_only, true);
const state = await read();
const payload = draft(state);
payload.variants[0].purchase_cost_cny = '41.00';
payload.variants[0].pricing_draft = input.pricing;
payload.variants[0].pricing_selection = {
preview_id: preview.data.preview_id,
option_id: preview.data.recommended_option_id,
input_hash: preview.data.input_hash,
};
const saved = await save(state, payload);
assert.equal(saved.status, 200, JSON.stringify(saved));
const validation = await request(
`/product-platform-profiles/${profile}/validation`,
'GET',
undefined,
{ 'X-Atlas-Platform-Id': platform },
);
assert.equal(validation.status, 200, JSON.stringify(validation));
assert.ok(
validation.data.variants[0].issues.some(
(issue: { code: string }) =>
issue.code === 'PRICING_RATE_REVIEW_REQUIRED',
),
);
const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>(
'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1',
[card],
);
assert.equal(rateCard.rows[0].status, 'draft');
assert.equal(rateCard.rows[0].reviewed_at, null);
});
});
@@ -29,6 +29,7 @@ describe('SKU 图片处理结果的保存资格', () => {
const service = new ProductEditorService(
{} as ConstructorParameters<typeof ProductEditorService>[0],
{} as ConstructorParameters<typeof ProductEditorService>[1],
{ pricingAllowDraftRates: false } as ConstructorParameters<typeof ProductEditorService>[2],
) as unknown as {
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
};
@@ -0,0 +1,676 @@
import { useEffect, useRef, useState } from 'react';
import {
App,
Modal,
Alert,
Button,
Card,
Col,
Collapse,
Descriptions,
Empty,
Input,
InputNumber,
Radio,
Row,
Select,
Space,
Statistic,
Tag,
Typography,
} from 'antd';
import type {
PricingCatalog,
PricingOption,
PricingParameters,
PricingPolicy,
PricingPreview,
PricingPreviewRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing';
import { apiErrorMessage } from '@/services/api';
import { percentToRate, rateToPercent } from './pricingPercentage';
export function defaultPricing(catalog: PricingCatalog): PricingParameters | null {
const policy = catalog.policies.find(
(p) =>
p.is_default && catalog.rate_cards.some((card) => card.id === p.parameters.rate_card_id)
);
if (policy) return structuredClone(policy.parameters);
const card = catalog.rate_cards[0];
if (!card || !card.transports.length) return null;
return {
pricing_policy_id: null,
rate_card_id: card.id,
transport_method_ids: card.transports.map((t) => t.id),
default_transport_method_id: card.transports[0].id,
domestic_shipping_and_label_fee_cny: '0',
ad_rate: '0',
last_mile_rate: '0',
withdrawal_rate: '0',
other_rate: '0',
desired_markup_rate: '0.5',
list_price_discount_rate: '0',
mode: 'target_markup',
selling_price: null,
fx: { mode: 'system' },
temporary_commission_rate: null,
};
}
export function PricingParametersFields({
value: p,
onChange,
catalog,
policyEditing = false,
}: {
policyEditing?: boolean;
value: PricingParameters;
onChange: (p: PricingParameters) => void;
catalog: PricingCatalog;
}) {
const patch = (v: Partial<PricingParameters>) => onChange({ ...p, ...v });
const rates: Array<[keyof PricingParameters, string]> = [
['desired_markup_rate', '目标加价率'],
['list_price_discount_rate', '划线折扣率'],
['ad_rate', '广告费率'],
['last_mile_rate', '尾程费率'],
['withdrawal_rate', '提现费率'],
['other_rate', '其他费率'],
];
return (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text type="secondary">定价方案</Typography.Text>
<Select
aria-label="定价方案"
style={{ width: '100%' }}
value={p.pricing_policy_id}
placeholder="本次自定义参数"
allowClear
options={catalog.policies.map((x) => ({
value: x.id,
label: x.name + (x.is_default ? ' · 默认' : ''),
}))}
onChange={(id) => {
const selected = catalog.policies.find((x) => x.id === id);
if (selected)
onChange({
...structuredClone(selected.parameters),
mode: p.mode,
selling_price: p.selling_price,
});
else patch({ pricing_policy_id: null });
}}
/>
</div>
<div>
<Typography.Text type="secondary">物流费率</Typography.Text>
<Select
aria-label="物流费率"
style={{ width: '100%' }}
value={p.rate_card_id}
options={catalog.rate_cards.map((x) => ({
value: x.id,
label: `${x.name} · ${x.version}${x.development_only ? ' · 开发草稿' : ''}`,
}))}
onChange={(id) => {
const c = catalog.rate_cards.find((x) => x.id === id)!;
patch({
rate_card_id: id,
pricing_policy_id: null,
transport_method_ids: c.transports.map((t) => t.id),
default_transport_method_id: c.transports[0]?.id ?? null,
});
}}
/>
</div>
<Row gutter={[12, 12]}>
{rates.map(([key, label]) => (
<Col span={12} key={key}>
<Typography.Text type="secondary">{label}(%)</Typography.Text>
<InputNumber<string>
aria-label={label}
style={{ width: '100%' }}
min="0"
max={key === 'desired_markup_rate' ? '999' : '99.99'}
step="1"
precision={2}
addonAfter="%"
stringMode
value={rateToPercent(p[key] as string)}
onChange={(v) => patch({ [key]: percentToRate(v ?? '0') })}
/>
</Col>
))}
</Row>
<Typography.Text type="secondary">
目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。
</Typography.Text>
<Collapse
items={[
{
key: 'fees',
label: '国内费用、汇率与临时佣金',
children: (
<Space orientation="vertical" style={{ width: '100%' }}>
<label>
国内运费 / 贴单(CNY)
<InputNumber<string>
aria-label="国内运费"
stringMode
min="0"
value={p.domestic_shipping_and_label_fee_cny}
onChange={(v) =>
patch({ domestic_shipping_and_label_fee_cny: v ?? '0' })
}
/>
</label>
<Select
aria-label="汇率来源"
style={{ width: '100%' }}
value={p.fx.mode}
options={[
{
value: 'system',
label: `CBR 系统汇率${catalog.exchange_rate ? ` · ${catalog.exchange_rate.rate} · ${catalog.exchange_rate.effective_on}` : ' · 暂无数据'}`,
},
{ value: 'policy_fixed', label: '方案固定汇率' },
...(!policyEditing
? [
{
value: 'one_time_override',
label: '本次临时汇率',
},
]
: []),
]}
onChange={(mode) =>
patch({
fx: { mode, rate: p.fx.rate, reason: p.fx.reason },
})
}
/>
{p.fx.mode !== 'system' && (
<>
<InputNumber<string>
aria-label="CNY 兑 RUB 汇率"
stringMode
min="0.00000001"
value={p.fx.rate}
disabled={
p.fx.mode === 'policy_fixed' && !policyEditing
}
onChange={(v) =>
patch({ fx: { ...p.fx, rate: v ?? undefined } })
}
placeholder="1 CNY = ? RUB"
/>
{p.fx.mode === 'one_time_override' && (
<Input
aria-label="汇率覆盖原因"
value={p.fx.reason}
placeholder="本次覆盖原因"
onChange={(e) =>
patch({
fx: { ...p.fx, reason: e.target.value },
})
}
/>
)}
</>
)}
<label>
临时佣金率(%,仅缺少正式规则时)
<InputNumber<string>
aria-label="临时佣金率"
stringMode
min="0"
max="99.99"
step="1"
precision={2}
addonAfter="%"
value={p.temporary_commission_rate === null ? null : rateToPercent(p.temporary_commission_rate)}
onChange={(v) => patch({ temporary_commission_rate: v === null ? null : percentToRate(v) })}
/>
</label>
</Space>
),
},
]}
/>
<div>
<Typography.Text type="secondary">默认运输方式</Typography.Text>
<Select
aria-label="默认运输方式"
style={{ width: '100%' }}
value={p.default_transport_method_id}
options={catalog.rate_cards
.find((c) => c.id === p.rate_card_id)
?.transports.map((t) => ({ value: t.id, label: t.name }))}
onChange={(id) =>
patch({
default_transport_method_id: id,
transport_method_ids: [...new Set([...p.transport_method_ids, id])],
})
}
/>
</div>
</Space>
);
}
export function PricingCalculator({
initial,
catalog,
onApply,
onCancel,
onPolicySaved,
previous,
}: {
previous?: PricingOption | null;
initial: PricingPreviewRequest;
catalog: PricingCatalog;
onApply?: (input: PricingPreviewRequest, preview: PricingPreview, optionId: string) => void;
onCancel?: () => void;
onPolicySaved?: (policy: PricingPolicy) => void;
}) {
const { message } = App.useApp();
const [policyName, setPolicyName] = useState<string | null>(null);
const [policySaving, setPolicySaving] = useState(false);
const [localCatalog, setLocalCatalog] = useState(catalog);
const [input, setInput] = useState(() => structuredClone(initial));
const [preview, setPreview] = useState<PricingPreview | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const sequence = useRef(0);
const [refresh, setRefresh] = useState(0);
const [now, setNow] = useState(Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const seq = ++sequence.current,
controller = new AbortController();
setPreview(null);
setError('');
setBusy(false);
const parsed = pricingPreviewRequestSchema.safeParse(input);
if (!parsed.success) {
setError(parsed.error.issues.map((i) => i.message).join(';'));
return () => controller.abort();
}
const timer = setTimeout(() => {
setBusy(true);
void pricingApi
.preview(parsed.data, controller.signal)
.then((p) => {
if (sequence.current === seq) {
setPreview(p);
setSelected(p.recommended_option_id);
}
})
.catch((e) => {
if (sequence.current === seq && !controller.signal.aborted)
setError(apiErrorMessage(e));
})
.finally(() => {
if (sequence.current === seq) setBusy(false);
});
}, 400);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [input, refresh]);
const option = preview?.options.find((o) => o.id === selected),
expired = !!preview && Date.parse(preview.expires_at) <= now;
return (
<div className="pricing-calculator">
<Modal
title="将当前参数另存为门店方案"
open={policyName !== null}
confirmLoading={policySaving}
onCancel={() => setPolicyName(null)}
onOk={async () => {
setPolicySaving(true);
try {
const p = input.pricing;
const saved = await pricingApi.createPolicy(input.store_id, {
name: policyName ?? '',
note: null,
allow_commission_override: !!localCatalog.policies.find(
(x) => x.id === p.pricing_policy_id
)?.allow_commission_override,
parameters: {
...p,
pricing_policy_id: null,
mode: 'target_markup',
selling_price: null,
temporary_commission_rate: null,
fx:
p.fx.mode === 'one_time_override'
? { mode: 'policy_fixed', rate: p.fx.rate }
: p.fx,
},
});
setLocalCatalog({
...localCatalog,
policies: [...localCatalog.policies, saved],
});
setPolicyName(null);
onPolicySaved?.(saved);
message.success('已保存方案;可在方案列表中选择,当前报价未改变');
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setPolicySaving(false);
}
}}
>
<Input
aria-label="另存方案名称"
placeholder="方案名称"
value={policyName ?? ''}
onChange={(e) => setPolicyName(e.target.value)}
/>
<Typography.Paragraph type="secondary">
保存费用、加价、折扣和运输偏好。采购价、包装、手动售价及临时佣金不保存。
{input.pricing.fx.mode === 'one_time_override'
? '本次汇率将保存为方案固定汇率。'
: ''}
</Typography.Paragraph>
</Modal>
<Row gutter={[24, 24]}>
<Col xs={24} lg={11}>
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text strong>
{catalog.store.name} · {catalog.store.currency_code}
</Typography.Text>
<label>
采购价(CNY)
<InputNumber<string>
aria-label="采购价"
style={{ width: '100%' }}
stringMode
min="0"
precision={2}
value={input.purchase_cost_cny}
onChange={(v) => setInput({ ...input, purchase_cost_cny: v ?? '' })}
/>
</label>
<Card
size="small"
title={onApply ? '有效包装 · 修改后应用为此 SKU 的覆盖值' : '商品包装'}
>
{input.package.status !== 'confirmed' && (
<Alert
type="warning"
title="包装规格尚未确认"
action={
<Button
onClick={() =>
setInput({
...input,
package: {
...input.package,
status: 'confirmed',
},
})
}
>
核对后确认包装
</Button>
}
/>
)}
<Row gutter={[8, 8]}>
{(
[
['shipping_weight_g', '重量 g'],
['length_mm', '长 mm'],
['width_mm', '宽 mm'],
['height_mm', '高 mm'],
] as const
).map(([k, label]) => (
<Col span={12} key={k}>
<label>
{label}
<InputNumber<string>
aria-label={label}
style={{ width: '100%' }}
stringMode
min="0.001"
value={input.package[k]}
onChange={(v) =>
setInput({
...input,
package: { ...input.package, [k]: v ?? '' },
})
}
/>
</label>
</Col>
))}
</Row>
</Card>
<Radio.Group
value={input.pricing.mode}
onChange={(e) =>
setInput({
...input,
pricing: {
...input.pricing,
mode: e.target.value,
selling_price:
e.target.value === 'target_markup'
? null
: input.pricing.selling_price,
},
})
}
options={[
{ value: 'target_markup', label: '按目标算价' },
{ value: 'fixed_sale', label: '手动售价' },
]}
/>
{input.pricing.mode === 'fixed_sale' && (
<InputNumber<string>
aria-label="手动售价"
stringMode
min="0.01"
value={input.pricing.selling_price}
onChange={(v) =>
setInput({
...input,
pricing: { ...input.pricing, selling_price: v },
})
}
placeholder={`销售价 ${catalog.store.currency_code}`}
/>
)}
<PricingParametersFields
value={input.pricing}
catalog={localCatalog}
onChange={(pricing) => setInput({ ...input, pricing })}
/>
</Space>
</Col>
<Col xs={24} lg={13}>
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
{previous && (
<Alert
type="info"
title={`上次报价:${previous.selling_price} ${previous.currency_code} · 预估净利润 ¥${previous.profit_cny}`}
description={
option
? `本次报价:${option.selling_price} ${option.currency_code} · 预估净利润 ¥${option.profit_cny}。点击应用才接受本次结果。`
: '重新试算后可对比价格与利润。'
}
/>
)}
<Typography.Title level={4} style={{ margin: 0 }}>
试算结果 {busy && <Tag color="processing">计算中</Tag>}
</Typography.Title>
{error && <Alert type="warning" title={error} />}
{expired && (
<Alert
type="warning"
title="预览已过期,请重新计算"
action={
<Button onClick={() => setRefresh((x) => x + 1)}>
重新计算
</Button>
}
/>
)}
{option ? (
<>
<Card style={{ background: '#f7f5ff' }}>
<Row gutter={12}>
<Col span={12}>
<Statistic
title={`销售价 · ${option.currency_code}`}
value={option.selling_price}
precision={2}
/>
<Typography.Text type="secondary">
划线价 {option.list_price}
</Typography.Text>
</Col>
<Col span={12}>
<Statistic
title="预估净利润 · CNY"
value={option.profit_cny}
precision={2}
styles={{
content: {
color:
Number(option.profit_cny) < 0
? '#cf1322'
: '#389e0d',
},
}}
/>
<Typography.Text type="secondary">
占销售额{' '}
{rateToPercent(option.margin_rate)}%
</Typography.Text>
</Col>
</Row>
</Card>
{option.warnings.map((w) => (
<Alert key={w} title={w} type="warning" />
))}
<Descriptions
title="费用明细 · CNY"
size="small"
column={1}
items={Object.entries(option.fees).map(([k, v]) => ({
key: k,
label: (
{
logistics_cny: '跨境物流',
domestic_cny: '国内运费 / 贴单',
commission_cny: '平台佣金',
ad_cny: '广告',
last_mile_cny: '尾程',
withdrawal_cny: '提现',
other_cny: '其他',
} as Record<string, string>
)[k],
children: v,
}))}
/>
</>
) : (
<Empty
description={
preview?.options.length
? '请选择运输方案'
: '填好资料后自动试算'
}
/>
)}
{preview && (
<>
<Typography.Text strong>运输方案比较</Typography.Text>
<Radio.Group
value={selected}
onChange={(e) => setSelected(e.target.value)}
style={{ width: '100%' }}
>
<Space orientation="vertical" style={{ width: '100%' }}>
{preview.options.map((o) => (
<Radio key={o.id} value={o.id}>
{o.transport_name} · {o.product_type_name} 
{o.selling_price} {o.currency_code} 利润 ¥
{o.profit_cny}
{o.id === preview.options[0]?.id && (
<Tag>最低售价</Tag>
)}
{o.id === preview.recommended_option_id && (
<Tag color="purple">方案默认</Tag>
)}
</Radio>
))}
</Space>
</Radio.Group>
{preview.unavailable_options.map((u) => (
<Typography.Paragraph
key={u.transport_method_id}
type="secondary"
>
{
catalog.rate_cards
.flatMap((c) => c.transports)
.find((t) => t.id === u.transport_method_id)?.name
}
:{u.reason}
</Typography.Paragraph>
))}
<Typography.Text type="secondary">
规则 {preview.context.rule_version} · 汇率{' '}
{preview.context.fx_rate}(
{(
{
system_rate: 'CBR 官方参考',
policy_fixed: '方案固定',
quote_override: '本次覆盖',
} as Record<string, string>
)[preview.context.fx_origin] ?? preview.context.fx_origin}
)
</Typography.Text>
{preview.context.warnings.map((w) => (
<Alert key={w} type="warning" title={w} />
))}
</>
)}
<Space wrap>
<Button onClick={() => setPolicyName('')}>参数另存方案</Button>
{onCancel && <Button onClick={onCancel}>取消</Button>}
<Button loading={busy} onClick={() => setRefresh((x) => x + 1)}>
重新计算
</Button>
{onApply && (
<Button
type="primary"
disabled={!option || expired || busy || !option.can_confirm}
onClick={() => onApply(input, preview!, selected!)}
>
应用到 SKU
</Button>
)}
</Space>
{onApply && (
<Typography.Text type="secondary">
应用后仍需保存商品,才会确认报价。
</Typography.Text>
)}
</Space>
</Col>
</Row>
</div>
);
}
@@ -0,0 +1,447 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Form, InputNumber, Modal, Space, Tag, Typography } from 'antd';
import type { FormInstance } from 'antd';
import type {
PricingCatalog,
PricingOption,
PricingParameters,
PricingPreview,
PricingPreviewRequest,
ProductEditorSnapshot,
SaveProductEditorDraftRequest,
} from '@atlas/contracts';
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
import { pricingApi } from '@/services/pricing';
import { apiErrorMessage } from '@/services/api';
import { defaultPricing, PricingCalculator } from './PricingCalculator';
type Values = SaveProductEditorDraftRequest;
export function skuPricingInput(
values: Values,
index: number,
profileId: string,
parameters: PricingParameters
): PricingPreviewRequest {
const v = values.variants[index],
base = values.common.default_package_spec,
pkg = v.package_spec;
return {
store_id: values.profile.target_store_id,
platform_product_category_id: values.profile.platform_product_category_id ?? '',
fulfillment: values.profile.fulfillment as 'rfbs',
binding: {
profile_id: profileId,
variant_id: v.id,
client_key: v.id ? null : v.client_key,
},
purchase_cost_cny: v.purchase_cost_cny ?? '',
package: {
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
length_mm: pkg.length_mm ?? base.length_mm ?? '',
width_mm: pkg.width_mm ?? base.width_mm ?? '',
height_mm: pkg.height_mm ?? base.height_mm ?? '',
status: (pkg.status ?? base.status) as 'confirmed',
},
pricing: parameters,
};
}
const keyFor = pricingInputKey;
export function SkuPricing({
form,
index,
snapshot,
auto,
batch,
onChanged,
}: {
form: FormInstance<Values>;
index: number;
snapshot: ProductEditorSnapshot;
auto: boolean;
batch: number;
onChanged: () => void;
}) {
Form.useWatch('variants', form);
Form.useWatch('common', form);
Form.useWatch('profile', form);
const values = form.getFieldsValue(true) as Values,
v = values.variants[index],
original = snapshot.variants.find((x) => x.id === v?.id);
const [catalog, setCatalog] = useState<PricingCatalog | null>(null),
[error, setError] = useState(''),
[busy, setBusy] = useState(false),
[result, setResult] = useState<{
inputKey: string;
preview: PricingPreview;
option: PricingOption;
} | null>(null),
[modal, setModal] = useState<PricingPreviewRequest | null>(null);
const restoreInheritance = useRef(false);
const sequence = useRef(0),
controller = useRef<AbortController | null>(null),
lastRequest = useRef(''),
lastBatch = useRef(batch);
const store = values.profile?.target_store_id;
useEffect(() => {
let active = true;
setCatalog(null);
setError('');
void pricingApi
.catalog(store)
.then((c) => {
if (active) setCatalog(c);
})
.catch((e) => {
if (active) setError(apiErrorMessage(e));
});
return () => {
active = false;
};
}, [store]);
useEffect(
() => () => {
controller.current?.abort();
sequence.current++;
},
[]
);
const params = v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null);
const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
const liveKey = live ? keyFor(live) : '';
const liveRef = useRef(liveKey);
liveRef.current = liveKey;
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!result) return;
const timer = setTimeout(
() => setNow(Date.now()),
Math.max(0, Date.parse(result.preview.expires_at) - Date.now()) + 20
);
return () => clearTimeout(timer);
}, [result]);
const resultValid =
(!!v?.pricing_selection || result?.option.can_confirm === false) &&
result?.inputKey === liveKey &&
Date.parse(result.preview.expires_at) > now;
const savedInput = original?.listing.pricing_draft
? {
store_id: snapshot.profile.target_store.id,
platform_product_category_id: snapshot.profile.category.id ?? '',
fulfillment: snapshot.profile.fulfillment as 'rfbs',
binding: {
profile_id: snapshot.profile.id,
variant_id: original.id,
client_key: null,
},
purchase_cost_cny: original.purchase_cost_cny ?? '',
package: {
...original.effective_package_spec,
status: original.effective_package_spec.status as 'confirmed',
},
pricing: original.listing.pricing_draft,
}
: null;
const confirmed =
!!original?.listing.pricing_valid &&
!!savedInput &&
liveKey === keyFor(savedInput as PricingPreviewRequest) &&
!v?.pricing_selection;
const option = resultValid
? result!.option
: confirmed
? original?.listing.pricing_result
: null;
const setParameters = (p: PricingParameters) => {
form.setFieldValue(['variants', index, 'pricing_draft'], p);
onChanged();
};
function apply(
input: PricingPreviewRequest,
preview: PricingPreview,
optionId: string,
copyInputs: boolean
) {
if (copyInputs) {
form.setFieldValue(['variants', index, 'purchase_cost_cny'], input.purchase_cost_cny);
if (
restoreInheritance.current &&
JSON.stringify(input.package) === JSON.stringify(modal?.package)
) {
form.setFieldValue(['variants', index, 'package_spec'], {
shipping_weight_g: null,
length_mm: null,
width_mm: null,
height_mm: null,
status: null,
});
} else if (JSON.stringify(input.package) !== JSON.stringify(modal?.package)) {
form.setFieldValue(['variants', index, 'package_spec'], input.package);
}
}
setParameters(input.pricing);
form.setFieldValue(['variants', index, 'pricing_selection'], {
preview_id: preview.preview_id,
option_id: optionId,
input_hash: preview.input_hash,
});
setResult({
inputKey: keyFor(input),
preview,
option: preview.options.find((o) => o.id === optionId)!,
});
setError('');
onChanged();
}
async function calculate(force = false, override?: PricingParameters) {
if (!catalog || !v || original?.listing.status === 'imported') return;
const current = form.getFieldValue(['variants', index]) as Values['variants'][number];
const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog);
if (!parameters) {
setError('没有已审核物流费率,请先配置费率');
return;
}
const input = skuPricingInput(
form.getFieldsValue(true),
index,
snapshot.profile.id,
parameters
),
inputKey = keyFor(input);
if (!force && ((lastRequest.current === inputKey && resultValid) || confirmed)) return;
const parsed = pricingPreviewRequestSchema.safeParse(input);
if (!parsed.success) {
setError('请补齐采购价、类目,并确认有效包装重量和尺寸');
return;
}
setParameters(parameters);
lastRequest.current = inputKey;
controller.current?.abort();
controller.current = new AbortController();
const seq = ++sequence.current;
setBusy(true);
setError('');
try {
const p = await pricingApi.preview(parsed.data, controller.current.signal);
if (seq !== sequence.current || liveRef.current !== inputKey) return;
const recommended = p.options.find((o) => o.id === p.recommended_option_id);
if (!recommended) {
setError(
p.unavailable_options.map((x) => x.reason).join(';') ||
'默认运输方式不可用,请在详情中选择'
);
return;
}
if (!recommended.can_confirm) {
setResult({ inputKey, preview: p, option: recommended });
setError('当前售价亏损,不能确认,请调整售价');
return;
}
apply(input, p, recommended.id, false);
} catch (e) {
if (seq === sequence.current && !controller.current.signal.aborted)
setError(apiErrorMessage(e));
} finally {
if (seq === sequence.current) setBusy(false);
}
}
// 包装和门店上下文变化自动重算;采购价与手动售价仅在失焦/Enter 时提交。
const contextKey = live
? JSON.stringify([
live.store_id,
live.platform_product_category_id,
live.fulfillment,
live.package,
])
: '';
const previousContext = useRef(contextKey);
useEffect(() => {
if (previousContext.current === contextKey) return;
previousContext.current = contextKey;
sequence.current++;
controller.current?.abort();
setBusy(false);
if (auto && live && result?.inputKey !== liveKey) {
const timer = setTimeout(() => void calculate(true), 400);
return () => clearTimeout(timer);
}
}, [contextKey, auto]);
useEffect(() => {
if (batch === lastBatch.current) return;
lastBatch.current = batch;
void calculate(true);
}, [batch]);
if (!v) return null;
const imported = original?.listing.status === 'imported';
return (
<div style={{ display: 'flex', gap: 12, alignItems: 'start', flexWrap: 'wrap', flex: 1 }}>
<div className="sku-card-field sku-price-field">
<span>采购价(CNY)</span>
<InputNumber<string>
aria-label={`SKU ${index + 1} 采购价`}
min="0"
stringMode
precision={2}
value={v.purchase_cost_cny}
onChange={(value) => {
form.setFieldValue(['variants', index, 'purchase_cost_cny'], value);
onChanged();
}}
onBlur={() => {
if (auto && !imported) void calculate();
}}
onPressEnter={(event) => {
event.preventDefault();
void calculate(true);
}}
/>
</div>
<div className="sku-card-field sku-price-field">
<span>
销售价({catalog?.store.currency_code ?? original?.listing.currency_code ?? '—'}
)
</span>
<InputNumber<string>
aria-label={`SKU ${index + 1} 销售价`}
min="0.01"
stringMode
precision={2}
disabled={imported || !params}
value={
params?.mode === 'fixed_sale'
? params.selling_price
: (option?.selling_price ?? original?.listing.selling_price)
}
onChange={(value) => {
if (params)
setParameters({ ...params, mode: 'fixed_sale', selling_price: value });
}}
onBlur={() => {
if (auto) void calculate(true);
}}
onPressEnter={(event) => {
event.preventDefault();
void calculate(true);
}}
/>
</div>
<div className="sku-card-value">
<span>预估净利润(CNY)</span>
<strong
style={{
color: option && Number(option.profit_cny) < 0 ? '#cf1322' : '#389e0d',
}}
>
{option?.profit_cny ?? '—'}
</strong>
</div>
{!imported && (
<Space>
<Button loading={busy} onClick={() => void calculate(true)}>
计算
</Button>
<Button
disabled={!catalog || !params}
onClick={() => {
restoreInheritance.current = false;
setModal(
skuPricingInput(
form.getFieldsValue(true),
index,
snapshot.profile.id,
params!
)
);
}}
>
试算详情
</Button>
</Space>
)}
<div style={{ width: '100%' }}>
<Space wrap>
<Tag color={confirmed ? 'success' : resultValid ? 'purple' : 'default'}>
{busy
? '计算中'
: confirmed
? '已确认'
: resultValid
? result?.option.can_confirm === false
? '亏损 · 请调整售价'
: '已试算 · 待保存'
: v.pricing_selection
? '输入已变化 · 需重新试算'
: '待定价'}
</Tag>
{catalog?.rate_cards.find((card) => card.id === params?.rate_card_id)
?.development_only && <Tag color="orange">开发草稿费率 · 不可上架</Tag>}
{params?.mode === 'fixed_sale' && (
<>
<Tag>手动售价</Tag>
<Button
size="small"
type="link"
onClick={() => {
const p = {
...params,
mode: 'target_markup' as const,
selling_price: null,
};
setParameters(p);
void calculate(true, p);
}}
>
使用建议售价
</Button>
</>
)}
<Typography.Text type="secondary">
{catalog?.policies.find((p) => p.id === params?.pricing_policy_id)?.name ??
'本次参数'}
{option ? ` · ${option.transport_name}` : ''}
</Typography.Text>
</Space>
{error && (
<div role="alert" style={{ color: '#ad6800', fontSize: 12 }}>
{error}
</div>
)}
</div>
<Modal
title={`${v.display_name ?? 'SKU'} · 定价试算`}
open={!!modal}
width={1000}
footer={null}
destroyOnHidden
onCancel={() => setModal(null)}
>
{modal && catalog && (
<>
<Typography.Paragraph type="secondary">
包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。
</Typography.Paragraph>
<Button
size="small"
onClick={() => {
restoreInheritance.current = true;
const base = form.getFieldValue(['common', 'default_package_spec']);
setModal({ ...modal, package: { ...base, status: base.status } });
}}
>
使用 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>
</div>
);
}
@@ -0,0 +1,25 @@
/** 界面使用百分数,API 保留十进制比例字符串;移位换算避免浮点误差。 */
export function rateToPercent(rate: string): string {
const negative = rate.startsWith('-');
const [integer, fraction = ''] = (negative ? rate.slice(1) : rate).split('.');
const digits = integer + fraction.padEnd(2, '0');
const point = integer.length + 2;
const whole = digits.slice(0, point).replace(/^0+(?=\d)/, '');
const decimals = digits.slice(point);
const hundredths =
BigInt(whole) * 100n +
BigInt(decimals.padEnd(2, '0').slice(0, 2)) +
(Number(decimals[2] ?? '0') >= 5 ? 1n : 0n);
return `${negative && hundredths > 0n ? '-' : ''}${hundredths / 100n}.${String(hundredths % 100n).padStart(2, '0')}`;
}
export function percentToRate(percent: string): string {
const [integer, fraction = ''] = percent.split('.');
const hundredths =
BigInt(integer || '0') * 100n +
BigInt(fraction.padEnd(2, '0').slice(0, 2)) +
(Number(fraction[2] ?? '0') >= 5 ? 1n : 0n);
const whole = String(hundredths / 10000n);
const decimals = String(hundredths % 10000n).padStart(4, '0').replace(/0+$/, '');
return decimals ? `${whole}.${decimals}` : whole;
}
+50 -15
View File
@@ -1,5 +1,10 @@
import { Button, Layout, Menu, Result, Spin } from 'antd';
import { AppstoreOutlined, ArrowLeftOutlined, DatabaseOutlined } from '@ant-design/icons';
import {
AppstoreOutlined,
ArrowLeftOutlined,
CalculatorOutlined,
DatabaseOutlined,
} from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useSystemAdminAccess } from '@/services/useSystemAdminAccess';
import './SystemAdminLayout.css';
@@ -12,12 +17,21 @@ export default function SystemAdminLayout() {
const location = useLocation();
const navigate = useNavigate();
if (access === 'checking') {
return <div className="system-admin-gate"><Spin description="正在验证系统管理员权限…" /></div>;
return (
<div className="system-admin-gate">
<Spin description="正在验证系统管理员权限…" />
</div>
);
}
if (access === 'denied') {
return <Result status="403" title="无权访问系统管理" subTitle="请联系系统维护者授予权限" extra={
<Button onClick={() => navigate('/online-products')}>返回工作台</Button>
} />;
return (
<Result
status="403"
title="无权访问系统管理"
subTitle="请联系系统维护者授予权限"
extra={<Button onClick={() => navigate('/online-products')}>返回工作台</Button>}
/>
);
}
return (
<Layout className="system-admin-layout">
@@ -28,24 +42,45 @@ export default function SystemAdminLayout() {
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname.startsWith('/system/platform-catalog')
? '/system/platform-catalog' : location.pathname]}
items={[{
key: '/system/platform-catalog',
icon: <DatabaseOutlined />,
label: '平台类目与属性',
}]}
selectedKeys={[
location.pathname.startsWith('/system/platform-catalog')
? '/system/platform-catalog'
: location.pathname,
]}
items={[
{
key: '/system/platform-catalog',
icon: <DatabaseOutlined />,
label: '平台类目与属性',
},
{
key: '/system/pricing-rates',
icon: <CalculatorOutlined />,
label: '物流费率审核',
},
]}
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header className="system-admin-header">
<span>系统管理 / 平台类目与属性</span>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/online-products')}>
<span>
系统管理 /{' '}
{location.pathname.startsWith('/system/pricing-rates')
? '物流费率审核'
: '平台类目与属性'}
</span>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/online-products')}
>
返回工作台
</Button>
</Header>
<Content className="system-admin-content"><Outlet /></Content>
<Content className="system-admin-content">
<Outlet />
</Content>
</Layout>
</Layout>
);
+451 -8
View File
@@ -1,8 +1,451 @@
import PagePlaceholder from '@/components/PagePlaceholder';
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
const PricingPage = () => (
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
);
export default PricingPage;
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
App,
Button,
Card,
Empty,
Input,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd';
import type {
PricingCatalog,
PricingCategory,
PricingPolicy,
PricingPolicyRequest,
Store,
} from '@atlas/contracts';
import {
PricingCalculator,
PricingParametersFields,
defaultPricing,
} from '@/components/pricing/PricingCalculator';
import { pricingApi } from '@/services/pricing';
import { storesApi } from '@/services/stores';
import { usePlatform } from '@/services/platform';
import { apiErrorMessage } from '@/services/api';
import { useSystemAdminAccess } from '@/services/useSystemAdminAccess';
import { rateToPercent } from '@/components/pricing/pricingPercentage';
export default function PricingPage() {
const { currentPlatform, context } = usePlatform(),
{ message } = App.useApp();
const navigate = useNavigate();
const adminAccess = useSystemAdminAccess();
const [stores, setStores] = useState<Store[]>([]),
[store, setStore] = useState<string | null>(null),
[catalog, setCatalog] = useState<PricingCatalog | null>(null),
[error, setError] = useState(''),
[categories, setCategories] = useState<PricingCategory[]>([]),
[category, setCategory] = useState<string | null>(null),
[query, setQuery] = useState(''),
[editing, setEditing] = useState<{
policy: PricingPolicy | null;
input: PricingPolicyRequest;
} | null>(null),
[saving, setSaving] = useState(false);
const request = useRef(0);
useEffect(() => {
let active = true;
setStore(null);
setCatalog(null);
if (currentPlatform)
void (async () => {
let cursor: string | undefined;
const all: Store[] = [];
do {
const r = await storesApi.list(currentPlatform.id, cursor);
all.push(...r.data);
cursor = r.meta.next_cursor ?? undefined;
} while (cursor);
if (active) {
setStores(all);
setStore(
all.find((s) => s.id === context?.default_store?.id)?.id ??
all[0]?.id ??
null
);
}
})().catch((e) => {
if (active) setError(apiErrorMessage(e));
});
return () => {
active = false;
};
}, [currentPlatform?.id]);
const reload = async () => {
if (store) setCatalog(await pricingApi.catalog(store));
};
useEffect(() => {
let active = true;
setCatalog(null);
setCategory(null);
setError('');
if (store)
void pricingApi
.catalog(store)
.then((c) => {
if (active) setCatalog(c);
})
.catch((e) => {
if (active) setError(apiErrorMessage(e));
});
return () => {
active = false;
};
}, [store]);
useEffect(() => {
const seq = ++request.current;
setCategories([]);
if (!store) return;
const timer = setTimeout(
() =>
void pricingApi
.categories(store, query)
.then((r) => {
if (request.current === seq) setCategories(r);
})
.catch((e) => {
if (request.current === seq) setError(apiErrorMessage(e));
}),
250
);
return () => clearTimeout(timer);
}, [store, query]);
const parameters = catalog ? defaultPricing(catalog) : null;
const rateCardNotice =
catalog && !parameters ? (
<Alert
type="warning"
title="当前平台没有可用于正式试算的物流费率"
description={
<Space orientation="vertical" size="small">
{catalog.pending_rate_cards.length > 0 && (
<span>
待复核:
{catalog.pending_rate_cards
.map((card) => `${card.name}(${card.version})`)
.join('、')}
</span>
)}
<span>
待复核费率不能用于正式报价。系统管理员须在「系统管理 →
物流费率审核」录入真实账单样本,复算通过后按生效日期启用。
</span>
{adminAccess === 'allowed' && (
<Button
type="link"
style={{ padding: 0 }}
onClick={() => navigate('/system/pricing-rates')}
>
前往物流费率审核
</Button>
)}
</Space>
}
/>
) : null;
const mutate = async (fn: () => Promise<unknown>) => {
try {
await fn();
await reload();
} catch (e) {
message.error(apiErrorMessage(e));
}
};
return (
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={3}>定价工具</Typography.Title>
<Typography.Text type="secondary">
按门店方案试算,比较物流费用与预估净利润。
</Typography.Text>
</div>
<Select
aria-label="定价门店"
placeholder="选择门店"
style={{ width: 320 }}
value={store}
options={stores.map((s) => ({
value: s.id,
label: `${s.name} · ${s.currency_code}`,
}))}
onChange={setStore}
/>
{error && <Alert type="error" title={error} />}{' '}
{!store && !error && <Empty description="请先创建目标门店" />}
{catalog && (
catalog.rate_cards.some((card) => card.development_only) && (
<Alert
type="warning"
title="当前使用开发草稿费率"
description="可用于方案、试算和商品编辑联调;按草稿费率确认的 SKU 不能首次上架,正式审核后需重新试算确认。"
/>
)
)}
{catalog && (
<Tabs
items={[
{
key: 'trial',
label: '即时试算',
children: (
<Card>
<Space
orientation="vertical"
style={{ width: '100%' }}
size="large"
>
<Select
aria-label="试算商品类目"
showSearch={{ filterOption: false, onSearch: setQuery }}
style={{ width: '100%' }}
placeholder="搜索商品类目"
value={category}
options={categories.map((c) => ({
value: c.id,
label: c.path || c.name,
}))}
onChange={setCategory}
/>
{!parameters ? (
rateCardNotice
) : !category ? (
<Empty description="选择类目后开始试算" />
) : (
<PricingCalculator
key={`${store}:${category}`}
catalog={catalog}
onPolicySaved={(policy) =>
setCatalog(
(current) =>
current && {
...current,
policies: [
...current.policies,
policy,
],
}
)
}
initial={{
store_id: store!,
platform_product_category_id: category,
fulfillment: 'rfbs',
binding: null,
purchase_cost_cny: '',
package: {
shipping_weight_g: '',
length_mm: '',
width_mm: '',
height_mm: '',
status: 'confirmed',
},
pricing: parameters,
}}
/>
)}
</Space>
</Card>
),
},
{
key: 'policies',
label: '定价方案',
children: (
<Card
title="门店方案"
extra={
<Tooltip
title={
!parameters
? '需要先启用经过审核的物流费率'
: undefined
}
>
<Button
type="primary"
disabled={!parameters}
onClick={() =>
setEditing({
policy: null,
input: {
name: '',
note: null,
parameters: {
...parameters!,
pricing_policy_id: null,
},
allow_commission_override: false,
},
})
}
>
新建方案
</Button>
</Tooltip>
}
>
{rateCardNotice}
<Table
rowKey="id"
dataSource={catalog.policies}
pagination={false}
columns={[
{
title: '方案',
dataIndex: 'name',
render: (name, p) => (
<>
{name}{' '}
{p.is_default && (
<Tag color="purple">默认</Tag>
)}
</>
),
},
{
title: '目标加价率',
render: (_, p) =>
`${rateToPercent(p.parameters.desired_markup_rate)}%`,
},
{
title: '汇率',
render: (_, p) =>
p.parameters.fx.mode === 'policy_fixed'
? p.parameters.fx.rate
: 'CBR 系统汇率',
},
{
title: '操作',
render: (_, p) => (
<Space>
<Button
type="link"
onClick={() =>
setEditing({
policy: p,
input: structuredClone({
name: p.name,
note: p.note,
parameters: p.parameters,
allow_commission_override:
p.allow_commission_override,
}),
})
}
>
编辑
</Button>
{!p.is_default && (
<Button
type="link"
onClick={() =>
void mutate(() =>
pricingApi.defaultPolicy(
p.id
)
)
}
>
设为默认
</Button>
)}
<Popconfirm
title="归档此方案?历史报价仍会保留。"
onConfirm={() =>
mutate(() =>
pricingApi.archivePolicy(p.id)
)
}
>
<Button danger type="link">
归档
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</Card>
),
},
]}
/>
)}
<Modal
title={editing?.policy ? '编辑定价方案' : '新建定价方案'}
open={!!editing}
width={620}
destroyOnHidden
confirmLoading={saving}
onCancel={() => setEditing(null)}
onOk={async () => {
if (!editing || !store) return;
setSaving(true);
try {
if (editing.policy)
await pricingApi.updatePolicy(editing.policy, editing.input);
else await pricingApi.createPolicy(store, editing.input);
await reload();
setEditing(null);
message.success('方案已保存');
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
setSaving(false);
}
}}
>
{editing && catalog && (
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
<Input
aria-label="方案名称"
placeholder="方案名称"
value={editing.input.name}
onChange={(e) =>
setEditing({
...editing,
input: { ...editing.input, name: e.target.value },
})
}
/>
<PricingParametersFields
policyEditing
value={editing.input.parameters}
catalog={{ ...catalog, policies: [] }}
onChange={(parameters) =>
setEditing({ ...editing, input: { ...editing.input, parameters } })
}
/>
<Space>
<Switch
checked={editing.input.allow_commission_override}
onChange={(v) =>
setEditing({
...editing,
input: { ...editing.input, allow_commission_override: v },
})
}
/>
缺少正式规则时允许临时佣金
</Space>
<Typography.Text type="secondary">
方案只保存费用、利润、汇率和运输偏好,不包含采购价与包装。修改不影响已确认报价。
</Typography.Text>
</Space>
)}
</Modal>
</Space>
);
}
@@ -148,6 +148,8 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
source_sku: variant.source_sku,
specification_values: variant.specification_values,
purchase_cost_cny: variant.purchase_cost_cny,
pricing_draft: variant.listing.pricing_draft ?? null,
pricing_selection: null,
title: variant.title,
tags: variant.tags,
description: variant.description,
@@ -801,6 +803,7 @@ const ProductEditPage = () => {
const useVariantTitle = () => setUsesVariantTitle(true);
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
const save = async (values: EditorFormValues) => {
values = { ...values, ...form.getFieldsValue(true) };
if (!productId || !currentPlatform || !snapshot) return false;
setSaving(true);
try {
@@ -824,7 +827,22 @@ const ProductEditPage = () => {
await load();
return true;
} catch (error) {
if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') {
if (apiErrorCode(error) === 'PRICING_PREVIEW_STALE') {
Modal.confirm({
title: '报价已失效',
content: apiErrorMessage(error),
okText: '仅保存草稿,稍后定价',
cancelText: '返回重新试算',
onOk: async () => {
const draft = {
...values,
variants: values.variants.map((v) => ({ ...v, pricing_selection: null })),
};
form.setFieldsValue(draft);
await save(draft);
},
});
} else if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') {
message.warning('采集来源已更新,已重新加载合并结果,请确认后再次保存');
await load();
} else if (apiErrorCode(error) === 'VERSION_CONFLICT') {
@@ -1,3 +1,5 @@
import { SkuPricing } from '@/components/pricing/SkuPricing';
import { Switch, Space as PricingSpace } from 'antd';
import { useEffect, useRef, useState } from 'react';
import {
Button,
@@ -166,6 +168,8 @@ export function ProductVariantSection({
onCompareCopy,
onImagesChanged,
}: Props) {
const [pricingAuto, setPricingAuto] = useState(() => localStorage.getItem('atlas.pricing.auto') !== 'false');
const [pricingBatch, setPricingBatch] = useState(0);
// 实时监听变体草稿,驱动已选图片和主图回显。
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
@@ -220,7 +224,7 @@ export function ProductVariantSection({
number="03"
title="变体设置"
description="卡片用于快速核对与填写;展开后可维护图片和该 SKU 的包装覆盖。"
extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>}
extra={<PricingSpace><span>自动试算</span><Switch checked={pricingAuto} onChange={value=>{setPricingAuto(value);localStorage.setItem('atlas.pricing.auto',String(value));}}/><Button onClick={()=>setPricingBatch(x=>x+1)}>按方案计算全部</Button><Tag>{snapshot.variants.length} 个 SKU</Tag></PricingSpace>}
/>
<Form.List name="variants">
{(fields, { add, remove }) => {
@@ -316,7 +320,8 @@ export function ProductVariantSection({
...variantValue,
id: null,
client_key: crypto.randomUUID(),
copy_from_variant_id: original?.id ?? null,
copy_from_variant_id: null,
pricing_selection: null,
display_name: variantValue.display_name
? `${variantValue.display_name} 副本`
: 'SKU 副本',
@@ -423,48 +428,7 @@ export function ProductVariantSection({
<Input placeholder="首次上架前填写" />
</Form.Item>
</div>
<div className="sku-card-field sku-price-field">
<span>采购价(CNY)</span>
<Form.Item
name={[field.name, 'purchase_cost_cny']}
noStyle
rules={[
{
pattern: /^\d+(\.\d{1,2})?$/,
message: '请输入最多两位小数的金额',
},
]}
>
<InputNumber<string>
min="0"
step="0.01"
precision={2}
stringMode
placeholder="—"
/>
</Form.Item>
</div>
<div className="sku-card-field sku-price-field">
<span>
销售价(
{original?.listing.currency_code ?? '₽'}
)
</span>
<InputNumber<string>
value={
original?.listing.selling_price ??
''
}
placeholder="—"
readOnly
controls={false}
stringMode
/>
</div>
<div className="sku-card-value">
<span>净利润</span>
<strong>—</strong>
</div>
<SkuPricing form={form} index={field.name} snapshot={snapshot} auto={pricingAuto} batch={pricingBatch} onChanged={onImagesChanged} />
<Button
className="sku-expand-button"
type="text"
@@ -0,0 +1,398 @@
import { useEffect, useRef, useState } from 'react';
import {
Alert,
App,
Button,
Card,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd';
import {
pricingRateReviewRequestSchema,
type PricingRateCardAdmin,
type PricingRateReviewRequest,
} from '@atlas/contracts';
import { platformCatalogAdminApi, type AdminPlatform } from '@/services/platformCatalogAdmin';
import { pricingAdminApi } from '@/services/pricingAdmin';
import { apiErrorMessage } from '@/services/api';
type Sample = PricingRateReviewRequest['samples'][number];
const emptySample = (ruleId: string): Sample => ({
rule_id: ruleId,
invoice_reference: '',
merchandise_value_cny: '',
shipping_weight_g: '',
length_mm: '',
width_mm: '',
height_mm: '',
billed_logistics_cny: '',
});
export default function SystemPricingRatesPage() {
const { message } = App.useApp();
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]);
const [platformId, setPlatformId] = useState<string | null>(null);
const [cards, setCards] = useState<PricingRateCardAdmin[]>([]);
const [cardId, setCardId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [reviewOpen, setReviewOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [sourceReference, setSourceReference] = useState('');
const [reviewNote, setReviewNote] = useState('');
const [effectiveFrom, setEffectiveFrom] = useState('');
const [samples, setSamples] = useState<Record<string, Sample>>({});
const approvalAttempt = useRef<{ signature: string; key: string } | null>(null);
useEffect(() => {
void platformCatalogAdminApi
.platforms()
.then((items) => {
setPlatforms(items);
setPlatformId(
items.find((item) => item.code.toUpperCase() === 'OZON')?.id ??
items[0]?.id ??
null
);
})
.catch((cause) => setError(apiErrorMessage(cause)));
}, []);
const reload = async (id: string) => {
setLoading(true);
try {
const result = await pricingAdminApi.cards(id);
setCards(result);
setCardId((current) =>
result.some((card) => card.id === current) ? current : (result[0]?.id ?? null)
);
setError('');
} catch (cause) {
setError(apiErrorMessage(cause));
} finally {
setLoading(false);
}
};
useEffect(() => {
if (platformId) void reload(platformId);
}, [platformId]);
const card = cards.find((item) => item.id === cardId) ?? null;
const beginReview = () => {
if (!card) return;
setSourceReference('');
approvalAttempt.current = null;
setReviewNote('');
setEffectiveFrom('');
setSamples(
Object.fromEntries(
card.types.map((type) => [
type.id,
emptySample(
card.rules.find((rule) => rule.product_type_id === type.id)?.id ?? ''
),
])
)
);
setReviewOpen(true);
};
const updateSample = (typeId: string, patch: Partial<Sample>) =>
setSamples((current) => ({ ...current, [typeId]: { ...current[typeId], ...patch } }));
const approve = async () => {
if (!card || !platformId) return;
const date = new Date(effectiveFrom);
if (!Number.isFinite(date.getTime())) {
message.error('请选择费率生效日期和时间');
return;
}
const parsed = pricingRateReviewRequestSchema.safeParse({
rules_hash: card.rules_hash,
source_reference: sourceReference,
review_note: reviewNote,
effective_from: date.toISOString(),
samples: card.types.map((type) => samples[type.id]),
});
if (!parsed.success) {
message.error('请填写来源、至少 20 字复核说明,以及每种产品类型的完整账单样本');
return;
}
setSaving(true);
try {
const signature = JSON.stringify(parsed.data);
if (approvalAttempt.current?.signature !== signature)
approvalAttempt.current = { signature, key: crypto.randomUUID() };
await pricingAdminApi.approve(card.id, parsed.data, approvalAttempt.current.key);
await reload(platformId);
setReviewOpen(false);
message.success('账单样本复算通过,费率卡已按生效日期启用');
} catch (cause) {
message.error(apiErrorMessage(cause));
} finally {
setSaving(false);
}
};
return (
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title level={3}>物流费率审核</Typography.Title>
<Typography.Text type="secondary">
查看平台资费版本,用实际物流账单逐类型复算后启用。审核记录会保留来源、样本和审核人。
</Typography.Text>
</div>
{error && <Alert type="error" title={error} />}
<Space wrap>
<Select
aria-label="费率平台"
style={{ width: 240 }}
value={platformId}
onChange={setPlatformId}
options={platforms.map((platform) => ({
value: platform.id,
label: platform.name,
}))}
/>
<Select
aria-label="费率版本"
style={{ minWidth: 360 }}
value={cardId}
onChange={setCardId}
loading={loading}
options={cards.map((item) => ({
value: item.id,
label: `${item.source_name} · ${item.version} · ${item.status === 'draft' ? '待审核' : '已启用'}`,
}))}
/>
<Button onClick={() => platformId && void reload(platformId)}>刷新</Button>
</Space>
{!card && !loading && <Alert type="info" title="当前平台没有物流费率卡" />}
{card && (
<>
<Card
title={
<Space>
{card.source_name}
<Tag color={card.status === 'active' ? 'green' : 'orange'}>
{card.status === 'active' ? '已启用' : '待审核'}
</Tag>
</Space>
}
extra={
card.status === 'draft' && (
<Button type="primary" onClick={beginReview}>
录入账单并审核启用
</Button>
)
}
>
<Space orientation="vertical">
<span>
版本:{card.version} 服务商:{card.provider_code} 生效时间:
{new Date(card.effective_from).toLocaleString()}
</span>
<span>
产品类型 {card.types.length} 种,运价规则 {card.rules.length}{' '}
条。已审核版本的规则变更应发布新版本。
</span>
{card.review && (
<span>
审核人:{card.reviewer_name ?? '已注销用户'} 审核时间:
{new Date(card.review.reviewed_at).toLocaleString()} 证据:
{card.review.source_reference}
</span>
)}
</Space>
</Card>
<Card title="产品类型与适用范围">
<Table
rowKey="id"
pagination={false}
size="small"
dataSource={card.types}
scroll={{ x: 1450 }}
columns={[
{ title: '类型', dataIndex: 'code' },
{
title: '货值 CNY',
render: (_, row) =>
`${row.min_merchandise_value !== null && row.min_value_inclusive ? '[' : '('}${row.min_merchandise_value ?? '−∞'}, ${row.max_merchandise_value ?? '+∞'}${row.max_merchandise_value !== null && row.max_value_inclusive ? ']' : ')'}`,
},
{
title: '实际重量 kg',
render: (_, row) =>
`${row.min_actual_weight_inclusive ? '[' : '('}${row.min_actual_weight_kg}, ${row.max_actual_weight_kg ?? '不限'}]`,
},
{
title: '尺寸上限 cm(三边和 / 最长 / 次长)',
render: (_, row) =>
`${row.max_sum_dimensions_cm ?? '不限'} / ${row.max_longest_side_cm ?? '不限'} / ${row.max_second_longest_side_cm ?? '不限'}`,
},
{
title: '体积重与进位',
render: (_, row) =>
row.billing_basis === 'actual_only'
? `仅实重;计费重向上取 ${row.billing_round_scale ?? '原值'} 位`
: `实重/体积取大;除数 ${row.volume_divisor};体积重向上取 ${row.volume_round_scale ?? '原值'} 位;计费重向上取 ${row.billing_round_scale ?? '原值'} 位`,
},
{
title: '体积重资格上限 kg',
render: (_, row) =>
row.max_volume_weight_kg
? `${row.max_volume_weight_kg}(先向上取 ${row.volume_limit_round_scale ?? '原值'} 位)`
: '不限',
},
]}
/>
</Card>
<Card title="运输规则(CNY)">
<Table
rowKey="id"
pagination={false}
size="small"
dataSource={card.rules}
scroll={{ x: 850 }}
columns={[
{ title: '产品类型', dataIndex: 'product_type_code' },
{ title: '运输方式', dataIndex: 'transport_method_name' },
{ title: '每票固定费', dataIndex: 'fixed_fee_cny' },
{ title: '每计费 kg', dataIndex: 'per_billable_kg_fee_cny' },
{
title: '最低收费',
render: (_, row) => row.min_charge_cny ?? '—',
},
{
title: '状态',
render: (_, row) =>
row.status === 'draft' ? '待审核' : '已启用',
},
]}
/>
</Card>
{card.review && (
<Card title="审核证据与样本">
<Typography.Paragraph>{card.review.review_note}</Typography.Paragraph>
<Table
rowKey="invoice_reference"
pagination={false}
size="small"
dataSource={card.review.samples}
scroll={{ x: 900 }}
columns={[
{ title: '账单编号', dataIndex: 'invoice_reference' },
{
title: '规则',
render: (_, sample) => {
const rule = card.rules.find(
(item) => item.id === sample.rule_id
);
return rule
? `${rule.product_type_code} · ${rule.transport_method_name}`
: '历史规则';
},
},
{ title: '货值 CNY', dataIndex: 'merchandise_value_cny' },
{ title: '重量 g', dataIndex: 'shipping_weight_g' },
{
title: '包装 mm',
render: (_, sample) =>
`${sample.length_mm} × ${sample.width_mm} × ${sample.height_mm}`,
},
{ title: '账单物流费 CNY', dataIndex: 'billed_logistics_cny' },
]}
/>
</Card>
)}
</>
)}
<Modal
title="用真实账单复核并启用费率"
open={reviewOpen}
width={1050}
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
confirmLoading={saving}
okText="复算通过后启用"
onOk={() => void approve()}
onCancel={() => setReviewOpen(false)}
>
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Alert
type="warning"
title="此操作会使费率参与正式报价"
description="至少为每种产品类型录入一条实际账单。账单金额请换算为 CNY 跨境物流费,服务端按当前规则复算,差额须不超过 0.01 CNY。账单真实性由审核人确认。"
/>
<Input
aria-label="来源与账单批次"
placeholder="资费源表、合同或账单批次编号"
value={sourceReference}
onChange={(event) => setSourceReference(event.target.value)}
/>
<Input
aria-label="生效时间"
type="datetime-local"
value={effectiveFrom}
onChange={(event) => setEffectiveFrom(event.target.value)}
/>
<Input.TextArea
aria-label="复核说明"
placeholder="说明账单来源、口径、适用市场和复核结论(至少 20 字)"
rows={2}
value={reviewNote}
onChange={(event) => setReviewNote(event.target.value)}
/>
{card?.types.map((type) => {
const sample = samples[type.id];
if (!sample) return null;
const fields: Array<[keyof Sample, string]> = [
['invoice_reference', '账单编号'],
['merchandise_value_cny', '货值 CNY'],
['shipping_weight_g', '重量 g'],
['length_mm', '长 mm'],
['width_mm', '宽 mm'],
['height_mm', '高 mm'],
['billed_logistics_cny', '账单物流费 CNY'],
];
return (
<Card key={type.id} size="small" title={`${type.code} · 账单样本`}>
<Space wrap>
<Select
aria-label={`${type.code} 运输规则`}
style={{ width: 190 }}
value={sample.rule_id}
options={card.rules
.filter((rule) => rule.product_type_id === type.id)
.map((rule) => ({
value: rule.id,
label: rule.transport_method_name,
}))}
onChange={(value) =>
updateSample(type.id, { rule_id: value })
}
/>
{fields.map(([field, label]) => (
<Input
key={field}
aria-label={`${type.code} ${label}`}
placeholder={label}
style={{
width: field === 'invoice_reference' ? 210 : 135,
}}
value={sample[field]}
onChange={(event) =>
updateSample(type.id, {
[field]: event.target.value,
})
}
/>
))}
</Space>
</Card>
);
})}
</Space>
</Modal>
</Space>
);
}
+7 -1
View File
@@ -18,6 +18,7 @@ import { PlatformProvider } from '@/services/platform';
import { useSession } from '@/services/session';
import SystemAdminLayout from '@/layouts/SystemAdminLayout';
import SystemCatalogPage from '@/pages/system/SystemCatalogPage';
import SystemPricingRatesPage from '@/pages/system/SystemPricingRatesPage';
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
function RequireSession({ children }: { children: ReactNode }) {
@@ -75,10 +76,15 @@ export const router = createBrowserRouter([
},
{
path: '/system',
element: <RequireSession><SystemAdminLayout /></RequireSession>,
element: (
<RequireSession>
<SystemAdminLayout />
</RequireSession>
),
children: [
{ index: true, element: <Navigate to="/system/platform-catalog" replace /> },
{ path: 'platform-catalog', element: <SystemCatalogPage /> },
{ path: 'pricing-rates', element: <SystemPricingRatesPage /> },
],
},
{
+27
View File
@@ -0,0 +1,27 @@
import type {
PricingCatalog,
PricingCategory,
PricingPolicy,
PricingPolicyRequest,
PricingPreview,
PricingPreviewRequest,
} from '@atlas/contracts';
import { api } from './api';
const headers = () => ({ 'Idempotency-Key': crypto.randomUUID() });
export const pricingApi = {
categories: (store: string, q: string) =>
api.get<PricingCategory[]>('/pricing/categories', { params: { store_id: store, q } }),
catalog: (store: string) =>
api.get<PricingCatalog>('/pricing/catalog', { params: { store_id: store } }),
preview: (input: PricingPreviewRequest, signal?: AbortSignal) =>
api.post<PricingPreview>('/pricing/quote-previews', input, { headers: headers(), signal }),
createPolicy: (store: string, input: PricingPolicyRequest) =>
api.post<PricingPolicy>(`/stores/${store}/pricing-policies`, input, { headers: headers() }),
updatePolicy: (policy: PricingPolicy, input: PricingPolicyRequest) =>
api.patch<PricingPolicy>(`/pricing-policies/${policy.id}`, input, {
headers: { ...headers(), 'If-Match': `"${policy.revision}"` },
}),
defaultPolicy: (id: string) =>
api.post<PricingPolicy>(`/pricing-policies/${id}/set-default`, {}, { headers: headers() }),
archivePolicy: (id: string) => api.delete(`/pricing-policies/${id}`, { headers: headers() }),
};
+13
View File
@@ -0,0 +1,13 @@
import type { PricingRateCardAdmin, PricingRateReviewRequest } from '@atlas/contracts';
import { api } from './api';
export const pricingAdminApi = {
cards: (platformId: string) =>
api.get<PricingRateCardAdmin[]>('/system/pricing/rate-cards', {
params: { platform_id: platformId },
}),
approve: (cardId: string, input: PricingRateReviewRequest, idempotencyKey: string) =>
api.post<PricingRateCardAdmin>(`/system/pricing/rate-cards/${cardId}/reviews`, input, {
headers: { 'Idempotency-Key': idempotencyKey },
}),
};
+4
View File
@@ -7,6 +7,10 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
// 浏览器直接编译共享契约源码,新增导出无需等待 CommonJS 预打包缓存刷新。
'@atlas/contracts': fileURLToPath(
new URL('../../packages/contracts/src/index.ts', import.meta.url)
),
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
+68
View File
@@ -0,0 +1,68 @@
import type { Database } from '@atlas/database';
/** CBR 的 Value 按 Nominal 报价;用整数除法换算,避免浮点误差与非预期 XML 内容。 */
export function parseCbrCny(xml: string) {
const date = xml.match(/<ValCurs\b[^>]*Date="(\d{2})\.(\d{2})\.(\d{4})"/);
const entry = [...xml.matchAll(/<Valute\b[^>]*>([\s\S]*?)<\/Valute>/g)]
.map((m) => m[1])
.find((v) => /<CharCode>\s*CNY\s*<\/CharCode>/.test(v));
const nominal = entry?.match(/<Nominal>\s*(\d{1,6})\s*<\/Nominal>/)?.[1],
value = entry?.match(/<Value>\s*(\d{1,9}),?(\d{0,10})\s*<\/Value>/);
if (!date || !nominal || !value || BigInt(nominal) === 0n)
throw new Error('CBR 响应缺少有效人民币报价');
const effectiveOn = `${date[3]}-${date[2]}-${date[1]}`;
const parsedDate = new Date(`${effectiveOn}T00:00:00Z`);
if (
!Number.isFinite(parsedDate.getTime()) ||
parsedDate.toISOString().slice(0, 10) !== effectiveOn ||
parsedDate.getTime() > Date.now() + 2 * 86400000
)
throw new Error('CBR 报价日期无效');
const scaled =
BigInt(value[1]) * 10000000000n +
BigInt((value[2] ?? '').padEnd(10, '0'));
const rate = (scaled + BigInt(nominal) / 2n) / BigInt(nominal);
if (rate <= 0n) throw new Error('CBR 报价必须大于零');
const digits = rate.toString().padStart(11, '0');
return {
effectiveOn,
rate: `${digits.slice(0, -10)}.${digits.slice(-10)}`,
};
}
export async function syncExchangeRate(database: Database) {
await database.transaction(async (tx) => {
const lock = await tx.query<{ locked: boolean }>(
`SELECT pg_try_advisory_xact_lock(74103921) AS locked`,
);
if (!lock.rows[0].locked) return;
const recent = await tx.query(
`SELECT id FROM atlas.exchange_rates WHERE source='cbr' AND kind='official_reference' AND base_currency_code='CNY' AND quote_currency_code='RUB' AND fetched_at>now()-interval '2 hours' LIMIT 1`,
);
if (recent.rowCount) return;
const response = await fetch(
'https://www.cbr.ru/scripts/XML_daily.asp',
{ signal: AbortSignal.timeout(15000) },
);
if (!response.ok)
throw new Error(`CBR 请求失败:HTTP ${response.status}`);
const bytes = await response.arrayBuffer();
if (bytes.byteLength > 1000000) throw new Error('CBR 响应大小异常');
const xml = new TextDecoder('windows-1251').decode(bytes),
rate = parseCbrCny(xml);
await tx.query(
`INSERT INTO atlas.exchange_rates(base_currency_code,quote_currency_code,rate,kind,source,source_reference,effective_on,observed_at) VALUES('CNY','RUB',$1,'official_reference','cbr','https://www.cbr.ru/scripts/XML_daily.asp',$2,now()) ON CONFLICT(base_currency_code,quote_currency_code,kind,source,effective_on) DO UPDATE SET fetched_at=now()`,
[rate.rate, rate.effectiveOn],
);
});
}
export async function cleanupPricingPreviews(database: Database) {
const workspaces = await database.query<{ id: string }>(
'SELECT id FROM atlas.workspaces',
);
for (const w of workspaces.rows)
await database.workspaceTransaction(w.id, async (tx) => {
await tx.query(
`DELETE FROM atlas.pricing_previews WHERE expires_at<now()-interval '2 days'`,
);
});
}
+11
View File
@@ -1,3 +1,4 @@
import { syncExchangeRate, cleanupPricingPreviews } from './exchange-rate-sync.js';
import { randomUUID } from 'node:crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { Queue, Worker } from 'bullmq';
@@ -460,6 +461,15 @@ async function bootstrap() {
'Atlas Worker is ready; consuming transactional-outbox events.',
);
}
// 汇率与预览清理可重复执行,失败保留上次汇率,不影响其他业务队列。
const pricingMaintenance=async()=>{
await Promise.allSettled([
syncExchangeRate(database).catch(()=>console.error('pricing_exchange_rate_sync_failed')),
cleanupPricingPreviews(database).catch(()=>console.error('pricing_preview_cleanup_failed')),
]);
};
const pricingTimer=setInterval(()=>void pricingMaintenance(),2*60*60_000);
void pricingMaintenance();
void bootstrap().catch((error: unknown) => {
console.error(
'worker_startup_failed',
@@ -468,6 +478,7 @@ void bootstrap().catch((error: unknown) => {
process.exitCode = 1;
});
async function shutdown() {
clearInterval(pricingTimer);
if (dispatcherTimer) clearInterval(dispatcherTimer);
if (mediaCleanupTimer) clearInterval(mediaCleanupTimer);
if (platformCatalogTimer) clearInterval(platformCatalogTimer);
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { parseCbrCny } from '../src/exchange-rate-sync.ts';
const xml = (nominal, value) =>
`<ValCurs Date="28.09.2026"><Valute ID="CNY"><CharCode>CNY</CharCode><Nominal>${nominal}</Nominal><Value>${value}</Value></Valute></ValCurs>`;
test('CBR 按面额做精确十进制换算', () => {
assert.deepEqual(parseCbrCny(xml(10, '123,4567')), {
effectiveOn: '2026-09-28',
rate: '12.3456700000',
});
assert.equal(parseCbrCny(xml(3, '1,0000')).rate, '0.3333333333');
});
test('缺失货币、非法日期和零面额不能覆盖有效汇率', () => {
assert.throws(() => parseCbrCny('<error/>'));
assert.throws(() => parseCbrCny(xml(0, '12,3')));
assert.throws(() => parseCbrCny(xml(1, '12,3').replace('28.09', '32.09')));
});