feat(pricing): 开发定价方案
This commit is contained in:
+3
-2
@@ -87,8 +87,9 @@ EXTENSION_PORT=8110
|
||||
|
||||
# DeepSeek API Key
|
||||
DEEPSEEK_API_KEY=
|
||||
# DeepSeek Model Name
|
||||
DEEPSEEK_MODEL_NAME=deepseek-flash
|
||||
|
||||
# 单张 AI 图片编辑使用 Pcore 的 gpt-image-2 异步图片接口;根地址和 /v1 地址均可,仅 Worker 读取。
|
||||
PCORE_BASE_URL=https://pcore.ai/v1
|
||||
PCORE_API_KEY=
|
||||
# DeepSeek Model Name
|
||||
DEEPSEEK_MODEL_NAME=deepseek-flash
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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() }),
|
||||
};
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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'`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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')));
|
||||
});
|
||||
+86
-145
@@ -1,188 +1,129 @@
|
||||
# 定价工具 API
|
||||
# 定价试算与门店方案 API
|
||||
|
||||
> 所有接口要求 `X-Atlas-Platform-Id`。首期仅支持 Ozon、市场 `RU`、`rfbs` 和已审核启用的 CEL 规则。报价引擎只读取 Atlas 已持久化的汇率、佣金和运价版本;请求期间绝不直接请求 CBR、CEL 或 Ozon。
|
||||
> 2026-09-28:维护者批准 SKU 就地试算、详情弹窗、随商品草稿原子确认。本契约替换原规划的独立 `pricing-confirms` 入口。
|
||||
> 稳定 DTO 位于 `packages/contracts/src/pricing.ts`;金额、汇率和比例均使用十进制字符串。
|
||||
|
||||
## 1. 统一口径
|
||||
## 1. 目录
|
||||
|
||||
- API 使用规范单位:采购成本、国内费用为 `CNY` 字符串;重量为 `kg` 字符串;尺寸为 `cm` 字符串。前端的 g/mm 输入必须先转换。
|
||||
- 所有比例以 `0`–`1` 字符串表示,如 `"0.20"` 是 20%。`desired_markup_rate` 是目标净利润 ÷ 采购成本;`list_price_discount_rate` 是买家相对划线价的折扣率。
|
||||
- 销售价和划线价均为 `RUB`;`merchandise_value_cny` 是销售价按本次汇率折算得到的 CNY,专门用于选择 CEL 产品类型。
|
||||
- 引擎枚举“佣金档 × CEL 产品类型 × 运输方式”,再回验佣金价格区间、CEL 货值区间、重量、尺寸及计费重。没有稳定有效组合时不能返回建议售价。
|
||||
- 系统汇率、方案固定汇率、一次性覆盖及临时佣金率都固化到后续 SKU 报价快照,不会改写全局规则或定价方案。
|
||||
### GET `/pricing/catalog?store_id={uuid}`
|
||||
|
||||
## 2. 只读上下文
|
||||
需要已认证工作空间。返回 `{data: PricingCatalog}`:
|
||||
|
||||
### 2.1 当前上下文
|
||||
- `store`:门店 ID、名称及报价币种(CNY / RUB)。
|
||||
- `rate_cards[]`:该门店平台、RU / rFBS 的有效费率卡及运输方式。正式环境必须 active、在有效期内、有审核时间及审核人。`development_only=true` 表示本地开发模式临时可选的 draft 卡。
|
||||
- `pending_rate_cards[]`:该平台 RU / rFBS 待审核草稿的名称、版本和状态,仅用于说明阻断原因,不作为方案或报价可选项。
|
||||
- `policies[]`:该门店活动方案,默认方案优先,包含完整参数和 revision。
|
||||
- `exchange_rate`:CBR 最新 CNY→RUB 官方参考汇率,包含 ID、日期和 stale;缺少数据时为 null。超过 72 小时提示过期参考,不自动代填其他来源。
|
||||
|
||||
`GET /api/pricing/context`
|
||||
### GET `/pricing/categories?store_id={uuid}&q={text}`
|
||||
|
||||
返回当前平台、默认市场、当前工作空间该平台的默认店铺及默认定价方案。没有默认店铺返回 `422 DEFAULT_STORE_REQUIRED`,不猜测目标店铺。
|
||||
检索门店平台下活动商品类目,返回 `{data:[{id,name,path}]}`,最多 50 条。`q` 最长 200 字;继续输入名称缩小范围。
|
||||
|
||||
```json
|
||||
{ "data": {
|
||||
"platform": { "id": "uuid", "code": "OZON", "name": "Ozon" },
|
||||
"market_code": "RU",
|
||||
"store": { "id": "uuid", "name": "Ozon 主店", "currency_code": "RUB" },
|
||||
"default_pricing_policy": { "id": "uuid", "name": "CEL 常规" }
|
||||
} }
|
||||
```
|
||||
## 2. POST `/pricing/quote-previews`
|
||||
|
||||
### 2.2 商品类目、物流目录与系统汇率
|
||||
|
||||
`GET /api/platform-product-categories?parent_id={uuid|null}&q={keyword}&active_only=true`
|
||||
|
||||
返回 header 平台的商品类目。`parent_id`(懒加载树)与 `q`(搜索)不能同时使用。每项有 `id`、`name`、`path`、`has_children`、`pricing_rule_status`:
|
||||
|
||||
- `available`:当前市场、履约模式下存在唯一有效映射和正式佣金规则;
|
||||
- `missing`:缺少有效映射或规则;
|
||||
- `ambiguous`:多个同优先级映射,普通用户不能猜测。
|
||||
|
||||
不暴露佣金类目 ID 让客户端自行映射。
|
||||
|
||||
`GET /api/pricing/catalog?fulfillment=rfbs&store_id={uuid}`
|
||||
|
||||
`store_id` 省略时取默认店铺;传入时必须属于当前工作空间与 header 平台。仅返回当前已启用价卡、服务商和运输方式:
|
||||
|
||||
```json
|
||||
{ "data": { "providers": [{
|
||||
"id": "uuid", "code": "CEL", "name": "CEL",
|
||||
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "effective_from": "2026-09-01T00:00:00Z" },
|
||||
"transport_methods": [
|
||||
{ "id": "uuid", "code": "air", "name": "陆空特快", "speed_label": "5–10 天" },
|
||||
{ "id": "uuid", "code": "land_air", "name": "陆空标准", "speed_label": "10–15 天" },
|
||||
{ "id": "uuid", "code": "land", "name": "陆运经济", "speed_label": "15–25 天" }
|
||||
]
|
||||
}] } }
|
||||
```
|
||||
|
||||
若规则仍为 `draft` 或未通过真实账单复核,catalog 不返回它;试算请求返回 `422 PRICING_RULES_UNAVAILABLE`。
|
||||
|
||||
`GET /api/pricing/exchange-rate?base_currency_code=CNY"e_currency_code=RUB`
|
||||
|
||||
读取最新 CBR `official_reference`:
|
||||
|
||||
```json
|
||||
{ "data": {
|
||||
"id": "uuid", "base_currency_code": "CNY", "quote_currency_code": "RUB", "rate": "12.33430000",
|
||||
"source": "cbr", "kind": "official_reference", "effective_on": "2026-09-02",
|
||||
"fetched_at": "2026-09-02T02:00:00Z", "is_stale": false
|
||||
} }
|
||||
```
|
||||
|
||||
没有系统汇率返回 `404 SYSTEM_EXCHANGE_RATE_NOT_FOUND`;超过 72 小时仍返回但 `is_stale=true`,前端必须明确提示。
|
||||
|
||||
## 3. 即时试算
|
||||
|
||||
`POST /api/pricing/quote-previews`
|
||||
|
||||
Headers:`Idempotency-Key` 必填。接口不创建 `pricing_quotes`;`preview_id` 最多可用于后续 SKU 确认 15 分钟,确认时仍会重新计算。
|
||||
要求 `Idempotency-Key`。成功返回 201;同键同输入重放原响应,同键不同输入返回 409。
|
||||
|
||||
```json
|
||||
{
|
||||
"store_id": "uuid",
|
||||
"store_id": "<store UUID>",
|
||||
"platform_product_category_id": "<category UUID>",
|
||||
"fulfillment": "rfbs",
|
||||
"platform_product_category_id": "uuid",
|
||||
"purchase_cost_cny": "80.00",
|
||||
"package": { "weight_kg": "0.990", "length_cm": "30", "width_cm": "31", "height_cm": "11" },
|
||||
"binding": {"profile_id":"<profile UUID>","variant_id":"<variant UUID>","client_key":null},
|
||||
"purchase_cost_cny": "28.00",
|
||||
"package": {"shipping_weight_g":"380","length_mm":"220","width_mm":"160","height_mm":"90","status":"confirmed"},
|
||||
"pricing": {
|
||||
"pricing_policy_id": "uuid",
|
||||
"transport_method_ids": ["uuid", "uuid", "uuid"],
|
||||
"domestic_shipping_and_label_fee_cny": "5.00",
|
||||
"ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02",
|
||||
"desired_markup_rate": "0.20", "list_price_discount_rate": "0.10",
|
||||
"fx": { "mode": "system" },
|
||||
"temporary_commission_rate": null
|
||||
"pricing_policy_id": "<policy UUID>",
|
||||
"rate_card_id": "<rate-card UUID>",
|
||||
"transport_method_ids": ["<transport UUID>"],
|
||||
"default_transport_method_id": "<transport UUID>",
|
||||
"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.50","list_price_discount_rate":"0.20",
|
||||
"mode":"target_markup","selling_price":null,
|
||||
"fx":{"mode":"policy_fixed"},"temporary_commission_rate":null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
- 独立试算 `binding=null`;SKU 必须提供 profile,已有 variant_id 与新建 client_key 二选一。验证资源归属,已上架 SKU 不接受新报价。
|
||||
- 包装输入是编辑草稿合并后的有效 g/mm 值,必须已确认;尺寸最多三位小数,重量至少 1 g。保存时从实际持久化草稿重新合并并核对,不能仅信任客户端摘要。
|
||||
- `mode=target_markup`:目标利润=采购成本×加价率;求解销售价。`fixed_sale`:selling_price 必填,按该售价核算利润。
|
||||
- 百分比费用以销售额为基数;除加价率外每项小于 1、最多六位小数。引擎拒绝总扣费率≥1。划线价=销售价÷(1−折扣率)。
|
||||
- 枚举运输方式×产品类型×佣金档,按最终币种舍入后回验货值、佣金、重量和体积重边界。默认运输不可用时 recommended_option_id=null,不替换为另一方式。
|
||||
- `fx.mode=system` 读取 PostgreSQL 最新 CBR;`policy_fixed` 必须绑定有固定汇率的方案;`one_time_override` 必须明确 rate 和 reason。固定汇率取方案值,不信任客户端覆盖。采用的汇率固定在本次预览中,15 分钟内确认不重新拉取外部汇率。
|
||||
- 只有不存在有效正式佣金规则且无方案或方案允许时,才能 temporary_commission_rate。已有正式规则禁止覆盖。
|
||||
|
||||
- `store_id` 可省略,服务端取默认店铺;非空时必须与 header 平台一致。
|
||||
- `pricing_policy_id` 非空时方案提供默认值;请求中的费用、目标、运输方式和汇率仅覆盖本次试算,不修改方案。
|
||||
- 无方案时所有费用、费率、目标、汇率模式和至少一个运输方式必填。
|
||||
- `fx.mode` 为 `system`、`policy_fixed` 或 `one_time_override`;后者必填 `rate`,建议附 `reason`。`policy_fixed` 只对 `fx_strategy=fixed` 方案有效。
|
||||
- 正式佣金规则缺失时,仅无方案即时试算或方案 `allow_commission_override=true` 可传 `temporary_commission_rate`。它必须被明确标记为临时覆盖。
|
||||
- 未指定运输方式时取方案已启用的比较方式;仍为空则取方案默认运输方式。它们必须属于同一启用价卡。
|
||||
响应包含 `preview_id,expires_at,input_hash,recommended_option_id,options,unavailable_options,context`。
|
||||
每个 option 包含目标币种销售/划线价、CNY/RUB 利润、计费重、佣金/物流引用、费用明细、warnings 和 can_confirm。
|
||||
亏损可查看,但 can_confirm=false;判定使用未舍入利润,显示为 0.00 的微亏也不能确认。
|
||||
context 保存规则版本、实际汇率与来源,以及系统汇率记录 ID / 日期。参数/规则快照由服务端持久化。
|
||||
|
||||
返回:
|
||||
预览按用户、工作空间、门店、SKU、规范化输入与规则摘要绑定,15 分钟有效。Worker 删除过期两天以上的预览。
|
||||
|
||||
```json
|
||||
{ "data": {
|
||||
"preview_id": "opaque-short-lived-id", "expires_at": "2026-09-02T08:15:00Z",
|
||||
"context": {
|
||||
"store_id": "uuid", "platform_id": "uuid", "market_code": "RU",
|
||||
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17" },
|
||||
"exchange_rate": { "rate": "12.33430000", "source": "cbr", "effective_on": "2026-09-02", "is_stale": false }
|
||||
},
|
||||
"recommended_option_id": "candidate-1",
|
||||
"options": [{
|
||||
"id": "candidate-1", "is_valid": true,
|
||||
"transport_method": { "id": "uuid", "code": "land_air", "name": "陆空标准" },
|
||||
"logistics_product_type": { "code": "budget", "name": "Budget" },
|
||||
"commission": { "rate": "0.120000", "source": "manual_rule", "version": "2026-09", "price_range": { "min": null, "max": "1500.00", "max_inclusive": true } },
|
||||
"prices": { "selling_price_rub": "999.00", "list_price_rub": "1110.00", "merchandise_value_cny": "80.99" },
|
||||
"weights": { "actual_weight_kg": "0.990", "volume_weight_kg": null, "billable_weight_kg": "0.990", "billing_basis": "actual_only" },
|
||||
"fees": {
|
||||
"purchase_cost_rub": "986.74", "logistics_fee_cny": "49.6600", "logistics_fee_rub": "612.55",
|
||||
"domestic_fee_cny": "5.0000", "domestic_fee_rub": "61.67", "commission_fee_rub": "119.88",
|
||||
"ad_fee_rub": "29.97", "last_mile_fee_rub": "19.98", "withdrawal_fee_rub": "9.99", "other_fee_rub": "19.98",
|
||||
"total_cost_rub": "1860.76", "profit_rub": "-861.76", "gross_margin_rate": "-0.862"
|
||||
},
|
||||
"calculation_trace": { "rounding": [], "formula_steps": [], "rule_versions": [] }, "warnings": []
|
||||
}],
|
||||
"unavailable_options": [{ "transport_method_id": "uuid", "code": "PACKAGE_DIMENSION_EXCEEDED", "message": "最长边超过该方式限制" }]
|
||||
} }
|
||||
```
|
||||
## 3. 商品保存时确认
|
||||
|
||||
数值只示意响应形状,非真实 CEL 结果。每种运输方式独立成功或列出不可报价原因;一个方式失败不阻断其他方式。推荐仅按完整成本、目标利润和规则有效性计算,不承诺时效。
|
||||
|
||||
缺少成本、有效包装规格、类目、汇率或佣金规则时返回 `422` 和可修复字段;不返回伪造价格。
|
||||
|
||||
## 4. 定价方案
|
||||
|
||||
方案属于 `store + platform`,币种从店铺读取。方案资源的核心形状:
|
||||
`PUT /products/{id}/editor-draft` 沿用商品/profile revision 的 `If-Match` 和 `Idempotency-Key`,每个 variant 增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid", "store_id": "uuid", "platform_id": "uuid", "name": "CEL 常规", "note": null,
|
||||
"status": "active", "is_default": true, "fulfillment": "rfbs",
|
||||
"rate_card": { "id": "uuid", "version": "CEL-OZON-RFBS-V6.17", "provider": { "code": "CEL", "name": "CEL" } },
|
||||
"default_transport_method_id": "uuid", "comparison_transport_method_ids": ["uuid", "uuid"],
|
||||
"fees": { "domestic_shipping_and_label_fee_cny": "5.00", "ad_rate": "0.03", "last_mile_rate": "0.02", "withdrawal_rate": "0.01", "other_rate": "0.02" },
|
||||
"desired_markup_rate": "0.20", "list_price_discount_rate": "0.10", "allow_commission_override": false,
|
||||
"fx": { "strategy": "system", "fixed_cny_to_rub_rate": null }, "revision": 1
|
||||
"pricing_draft": "本次 PricingParameters 对象或 null",
|
||||
"pricing_selection": {"preview_id":"<UUID>","option_id":"<UUID>","input_hash":"<64-char SHA256>"}
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/stores/{store_id}/pricing-policies?status=active&cursor={opaque}&limit=20`
|
||||
两个字段均可省略以兼容既有客户端;读取 listing 返回 `pricing_draft,pricing_result,pricing_valid`。
|
||||
|
||||
按 `is_default DESC, updated_at DESC, id DESC` 返回方案。路径店铺必须属于当前工作空间与 header 平台。
|
||||
确认在现有商品事务中执行:先保存草稿和新 SKU,再核对实际有效输入、用户绑定、有效期、审核规则摘要、币种及候选资格,最后创建不可变报价/候选快照并切换 listing 售价引用。
|
||||
新 SKU 的 client_key 在同一事务映射到真实 ID。独立预览、其他用户或其他 SKU 的预览不能用于确认。
|
||||
|
||||
`GET /api/pricing-policies/{policy_id}` 返回完整方案。
|
||||
- 携带失效确认意图:409 `PRICING_PREVIEW_STALE`,details 定位具体 SKU,整笔保存回滚。
|
||||
- 不携带确认意图:允许保存未定价草稿;输入/币种变化使报价失效。用户可在冲突弹窗明确选择“仅保存草稿,稍后定价”。
|
||||
- 未变化的已确认报价继续使用原快照,无时间过期,不重新读规则或汇率。方案更新/归档不改变其价格。
|
||||
- 发布校验要求 pricing_valid=true,无有效报价返回 `PRICING_REQUIRED`;已导入 SKU 沿用既有不可再次首次上架边界。
|
||||
- 数据库复合外键保证 quote/profile/product/variant/store/platform 归属,RLS 隔离租户。确认快照拒绝 UPDATE;成本、包装、类目、店铺或币种变化触发失效。
|
||||
|
||||
`POST /api/stores/{store_id}/pricing-policies`
|
||||
## 4. 门店方案
|
||||
|
||||
Headers:`Idempotency-Key` 必填。请求体采用上方形状的可写字段:创建时 `rate_card_id` 代替 `rate_card`,运输方式以 UUID 数组提交。首个 active 方案强制默认;后续 `is_default=true` 在同一事务取消旧默认。
|
||||
| 方法 | 路径 | 约束 |
|
||||
|---|---|---|
|
||||
| GET | `/stores/{id}/pricing-policies` | 返回当前门店活动方案数组 |
|
||||
| POST | `/stores/{id}/pricing-policies` | Idempotency-Key;成功 201 |
|
||||
| PATCH | `/pricing-policies/{id}` | Idempotency-Key + If-Match(带引号的 revision);冲突 409 |
|
||||
| POST | `/pricing-policies/{id}/set-default` | Idempotency-Key;门店锁保证唯一默认;成功 200 |
|
||||
| DELETE | `/pricing-policies/{id}` | Idempotency-Key;归档并清除默认标志,返回 `{id,archived:true}` |
|
||||
|
||||
`PATCH /api/pricing-policies/{policy_id}`
|
||||
写入 `{name,note,parameters,allow_commission_override}`。只保存费用、加价、折扣、固定/系统汇率与运输偏好;采购价、包装、手动售价及临时佣金不属于方案持久字段。
|
||||
方案的默认和比较运输必须属于同一可用费率卡;正式环境仅允许已审核卡。历史快照不会被方案修改;尚未确认的预览因方案版本改变要求重算。
|
||||
|
||||
Headers:`If-Match`、`Idempotency-Key` 必填。仅改提交字段;变更履约、价卡或运输方式时服务端重新校验所有关联。变更只影响未来试算/SKU 新确认,绝不重算旧报价。
|
||||
## 5. 数据启用与错误
|
||||
|
||||
`POST /api/pricing-policies/{policy_id}/set-default`
|
||||
迁移中的 CEL 目录为 `draft`,覆盖六种产品类型、三种运输方式,香港空运不纳入。真实账单复核、有效日期/审核人以及平台类目佣金映射仍是正式使用前置条件,接口不会回退到示例费率或固定佣金。
|
||||
|
||||
Headers:`Idempotency-Key` 必填。仅 active 方案可设默认。
|
||||
商品编辑联调期间,API 在 `NODE_ENV=development` 时默认允许 draft 费率卡参与方案保存、预览与草稿报价确认;可设 `PRICING_ALLOW_DRAFT_RATES=false` 关闭。测试环境只有显式设 `PRICING_ALLOW_DRAFT_RATES=true` 才开启;生产环境始终关闭。该模式不修改费率卡状态、不生成审核记录。目录的 `rate_cards[].development_only` 和预览的 `context.development_only` 标识试算性质;基于此类预览确认的 SKU 报价可在编辑页查看,但首次上架校验必须返回 `PRICING_RATE_REVIEW_REQUIRED`,直至使用已审核费率重新试算确认。佣金和汇率仍按现有输入规则处理。
|
||||
|
||||
`DELETE /api/pricing-policies/{policy_id}`
|
||||
### 系统管理员审核 CEL 费率
|
||||
|
||||
Headers:`Idempotency-Key` 必填。将方案置为 `archived`,不物理删除快照。删除默认方案时,若还有 active 方案则返回 `422 DEFAULT_PRICING_POLICY_REPLACEMENT_REQUIRED`,必须先选择替代默认;否则允许无默认方案。
|
||||
系统管理「物流费率审核」使用以下接口;全部要求登录且具备实时有效的 `system_admin_grants` 授权,普通门店用户返回 403 `SYSTEM_ADMIN_REQUIRED`:
|
||||
|
||||
## 5. SKU 确认衔接
|
||||
| 方法 | 路径 | 行为 |
|
||||
|---|---|---|
|
||||
| GET | `/system/pricing/rate-cards?platform_id={uuid}` | 查看该平台 RU/rFBS 草稿及已启用费率,含产品类型、规则、审核记录与 `rules_hash` |
|
||||
| GET | `/system/pricing/rate-cards/{id}` | 重读单个费率版本 |
|
||||
| POST | `/system/pricing/rate-cards/{id}/reviews` | `Idempotency-Key` UUID 必填;原子保存审核证据并启用费率,成功 201 |
|
||||
|
||||
即时试算不落 `pricing_quotes`。商品编辑模块将定义“确认 SKU 报价”:接收 `preview_id`、SKU、最终选中候选及可选一次性覆盖,重新计算后以事务写入 `pricing_quotes + pricing_quote_options`,固化输入、汇率、佣金、CEL 规则版本和费用拆分。
|
||||
审核请求包含 `rules_hash`、`source_reference`(资费合同/实际账单批次)、至少 20 字的 `review_note`、`effective_from`(含时区 ISO 时间)和 `samples`。每条样本包含 `rule_id`、唯一的 `invoice_reference`、`merchandise_value_cny`、包装重量 g / 长宽高 mm、`billed_logistics_cny`。每种 CEL 产品类型至少一条,合计不少于六条。服务端使用与正式报价相同的包装适用性和运费函数复算;样本货值须命中对应类型,账单运费与规则计算差额不得超过 0.01 CNY。真实账单来源和口径仍由审核人负责确认,系统不会凭示例数字自行审核。
|
||||
|
||||
客户端不能提交预览中的价格、物流费或规则 ID 作为确认事实。预览过期、规则/汇率更新或包装规格变化时,服务端拒绝旧预览并要求重新试算。临时切换方案或运输方式只进入本次 SKU 快照,绝不修改默认方案。
|
||||
仅支持 Ozon/CEL 草稿版本。规则必须仍为 16 条 draft,类型为指定六种,且 `rules_hash` 与管理员查看时一致;否则返回 409/422。通过后在同一数据库事务保存不可修改的审核记录、把规则与费率卡改为 active、设置审核人与生效日期。若生效日期在未来,直到该时间才出现在卖家端可用费率中。数据库拒绝改写已审核的费率卡、产品类型、运输方式和规则;修改资费应新建版本重新审核,不覆盖已确认 SKU 报价。已启用费率不提供再次审核入口。
|
||||
|
||||
## 6. 后台维护边界
|
||||
系统管理不会自动配置平台类目佣金映射;缺少正式佣金规则时,卖家试算仍需满足临时佣金覆盖条件。当前 CEL 种子保持 draft,尚无真实账单证据时不得提交启用。
|
||||
|
||||
普通卖家端不提供佣金类目、佣金规则、物流价卡或汇率历史的写接口。它们由受控运营后台、seed/migration 或 Worker 维护;若将来开放运营 API,必须独立鉴权、审计、版本发布和回滚。
|
||||
| HTTP / code | 用户动作 |
|
||||
|---|---|
|
||||
| 422 VALIDATION_ERROR | 修正输入格式或缺项 |
|
||||
| 422 PRICING_RULES_UNAVAILABLE | 配置并审核物流费率 |
|
||||
| 422 PRICING_COMMISSION_UNAVAILABLE | 配置正式映射,或使用允许临时佣金的方案 |
|
||||
| 422 PRICING_FX_UNAVAILABLE | 等待 CBR 同步,或明确采用临时/方案固定汇率 |
|
||||
| 409 PRICING_PREVIEW_STALE | 重算、比较并重新应用,或显式仅保存草稿 |
|
||||
| 409 VERSION_CONFLICT | 重新读取版本后修改 |
|
||||
|
||||
独立上下文、汇率信息整合在 catalog;本期不开放原规划的独立 context、exchange-rate、pricing-confirms 路由和报价历史列表。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 商品编辑与首次上架 API
|
||||
|
||||
> M3.0~M3.4 已实现编辑上下文、草稿、SKU、来源素材、类目和动态属性。平台目录刷新方案第 1~4 步已把类目树、类型属性、字典读取及发布预检迁至平台级缓存:编辑器按需刷新,首次发布预检只刷新当前类型和实际使用的字典。AI 填报任务已实现;定价确认和真正调用 Ozon 的首次发布 job 仍未实现。
|
||||
> M3.0~M3.4 已实现编辑上下文、草稿、SKU、来源素材、类目和动态属性。平台目录刷新方案第 1~4 步已把类目树、类型属性、字典读取及发布预检迁至平台级缓存:编辑器按需刷新,首次发布预检只刷新当前类型和实际使用的字典。AI 填报任务及随商品保存的定价确认已实现;真正调用 Ozon 的首次发布 job 仍未实现。
|
||||
|
||||
> 此处的商品始终是采集箱内的 Atlas 草稿。所有平台范围接口要求 `X-Atlas-Platform-Id`。商品通用资料可以跨平台复用;`product_platform_profile`、SKU 首次导入状态和在线商品不能跨平台/门店复用。
|
||||
|
||||
@@ -248,17 +248,13 @@ Body:`{ "variant_ids": ["uuid", "uuid"] }`,最多 100 个且不能重复。
|
||||
|
||||
## 4. SKU 定价确认
|
||||
|
||||
`POST /api/product-platform-profiles/{profile_id}/variants/{variant_id}/pricing-confirms`
|
||||
定价确认与 `PUT /api/products/{product_id}/editor-draft` 同一事务完成。每个 SKU 可携带 `pricing_draft` 和 `pricing_selection: { preview_id, option_id, input_hash }`;新 SKU 使用 `client_key` 绑定预览,保存时映射为真实 ID。独立试算不能直接确认到 SKU。
|
||||
|
||||
Headers:`Idempotency-Key` 必填。Body:
|
||||
服务端核对预览归属、15 分钟有效期、当前采购价、有效包装、类目、门店、币种及规则版本,再写入不可变 `pricing_quotes`、全部候选和 listing 价格。任何一个明确选择的报价失效,整个商品保存回滚。客户端可提示用户重新计算,或经用户选择清除本次确认意图后仅保存草稿。
|
||||
|
||||
```json
|
||||
{ "preview_id": "opaque-id", "selected_option_id": "candidate-1", "one_time_overrides": null }
|
||||
```
|
||||
没有报价时仍可保存商品,但首次发布预检返回 `PRICING_REQUIRED`。已确认报价不会随预览到期而失效;采购、包装、目标门店/类目、履约或门店币种改变后须重新确认。调整默认方案、汇率或费率不会反向改写历史快照。详见[定价 API](./pricing.md)。
|
||||
|
||||
服务端重新解析 SKU 的有效包装规格、验证预览仍未过期且配置/汇率/规则未变化,重新计算后写入 `pricing_quotes`、全部 `pricing_quote_options` 和当前平台 listing 的售价/划线价/币种/报价引用。成功返回 `201` 与不可变报价摘要。
|
||||
|
||||
包装规格缺失或 `ambiguous`、预览失效、候选不属于预览、目标店铺不一致、物流不适用时返回 `422`,不覆盖既有价格。临时更换方案或运输方式仅写入本次报价,不改门店默认方案。
|
||||
开发环境可按定价 API 的草稿费率模式完成商品编辑联调;这种报价即使已保存,也会在首次发布预检返回 `PRICING_RATE_REVIEW_REQUIRED`。费率完成正式审核后,须用已审核卡重新试算并确认 SKU。
|
||||
|
||||
## 5. 素材选择与水印派生
|
||||
|
||||
@@ -319,3 +315,10 @@ Headers:`Idempotency-Key` 必填。Body:
|
||||
## 8. 已替代的旧草案
|
||||
|
||||
本文替代 [`../product/api.md`](../product/api.md) 中与商品编辑有关的路径。旧草案保留为历史背景,实施以本文件、[`pricing.md`](./pricing.md) 与 [`collection-and-extension.md`](./collection-and-extension.md) 为准。
|
||||
|
||||
### 2026-09-28 SKU 定价确认修订
|
||||
|
||||
草稿的每个 variant 可携带 `pricing_draft` 和 `pricing_selection`,与商品保存原子提交。
|
||||
读取 listing 返回 `pricing_draft`、`pricing_result` 和 `pricing_valid`。
|
||||
确认失败整笔回滚并定位 SKU;无报价允许保存,但发布预检返回 `PRICING_REQUIRED`。
|
||||
输入变化使报价失效;已确认快照保留。具体 DTO 与错误见 pricing.md 实施修订。
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# SKU 定价试算与确认:产品及实施提案
|
||||
|
||||
> 2026-09-28 · 维护者已批准本方案,进入实施。第 5 节的契约变更同步到 API 文档与共享契约;原型示例数据不作为生产费率。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
建议采用「SKU 就地自动试算 + 详细试算弹窗 + 商品页统一保存」。采购价失焦或按 Enter 后,用该 SKU 当前方案计算并回填销售价、划线价和预估净利润;保留“计算”按钮供主动重算。用户可以关闭自动试算。弹窗用来查看费用、比较物流、调整本次参数,点击“应用到 SKU”回到未保存表单。商品页“保存”才确认报价并持久化。
|
||||
|
||||
把用户的两种意图分清:输入成本时需要立即看到结果;保存商品时才承诺这次售价。避免每个 SKU 都弹出一次确认框,也避免一次失焦就写入正式报价。
|
||||
|
||||
## 2. 已阅读的依据与当前差距
|
||||
|
||||
- 新项目:`requirements/pricing-tool.md`、`requirements/cel-ozon-rfbs-rates-review.md`、`api/pricing.md`、`api/product-editor-and-publishing.md`、`architecture/exchange-rates.md`,以及现有 pricing 表和 SKU 表单。
|
||||
- 旧项目:`studio/src/pages/trial/TrialPage.tsx` 的「2 价格试算」、`TrialPricingPanel.tsx`、`studio/src/pricing/pricing.ts`。
|
||||
- 旧页面通过 `recalc()` 在参数变化时计算,并通过 `onSave()` 保存参数、售价、划线价与汇率。左右布局、包装数据带入、费用明细和中俄币种对照值得保留。
|
||||
- 旧公式把采购成本上的加价率叫“净利率”,使用固定 12%/18% 佣金和 3.5% 其他扣费;low/high/high2 不能表达新的 CEL 货值类型与运输方式,不适合直接迁入 Atlas。
|
||||
- Atlas 目前的 SKU 摘要已有采购价输入、只读售价和“净利润 —”;定价工具还是占位页。已有 pricing 基础表,但预览服务、报价确认、汇率 Worker 和方案管理尚需实现。
|
||||
- 现有 `pricing-confirms` 契约独立落库,而本提案建议随草稿保存确认;不能只改前端按钮文案,必须先调整保存事务与契约。
|
||||
|
||||
## 3. SKU 与弹窗交互
|
||||
|
||||
### 3.1 SKU 卡片
|
||||
|
||||
摘要行保留:采购价 CNY、销售价(目标店铺币种)、预估净利润 CNY、“计算”、“试算详情”。第二行以较轻的文字显示方案、采用的运输方式、报价状态;包装缺失时显示具体缺项和补充入口。
|
||||
|
||||
自动算价默认开启,作为用户的编辑偏好。有效值发生变化且字段失焦/按 Enter 后触发,不在每次键入时创建请求。采购价、有效包装规格、目标店铺、类目或 SKU 参数变化均使之前的结果失效;同值失焦不重复请求。包装继承关系仍由 SPU/SKU 原有模型解析。
|
||||
|
||||
SKU 状态为:未试算 → 计算中 → 已试算·待保存 → 已确认。缺少资料、请求失败或参数已变时显示可修复状态,不把旧利润继续当成当前结果。
|
||||
|
||||
“按方案计算全部”逐 SKU 返回结果;成功项更新表单,失败项保留输入并标出原因。手动售价不被批量推荐价覆盖,手动项按原售价重新核算利润。
|
||||
|
||||
### 3.2 手动售价
|
||||
|
||||
用户可直接修改售价,系统切换到“手动售价”模式,按这个价格计算物流适用性、佣金及利润。此后修改采购价或包装数据,保留售价,仅更新成本和利润。“使用建议售价”是显式退出手动模式的动作。
|
||||
|
||||
预估净利润只读,不接受手填。标签使用“预估净利润”;它是扣除已配置费用后的预计利润,不是账单结算利润。低于目标或为负时明确显示差额。首期建议亏损报价可保留为草稿,但不得自动成为可发布报价;后续若允许强制确认亏损价,需另行确定权限与确认规则。
|
||||
|
||||
### 3.3 试算弹窗
|
||||
|
||||
- 顶部:SKU 名称、当前目标店铺及币种、定价方案。进入时复制该 SKU 的表单输入与参数,取消不修改 SKU。
|
||||
- 左侧:采购价、有效包装重量/尺寸、目标加价率、划线折扣率;高级费用折叠展示国内运费/贴单、广告、尾程、提现、其他费、汇率来源和临时覆盖。
|
||||
- 页面中的加价率、折扣率、广告、尾程、提现、其他费用率及临时佣金率均按百分数展示和输入,统一保留两位小数;API 仍使用十进制比例字符串,例如页面 `50.00%` 对应请求 `"0.5"`。
|
||||
- 右侧:大号销售价、划线价、预估净利润及利润占销售额比例;费用明细与规则来源按需展开。
|
||||
- 物流比较:展示方案默认运输方式,同时允许比较其他可用方式。最低价只是标签,不自动替换方案指定的运输方式;指定方式不可用时要求用户选择。
|
||||
- 修改参数后延迟约 400 ms 试算,输入未完整时不发送请求;取消旧请求并校验返回版本,防止旧结果覆盖新参数。
|
||||
- 底部:取消 / 应用到 SKU。应用只更新当前 SKU 的待保存报价和本次参数,不修改门店默认方案。
|
||||
- 用户在弹窗修正包装时,明确写入该 SKU 的包装覆盖值,不修改 SPU 默认规格;原型只演示数值,正式组件需显示继承来源并提供恢复继承操作。
|
||||
|
||||
### 3.4 独立定价工具与定价方案
|
||||
|
||||
独立“即时试算”和 SKU 弹窗使用同一输入组件、结果组件与服务端引擎。独立试算不要求已有商品,不提供“应用到 SKU”。
|
||||
|
||||
“定价方案”负责门店范围的默认扣费、利润目标、折扣、汇率策略和运输偏好;每个门店最多一个默认方案。新 SKU 首次计算时采用它,已有 SKU 继续使用自己的方案/本次覆盖。方案编辑不重算历史已确认报价。将临时参数另存方案应是独立明确动作,采购价和包装尺寸不随之保存。
|
||||
|
||||
## 4. 保存、变化与失效
|
||||
|
||||
| 操作或事件 | 表单结果 | 已确认快照 |
|
||||
|---|---|---|
|
||||
| 成本失焦,输入完整 | 自动预览并回填,标记待保存 | 保留 |
|
||||
| 弹窗点击应用 | 覆盖当前 SKU 的待保存报价 | 保留 |
|
||||
| 手改售价 | 保留手动价,重算费用/利润 | 保留 |
|
||||
| 包装/类目/店铺变化 | 旧预览失效;重算或提示缺项 | 保留历史,新草稿标记未满足发布报价条件 |
|
||||
| 方案或系统汇率更新 | 不静默替换已确认价格 | 保持原输入、版本、汇率与结果 |
|
||||
| 点击商品页保存 | 服务端核对并确认本次报价 | 生成新不可变快照,并切换 listing 引用 |
|
||||
| 预览过期或规则在保存前变化 | 重算并展示新旧价格/费用差异 | 未经重新接受不替换 |
|
||||
| 取消弹窗/离开未保存页面 | 放弃相应临时修改 | 不变 |
|
||||
|
||||
保存不能因为某个尚未定价的 SKU 阻止保存整个商品草稿;该 SKU 可保存为“待定价”,发布时阻断。对于携带了报价确认意图的 SKU,只要存在过期/版本冲突,就返回具体 SKU 错误,整个保存事务回滚,不悄悄丢弃确认意图。界面可提供“仅保存草稿,稍后定价”的明确后续操作。
|
||||
|
||||
已确认报价不因时间经过而自动过期;15 分钟有效期只作用于未确认预览。影响报价的商品输入改变后,当前草稿必须重新确认。发布时检查快照与待发布输入一致,已导入 SKU 沿用既有编辑边界。
|
||||
|
||||
## 5. 后端及契约建议
|
||||
|
||||
维护者已批准以下变化:
|
||||
|
||||
1. **预览上下文**:扩展现有 preview 契约以识别 `profile_id + variant_id`,并接收明确的未保存采购价、包装覆盖和本次参数。服务端校验资源归属,并按与草稿保存相同的逻辑合并包装。未保存的新 SKU 需支持当前商品内的客户端草稿标识,保存时原子映射到真实 SKU ID;或首版在首次保存后才开放报价确认。
|
||||
2. **两种定价意图**:支持目标加价率求售价,以及固定售价反算利润。输出统一的售价、划线价、利润、费用和规则快照。金额使用十进制定点计算,HTTP 仍传字符串。
|
||||
3. **预览标识**:预览绑定用户/工作空间、平台/店铺、SKU、规范化输入摘要、规则/方案/汇率版本及有效期。可保存在有过期清理的 PostgreSQL 预览表;不得只凭客户端提供的金额确认,不以 Redis 作为确认事实来源。
|
||||
4. **统一保存**:在 `editor-draft` 中增加每个 SKU 的可选 `pricing_selection`(preview id、候选 id、输入摘要),并为包含报价确认的保存提供幂等键。草稿、SKU、报价/候选快照、listing 售价与报价引用在同一事务提交,沿用 product/profile revision 乐观锁。
|
||||
5. **复用确认逻辑**:将当前规划的 `pricing-confirms` 行为放入可被草稿保存事务调用的领域服务。是否保留独立 HTTP 入口需要统一决策;不能采用前端“先保存草稿,再逐个确认报价”来伪装原子保存。
|
||||
6. **严格的同归属约束**:补齐报价与 product/profile/variant/listing 的复合外键和工作空间 RLS;现有基础 DDL 中 `pricing_quotes.product_variant_id` 仅是逻辑关联,不能直接当作完整领域约束。
|
||||
7. **确认校验**:检查预览输入和待保存草稿一致、版本可用、候选有效、币种及舍入后结果一致;不在确认请求中重新抓取外部汇率。未变化的已确认 SKU 不重复创建报价。
|
||||
8. **币种与单位**:售价和划线价以目标店铺支持的币种持久化,CNY 成本为统一输入,RUB 阶梯按本次汇率判定;明确现有 RUB-only 报价列与 CNY 店铺售价的映射。界面可切换展示单位,内部统一 g/mm 或规范化 kg/cm,禁止混算。
|
||||
|
||||
## 6. 计算引擎
|
||||
|
||||
复用旧项目的拆分方式与展示经验,重写纯函数计算器。服务端引擎是业务事实来源,前端只负责预览展示。所有入口调用同一引擎。
|
||||
|
||||
目标加价率基于采购成本:目标利润 = 采购成本 × 加价率。固定成本包含采购、最终跨境物流、国内运费/代贴单及固定附加费。各百分比费用基于最终销售价;划线价 = 销售价 ÷ (1 − 划线折扣率)。
|
||||
|
||||
引擎枚举“运输方式 × CEL 产品类型 × 佣金档”,计算售价后回验货值范围、重量、尺寸、计抛、佣金边界和最终舍入价格。固定售价模式直接以输入售价确定区间并计算利润。无自洽候选时返回原因,不任意选档或无限迭代。
|
||||
|
||||
CEL 表尚需真实账单复核;佣金映射与规则也需审核版本。开发可先使用明确标记的测试规则与方案固定汇率推进全链路;正式环境缺少有效规则时禁止伪造价格。CBR 更新、规则发布和过期提示按现有文档落地。
|
||||
|
||||
## 7. 开发顺序与验收
|
||||
|
||||
1. **确认交互及契约**:先确认自动失焦回填、手动价保护、随草稿保存这三个选择;再更新需求/API/contracts。影响 Web、API、Worker 汇率任务、database 和 contracts,不复用旧 SQLite 或无鉴权 API。
|
||||
2. **引擎和规则**:实现 Decimal 计算、CEL/佣金版本读取、单位转换、候选枚举和固定价反算;测试价格跨档、重量/体积重边界、取整后跨档、总费率 ≥100%、负利润及不可报价。
|
||||
3. **首个垂直交付**:一个已保存 SKU、一个门店方案、采购价失焦预览、弹窗改参数、统一保存、刷新重读及发布预检引用报价。此阶段就验证租户隔离、旧响应丢弃、幂等重放、并发版本冲突、确认失败整笔回滚、方案变更不改历史报价。
|
||||
4. **扩展编辑效率**:多 SKU 批量试算、缺项定位、包装继承、新 SKU 保存映射、手动售价保护、预览过期后的差异接受。
|
||||
5. **完整工具页**:复用同一组件和引擎补独立试算、方案 CRUD、默认方案、汇率同步及真实账单复核。试算记录列表后置。
|
||||
|
||||
首轮验收:修改采购价后能在 SKU 行看到新售价与利润;弹窗取消不改草稿;应用只改对应 SKU;成本变化不覆盖手动价;保存后重读能解释该价格所用的包装、汇率、佣金和物流规则;失败 SKU 不阻塞其他 SKU 的试算;缺少报价的草稿可保存但不可发布。
|
||||
+21
-4
@@ -1,12 +1,12 @@
|
||||
# Atlas Seller Kit 交接说明
|
||||
|
||||
> 更新:2026-09-27。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.47 节为准。
|
||||
> 更新:2026-09-28。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.47 节为准。
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
这是一个全新的云端产品,不是 `ozon-seller-kit` 的本地版重构。目标用户可拥有多个门店,每个门店只能绑定一个平台;平台自身可支持多个市场、国家、语言和币种。因此语言、币种、类目和发布规则不能写死为 Ozon 或中文/俄文。
|
||||
|
||||
pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享契约、数据库包和本地基础设施。认证、工作空间隔离、门店/素材、采集箱、商品编辑草稿、平台级商品参考目录、发布预检和商品 AI 填报已有实现;真正的 Ozon 首次发布、在线商品同步和定价确认仍未形成完整闭环。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件;复用纯规则前须适配多租户和云端边界。
|
||||
pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、共享契约、数据库包和本地基础设施。认证、工作空间隔离、门店/素材、采集箱、商品编辑草稿、平台级商品参考目录、发布预检和商品 AI 填报已有实现;SKU 定价试算与确认、在线商品同步已有实现,真正的 Ozon 首次发布仍待接入。不要从旧项目复制 SQLite 模型、无鉴权 API、旧 `/trial/:id` 或商品编辑页组件;复用纯规则前须适配多租户和云端边界。
|
||||
|
||||
当前运行时基线:Node 24.20.0、pnpm 11.25.0、PostgreSQL 18.6、Redis 7.4.11、MinIO `RELEASE.2025-09-07T16-13-09Z`;Web 默认端口为 `8100`,API 默认端口为 `8200`,扩展 dev server 默认端口为 `8110`(仅本地开发用),Worker 无 HTTP 端口。完整目录、依赖边界与版本表见 [`architecture/project-structure.md`](../architecture/project-structure.md) 和 [`architecture/technology-and-deployment.md`](../architecture/technology-and-deployment.md)。
|
||||
|
||||
@@ -14,12 +14,28 @@ pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、
|
||||
|
||||
已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。
|
||||
|
||||
尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。
|
||||
SKU 定价已打通试算、表单应用与原子保存确认,在线商品同步已有 API/Worker。可直接发布的闭环仍缺真正的 Ozon 发布 job/import 及结果轮询;正式定价还需完成 CEL 费率及类目佣金映射审核。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 套图生图仍是占位入口;图片「编辑」菜单中的一键 3:4、添加水印和单张 AI 编辑已接入异步素材任务。
|
||||
|
||||
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
|
||||
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;审核正式物流费率与佣金映射,并用实际结算账单核对 Ozon 平台服务费、收款费和其他扣费;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
|
||||
|
||||
接手前请先阅读 `git status --short`,确认当前工作区状态;本轮图片选择的 Web、API、contracts、迁移、测试和文档随本报告一起提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。
|
||||
|
||||
### 2026-09-28:SKU 定价试算与确认
|
||||
|
||||
- SKU 采购价失焦/回车自动计算、手动算价、批量计算和保留手动售价;明细弹窗比较运输方案、调整参数并应用当前 SKU。
|
||||
- 独立试算、门店方案管理和参数另存已实现。Decimal 计算器由服务端统一提供,保存商品时原子确认不可变报价;变更输入使报价失效,首次发布要求有效报价。
|
||||
- Worker 增加 CBR 汇率同步与过期预览清理。新增迁移 `043`~`048`;CEL 初始化数据保持 draft,不能直接用于正式算价。
|
||||
- 系统管理补充 CEL 费率审核页与管理员 API;迁移 `049` 保存不可修改的审核证据,`050`/`051` 锁定已审核版本及规则归属。六类真实账单样本须按正式物流计费规则复算后才可启用,当前 CEL 种子依然是 draft。
|
||||
- `pnpm dev` 会先构建共享依赖;Web 开发读取 contracts 源码,解决新增导出未构建导致启动失败的问题。
|
||||
- 本轮影响 Web、API、Worker、contracts、database。验收记录及后续工作见[SKU 定价验证记录](../reviews/sku-pricing-verification.md),HTTP 以[定价 API](../api/pricing.md)为准。
|
||||
|
||||
### 2026-09-28:Ozon 定价附加费率调查
|
||||
|
||||
- 已将 Ozon Global 官方公开规则、适用条件、来源链接及 Atlas 字段对应关系写入[Ozon 尾程、收款与其他费率调查](../requirements/ozon-fulfillment-and-payment-fees-research.md)。本次仅新增调查文档和交接记录,没有修改定价实现或方案默认值。
|
||||
- Ozon 合作配送的物流平台接入服务费为每票货值的 2%,每票最低 15 ₽、最高 200 ₽;实际配送费另按承运商规则计算。“尾程费率”不能直接理解为全部尾程配送费。
|
||||
- 中国卖家的钱包公开费率为 PandaPay 1.10%,LianLian Pay、Payful (GEP)、PingPong 1.20%;“其他费率”没有统一数值。另有适用于 2026-09-01 起订单的国际运输合同服务费,每票货值 0.33%。
|
||||
- 当前引擎将 `last_mile_rate`、`withdrawal_rate`、`other_rate` 均按单个 SKU 销售价计入成本。下一步须用 Ozon 结算明细及 CEL 合同/账单确认收费主体、基数、是否重复计入;若要用于正式报价,还需处理每票计费、2% 上下限和合包分摊,不能直接把本调查的比例当作已审核规则。
|
||||
|
||||
### 1.1 开发进度(2026-09-02)
|
||||
|
||||
今日完成三项工作,全部通过 typecheck、生产构建,并经浏览器或启动实测:
|
||||
@@ -608,6 +624,7 @@ User → Workspace → Membership / Role
|
||||
- [商品采集图片与存储策略](../architecture/media-storage-strategy.md)
|
||||
- [实现前待决与待落地事项](../reviews/implementation-readiness-open-items.md)
|
||||
- [浏览器采集插件需求与技术方案](../requirements/collector-browser-extension.md)
|
||||
- [Ozon 尾程、收款与其他费率调查](../requirements/ozon-fulfillment-and-payment-fees-research.md)
|
||||
- [M2.2 / M2.3 扩展采集交接](./m2.2-m2.3-extension-collection.md)
|
||||
- [M2.4 来源解析与测试页交接](./m2.4-source-parsers-and-inspection.md)
|
||||
- [技术栈、异步任务与部署方案](../architecture/technology-and-deployment.md)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Ozon 尾程、收款与其他费率调查
|
||||
|
||||
> 调查日期:2026-09-28。官方页面查询区域选择“中国”。本文件记录 Ozon Global 官方公开规则及其与 Atlas 定价字段的对应关系,不代替店铺合同、CEL 合同和实际结算账单。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
| 费用 | Ozon 官方公开规则 | 对 Atlas 当前定价字段的含义 |
|
||||
|---|---|---|
|
||||
| “尾程费” | 没有统一的“尾程配送费率”。Ozon 合作配送由物流平台接入服务费和承运商配送费等组成;前者为每票货值的 **2%**,每票最低 **15 ₽**、最高 **200 ₽**;后者由承运商按实重或体积重等规则计算。 | `last_mile_rate = 2.00%` 只能近似表示物流平台接入服务费,不能代表全部尾程运费,也无法体现每票最低、最高收费。 |
|
||||
| 提现/收款 | 中国卖家可用的钱包费率:**PandaPay 1.10%**;**LianLian Pay、Payful (GEP)、PingPong 1.20%**。 | `withdrawal_rate` 应按店铺实际使用的收款服务商设置;这些是收款服务商费率,不是 Ozon 对所有卖家统一收取的“提现费”。 |
|
||||
| 其他 | 没有统一的“其他费率”。可明确识别的一项为“签订国际运输组织合同服务费”:**每票货值的 0.33%**,适用于 **2026-09-01 起创建的订单**。买家付款的收单费由处理付款的银行定价;其他付费服务各有规则。 | `other_rate = 0.33%` 仅可作为上述服务适用且未在别处计入时的近似值,不能视为全部其他费用。 |
|
||||
|
||||
来源:[Ozon 合作配送费用](https://global-help.ozon.com/commissions/ozon-fees/partner-delivery-rates)、[Ozon 收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements)、[Ozon 其他服务费用](https://global-help.ozon.com/commissions/ozon-fees/commissions-for-other-services)。
|
||||
|
||||
## 2. “尾程费”具体指什么
|
||||
|
||||
物流语境中的“尾程”通常指包裹进入目的地配送网络后,送至买家、提货点或自提柜的最后一段。它描述配送阶段,并不天然对应一个固定百分比。
|
||||
|
||||
Ozon 官方的合作配送费用说明将费用拆成至少两类:
|
||||
|
||||
1. **接入 Ozon 物流平台的电子服务费**:每票货值 × 2%,但每票最低 15 ₽、最高 200 ₽。
|
||||
2. **商品送达买家的承运商配送费**:按承运商公式计算,以实重或体积重为基础。退货、部分特定渠道还有单独费用。不能将其概括为 2%。
|
||||
|
||||
因此,当前需求文档使用的“尾程(Ozon 代理费)”名称容易把**平台服务费**与**实际配送费**混在一起。CEL 的 Ozon-rFBS 资费表又另按“每票固定费 + 计费重量单价”给出跨境物流报价。尚无证据证明 CEL 报价包含或不包含 Ozon 上述单独扣费;设置方案前应对照 CEL 合同与 Ozon 结算明细,避免漏计或重复计费。[Ozon 合作配送费用](https://global-help.ozon.com/commissions/ozon-fees/partner-delivery-rates)、[CEL 资费规则复核](./cel-ozon-rfbs-rates-review.md)
|
||||
|
||||
## 3. 收款费率的适用范围
|
||||
|
||||
Ozon 官方“中国”区域结算说明列出的电子钱包费率如下:
|
||||
|
||||
| 收款服务商 | 官方列示费率 | 可用结算币种 |
|
||||
|---|---:|---|
|
||||
| PandaPay | 1.10% | CNY |
|
||||
| LianLian Pay | 1.20% | CNY |
|
||||
| Payful (GEP) | 1.20% | CNY、CNH |
|
||||
| PingPong | 1.20% | CNY |
|
||||
|
||||
这些服务商的公开费率应与实际使用的钱包、合同和到账流水核对。官方页面称其为服务商“佣金”,未在所查说明中明确它与每个 SKU 销售价之间的计算关系。Atlas 当前按 SKU 销售价乘 `withdrawal_rate`,只能用于报价估算;不能把单件估算金额当作最终提现扣费。[Ozon 收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements)
|
||||
|
||||
## 4. “其他费率”的边界
|
||||
|
||||
Ozon 官方其他服务费用页说明:
|
||||
|
||||
- “签订国际运输组织合同服务费”按每票货值的 **0.33%**收取,订单创建日期从 **2026-09-01** 起适用,在票件交付或按页面规定的退回状态后扣费。
|
||||
- 买家付款的**收单费**由实际处理支付的合作银行定价,因此没有统一的公开百分比。
|
||||
- 品牌推广、促销、订阅等服务按是否开通及各自规则另行收费;它们不能被一笔固定“其他费率”准确概括。
|
||||
|
||||
`other_rate` 在 Atlas 需求中还预留给货损等估算项目。将官方 0.33% 纳入方案前,需确认店铺订单适用该项、结算中实际产生该扣费,且 CEL 费用或其他成本项未覆盖同一笔费用。[Ozon 其他服务费用](https://global-help.ozon.com/commissions/ozon-fees/commissions-for-other-services)、[Atlas 定价需求](./pricing-tool.md)
|
||||
|
||||
## 5. 与当前定价实现的差异及使用建议
|
||||
|
||||
Atlas 当前将 `last_mile_rate`、`withdrawal_rate`、`other_rate` 相加,分别乘以单个 SKU 的销售价计入成本;字段默认值均为 `0`。需求文档和 API 示例里的比例是方案输入或示例值,并非已审核的 Ozon 官方默认费率。[定价引擎](../../apps/api/src/pricing/pricing-engine.ts)、[定价契约](../../packages/contracts/src/pricing.ts)、[定价需求](./pricing-tool.md)、[定价 API](../api/pricing.md)
|
||||
|
||||
在尚未完成店铺账单核对时,可按以下口径做**临时试算**,并在方案备注中写清来源和假设:
|
||||
|
||||
| 字段 | 临时输入口径 | 需要进一步核实的内容 |
|
||||
|---|---|---|
|
||||
| `last_mile_rate` | 若 Ozon 的物流平台接入服务费需单独承担,可输入 **2.00%**。 | CEL 报价是否已覆盖同一费用;每票最低 15 ₽、最高 200 ₽;合包订单分摊方式。 |
|
||||
| `withdrawal_rate` | PandaPay 输入 **1.10%**;LianLian Pay、Payful (GEP)、PingPong 输入 **1.20%**。 | 实际钱包、合同费率、计费基数及账单扣费。 |
|
||||
| `other_rate` | 确认适用并单独扣收国际运输合同服务费时,可先输入 **0.33%**;其他项目按事实另估。 | 0.33% 是否产生及是否重复计入;收单费和已开通付费服务。 |
|
||||
|
||||
**精度限制**:Ozon 的 2% 与 0.33% 均以“每票货值”为基数,而 Atlas 当前以“单个 SKU 销售价”为基数;合包、拆单、每票上下限或费用减免时,两者会有差异。正式报价应以 Ozon 店铺后台的**财务 → 店铺经济**、**财务 → 单据**中的扣费明细及 CEL 账单复核,并在确认收费口径后调整数据模型或计算规则。[Ozon 收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements)
|
||||
|
||||
## 6. 官方来源
|
||||
|
||||
1. [Ozon Global:合作配送费用](https://global-help.ozon.com/commissions/ozon-fees/partner-delivery-rates),查询区域:中国;含物流平台 2% 及每票上下限、承运商配送费规则。
|
||||
2. [Ozon Global:收款及结算方式](https://global-help.ozon.com/accounting/receiving-payments/settlements),查询区域:中国;含四家电子钱包费率及财务明细入口。
|
||||
3. [Ozon Global:其他服务费用](https://global-help.ozon.com/commissions/ozon-fees/commissions-for-other-services),查询区域:中国;含 0.33% 服务费生效时间、收单费及可选付费服务。
|
||||
@@ -41,6 +41,8 @@
|
||||
|
||||
`product_variant_platform_listings` 同时引用平台发布配置(profile)和通用 SKU(variant),但必须强制:`variant.product_id = profile.product_id`。同一条归属链也适用于属性、报价、发布任务项和在线镜像映射。
|
||||
|
||||
2026-09-28:迁移 `043`~`045` 已补齐报价的同商品、同 profile、同门店/平台归属,以及已选候选属于当前报价的复合外键;确认快照不可更新。跨 SKU 和跨租户测试已通过。
|
||||
|
||||
实施时优先选用可维护的复合外键/冗余 `product_id` 加一致性约束;如 PostgreSQL 外键表达能力不足,再使用约束触发器。不得只依赖服务层校验。
|
||||
|
||||
### 2. 类目切换后未映射属性的持久化
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# SKU 定价实现与验证记录
|
||||
|
||||
日期:2026-09-28。方案:[SKU 定价工作流](../product/sku-pricing-workflow-proposal.md)。接口:[定价 API](../api/pricing.md)。
|
||||
|
||||
## 实现范围
|
||||
|
||||
- SKU 采购价失焦/回车自动计算,可关闭自动计算;支持按钮计算和批量计算,手动售价保留。
|
||||
- 明细弹窗支持费用、汇率、包装、运输方式比较;取消不改草稿,应用仅回填当前 SKU,保存商品才确认报价。
|
||||
- 独立试算与 SKU 共用服务端 Decimal 计算器;门店方案支持创建、编辑、默认、归档和参数另存,另存后列表立即更新。
|
||||
- 商品保存事务核对预览归属、期限、输入和规则;确认任一 SKU 失败则全部回滚。新 SKU 使用 client_key 映射真实 ID。
|
||||
- 报价与候选不可变,输入改变使有效标记失效;未定价允许保存草稿,首次发布预检要求有效报价。
|
||||
- Worker 增加 CBR 官方参考汇率同步、并发锁及过期预览清理。
|
||||
|
||||
跨领域影响:Web 交互、API 计算/确认、Worker 同步、contracts DTO、database 约束与初始化,以及开发构建依赖。现有 `.env.example` 用户改动未修改。
|
||||
|
||||
## 自动验证
|
||||
|
||||
| 验证 | 结果 |
|
||||
| --- | --- |
|
||||
| 定价引擎单元测试 | 9 项通过:十进制、币种舍入、体积重、边界、规则适用性、负利润及输入规范化 |
|
||||
| 定价 HTTP/PostgreSQL 集成测试 | 11 项通过:幂等、租户与 SKU 归属、原子确认、并发、失效、不可变快照、方案管理 |
|
||||
| 既有商品编辑集成测试 | 12 项通过 |
|
||||
| 系统物流费率审核 HTTP/PostgreSQL 集成测试 | 1 项通过:管理员鉴权、六类账单复算、重复账单拒绝、并发幂等、已审核版本和规则归属不可改写;与上述测试合计 33 项 |
|
||||
| CBR 解析测试 | 2 项通过:面额换算与非法响应 |
|
||||
| contracts、API、API 测试、Web、Worker 类型检查 | 通过 |
|
||||
| contracts、database、API、Worker 构建及 Web 生产构建 | 通过;Web 仍有较大 bundle 提示 |
|
||||
| Turbo dev dry-run | API/Worker 先构建 contracts/database,Web/扩展先构建 contracts |
|
||||
| git diff --check | 通过 |
|
||||
|
||||
CBR 官方公开接口实际请求成功,解析得到生效日期 `2026-09-26`、CNY/RUB `12.5355000000`。同步函数检测到数据库两小时内已有官方记录而跳过重复抓取;本轮未强制改写既有汇率。
|
||||
|
||||
## 浏览器联调
|
||||
|
||||
使用隔离工作空间、临时平台、门店及标明测试用途的费率,真实调用本地 API:
|
||||
|
||||
- 采购价变化自动回填售价与利润;手动售价 90 在采购价变化后保持。
|
||||
- 明细取消保留原草稿,应用后保存并重读显示“已确认”。
|
||||
- 批量计算保留手动售价,缺采购价的 SKU 单独提示,允许其保存未定价草稿。
|
||||
- 独立试算样例采购 28 CNY,包装 380 g / 220×160×90 mm,返回售价 77.44 CNY、利润 14 CNY。
|
||||
- 参数另存弹窗成功保存,方案管理列表立即显示新方案。
|
||||
- Ozon 正式上下文没有审核费率时显示明确空状态。
|
||||
- 2026-09-28 补充:目录返回待复核费率卡;试算与方案页明确显示草稿版本及新建方案的阻断原因,并向系统管理员提供审核页入口。
|
||||
- 2026-09-28 后续补齐:系统管理新增物流费率审核页及管理员 API。账单样本按正式计费函数复算,审核记录和启用状态同事务保存;普通用户 403、差额不符 422、成功/幂等/重复审核由 PostgreSQL 集成测试覆盖。CEL 正式草稿仍保持待审核,未使用测试账单启用。
|
||||
- 管理员真实会话浏览器联调通过:菜单可进入审核页,展示 Ozon CEL 六种产品类型与 16 条规则;审核表单具备六类样本输入与可滚动区域,定价页可直达入口。未提交正式资费审核。
|
||||
|
||||
联调发现并修复 contracts 开发导出缺失、JSONB 键顺序/金额末尾零导致报价误失效、弹窗应用后自动重算覆盖候选和方案列表未即时更新。最终自动回归以修复后的正式测试文件为准;过程中旧临时夹具出现的失败不计入通过结果。
|
||||
|
||||
## 本地操作与清理
|
||||
|
||||
- 迁移器应用原先待执行的 `033`~`042` 及本次 `043`~`051`。新增迁移已应用,不应再编辑其内容。
|
||||
- 维护者 Web `8100`、API `8200` 继续运行;未主动停止这两个服务,开发监听器随文件修改自动重载。
|
||||
- 临时测试应用已退出;测试平台、门店及工作空间数据已清理,查询临时平台剩余数为 0。
|
||||
- 临时测试代码和凭证文件已删除,浏览器恢复 Ozon 平台,任务页面已关闭。
|
||||
- 审核测试创建的未来生效 QA 费率已清理(剩余 0);CEL 正式版本 `cel-rfbs-review-2026-09` 仍为 draft,16 条规则仍为 draft,审核时间为空。
|
||||
|
||||
## 后续业务工作
|
||||
|
||||
1. 按真实账单审核 CEL 费率和适用边界,补齐正式类目佣金映射。本次 CEL 种子保持 draft,测试费率没有进入正式平台。
|
||||
2. 使用真实商品、CNY/RUB 门店回归费用与利润;产品中的净利润是已配置费用下的预估值。
|
||||
3. 将 Ozon 首次发布 job、import 提交和结果轮询接到已有发布预检及报价快照。当前预检 ready 不等于已发布。
|
||||
|
||||
现在可直接运行 `pnpm dev`,无需每次手工先执行完整 `pnpm build`。部署仍须按顺序执行迁移和生产构建。
|
||||
@@ -6,3 +6,6 @@ export * from './media';
|
||||
export * from './product-editor';
|
||||
export * from './product-ai';
|
||||
export * from './online-products';
|
||||
|
||||
export * from './pricing';
|
||||
export * from './pricing-admin';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const positiveAmount = z
|
||||
.string()
|
||||
.regex(/^\d{1,9}(\.\d{1,4})?$/)
|
||||
.refine((v) => Number(v) > 0);
|
||||
export const pricingRateReviewSampleSchema = z
|
||||
.object({
|
||||
rule_id: z.uuid(),
|
||||
invoice_reference: z.string().trim().min(3).max(300),
|
||||
merchandise_value_cny: positiveAmount,
|
||||
shipping_weight_g: positiveAmount,
|
||||
length_mm: positiveAmount,
|
||||
width_mm: positiveAmount,
|
||||
height_mm: positiveAmount,
|
||||
billed_logistics_cny: positiveAmount,
|
||||
})
|
||||
.strict();
|
||||
export const pricingRateReviewRequestSchema = z
|
||||
.object({
|
||||
rules_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
source_reference: z.string().trim().min(3).max(500),
|
||||
review_note: z.string().trim().min(20).max(2000),
|
||||
effective_from: z.iso.datetime({ offset: true }),
|
||||
samples: z.array(pricingRateReviewSampleSchema).min(6).max(30),
|
||||
})
|
||||
.strict();
|
||||
export type PricingRateReviewRequest = z.infer<
|
||||
typeof pricingRateReviewRequestSchema
|
||||
>;
|
||||
export type PricingRateRuleAdmin = {
|
||||
id: string;
|
||||
product_type_id: string;
|
||||
product_type_code: string;
|
||||
transport_method_id: string;
|
||||
transport_method_name: string;
|
||||
fixed_fee_cny: string;
|
||||
per_billable_kg_fee_cny: string;
|
||||
min_charge_cny: string | null;
|
||||
status: string;
|
||||
};
|
||||
export type PricingRateCardAdmin = {
|
||||
id: string;
|
||||
platform_id: string;
|
||||
provider_code: string;
|
||||
version: string;
|
||||
source_name: string;
|
||||
source_uri: string | null;
|
||||
status: string;
|
||||
effective_from: string;
|
||||
reviewed_at: string | null;
|
||||
reviewer_name: string | null;
|
||||
rules_hash: string;
|
||||
types: Array<{
|
||||
id: string;
|
||||
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_round_scale: number | null;
|
||||
billing_round_scale: number | null;
|
||||
max_volume_weight_kg: string | null;
|
||||
volume_limit_round_scale: number | null;
|
||||
}>;
|
||||
rules: PricingRateRuleAdmin[];
|
||||
review: {
|
||||
source_reference: string;
|
||||
review_note: string;
|
||||
samples: PricingRateReviewRequest['samples'];
|
||||
reviewed_at: string;
|
||||
} | null;
|
||||
};
|
||||
@@ -0,0 +1,273 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const decimal = z.string().regex(/^\d{1,9}(\.\d{1,8})?$/);
|
||||
const positive = decimal.refine((value) => Number(value) > 0, '必须大于零');
|
||||
const rate = z
|
||||
.string()
|
||||
.regex(/^0(\.\d{1,6})?$/)
|
||||
.refine((value) => Number(value) < 1, '比例必须小于 1');
|
||||
export const pricingParametersSchema = z
|
||||
.object({
|
||||
pricing_policy_id: z.uuid().nullable().default(null),
|
||||
rate_card_id: z.uuid(),
|
||||
transport_method_ids: z
|
||||
.array(z.uuid())
|
||||
.min(1)
|
||||
.max(10)
|
||||
.refine(
|
||||
(ids) => new Set(ids).size === ids.length,
|
||||
'运输方式不能重复',
|
||||
),
|
||||
default_transport_method_id: z.uuid().nullable().default(null),
|
||||
domestic_shipping_and_label_fee_cny: decimal.default('0'),
|
||||
ad_rate: rate.default('0'),
|
||||
last_mile_rate: rate.default('0'),
|
||||
withdrawal_rate: rate.default('0'),
|
||||
other_rate: rate.default('0'),
|
||||
desired_markup_rate: z
|
||||
.string()
|
||||
.regex(/^\d(\.\d{1,6})?$/)
|
||||
.refine((v) => Number(v) < 10)
|
||||
.default('0.5'),
|
||||
list_price_discount_rate: rate.default('0'),
|
||||
mode: z.enum(['target_markup', 'fixed_sale']).default('target_markup'),
|
||||
selling_price: positive.nullable().default(null),
|
||||
fx: z
|
||||
.object({
|
||||
mode: z.enum(['system', 'policy_fixed', 'one_time_override']),
|
||||
rate: positive.optional(),
|
||||
reason: z.string().trim().min(1).max(500).optional(),
|
||||
})
|
||||
.strict()
|
||||
.default({ mode: 'system' }),
|
||||
temporary_commission_rate: rate.nullable().default(null),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.mode === 'fixed_sale' && !v.selling_price)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['selling_price'],
|
||||
message: '请输入手动售价',
|
||||
});
|
||||
if (v.fx.mode === 'one_time_override' && (!v.fx.rate || !v.fx.reason))
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['fx'],
|
||||
message: '临时汇率需要数值和原因',
|
||||
});
|
||||
});
|
||||
export const pricingPackageSchema = z
|
||||
.object({
|
||||
shipping_weight_g: positive.refine(
|
||||
(v) => Number(v) >= 1 && (v.split('.')[1]?.length ?? 0) <= 3,
|
||||
'包装重量至少 1 g,最多三位小数',
|
||||
),
|
||||
length_mm: positive.refine(
|
||||
(v) => (v.split('.')[1]?.length ?? 0) <= 3,
|
||||
'最多三位小数',
|
||||
),
|
||||
width_mm: positive.refine(
|
||||
(v) => (v.split('.')[1]?.length ?? 0) <= 3,
|
||||
'最多三位小数',
|
||||
),
|
||||
height_mm: positive.refine(
|
||||
(v) => (v.split('.')[1]?.length ?? 0) <= 3,
|
||||
'最多三位小数',
|
||||
),
|
||||
status: z.literal('confirmed'),
|
||||
})
|
||||
.strict();
|
||||
export const pricingPreviewRequestSchema = z
|
||||
.object({
|
||||
store_id: z.uuid(),
|
||||
platform_product_category_id: z.uuid(),
|
||||
fulfillment: z.literal('rfbs'),
|
||||
binding: z
|
||||
.object({
|
||||
profile_id: z.uuid(),
|
||||
variant_id: z.uuid().nullable(),
|
||||
client_key: z.string().min(1).max(100).nullable(),
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => Boolean(v.variant_id) !== Boolean(v.client_key))
|
||||
.nullable()
|
||||
.default(null),
|
||||
purchase_cost_cny: z.string().regex(/^\d{1,9}(\.\d{1,2})?$/),
|
||||
package: pricingPackageSchema,
|
||||
pricing: pricingParametersSchema,
|
||||
})
|
||||
.strict();
|
||||
export const pricingSelectionSchema = z
|
||||
.object({
|
||||
preview_id: z.uuid(),
|
||||
option_id: z.uuid(),
|
||||
input_hash: z.string().length(64),
|
||||
})
|
||||
.strict();
|
||||
export const pricingPolicyRequestSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
note: z.string().max(1000).nullable().default(null),
|
||||
parameters: pricingParametersSchema,
|
||||
allow_commission_override: z.boolean().default(false),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.parameters.fx.mode === 'one_time_override')
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['parameters', 'fx'],
|
||||
message: '方案请使用系统或固定汇率',
|
||||
});
|
||||
if (v.parameters.fx.mode === 'policy_fixed' && !v.parameters.fx.rate)
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['parameters', 'fx', 'rate'],
|
||||
message: '固定汇率必填',
|
||||
});
|
||||
});
|
||||
export type PricingParameters = z.infer<typeof pricingParametersSchema>;
|
||||
export type PricingPreviewRequest = z.infer<typeof pricingPreviewRequestSchema>;
|
||||
export type PricingSelection = z.infer<typeof pricingSelectionSchema>;
|
||||
export type PricingPolicyRequest = z.infer<typeof pricingPolicyRequestSchema>;
|
||||
export type PricingOption = {
|
||||
id: string;
|
||||
transport_method_id: string;
|
||||
transport_name: string;
|
||||
product_type_id: string;
|
||||
product_type_name: string;
|
||||
logistics_rate_rule_id: string;
|
||||
commission_rule_id: string | null;
|
||||
commission_rate: string;
|
||||
selling_price: string;
|
||||
list_price: string;
|
||||
currency_code: string;
|
||||
selling_price_rub: string;
|
||||
list_price_rub: string;
|
||||
merchandise_value_cny: string;
|
||||
profit_cny: string;
|
||||
profit_rub: string;
|
||||
margin_rate: string;
|
||||
billable_weight_kg: string;
|
||||
fees: {
|
||||
logistics_cny: string;
|
||||
domestic_cny: string;
|
||||
commission_cny: string;
|
||||
ad_cny: string;
|
||||
last_mile_cny: string;
|
||||
withdrawal_cny: string;
|
||||
other_cny: string;
|
||||
};
|
||||
warnings: string[];
|
||||
can_confirm: boolean;
|
||||
};
|
||||
export type PricingPreview = {
|
||||
preview_id: string;
|
||||
expires_at: string;
|
||||
input_hash: string;
|
||||
recommended_option_id: string | null;
|
||||
options: PricingOption[];
|
||||
unavailable_options: Array<{ transport_method_id: string; reason: string }>;
|
||||
context: {
|
||||
currency_code: string;
|
||||
fx_rate: string;
|
||||
fx_origin: string;
|
||||
rule_version: string;
|
||||
development_only: boolean;
|
||||
exchange_rate: {
|
||||
id: string;
|
||||
rate: string;
|
||||
effective_on: string;
|
||||
stale: boolean;
|
||||
} | null;
|
||||
warnings: string[];
|
||||
};
|
||||
};
|
||||
export type PricingPolicy = PricingPolicyRequest & {
|
||||
id: string;
|
||||
store_id: string;
|
||||
revision: number;
|
||||
is_default: boolean;
|
||||
};
|
||||
export type PricingCatalog = {
|
||||
store: { id: string; name: string; currency_code: string };
|
||||
pending_rate_cards: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
status: 'draft';
|
||||
}>;
|
||||
rate_cards: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
development_only: boolean;
|
||||
transports: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
policies: PricingPolicy[];
|
||||
exchange_rate: {
|
||||
id: string;
|
||||
rate: string;
|
||||
effective_on: string;
|
||||
stale: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type PricingCategory = { id: string; name: string; path: string };
|
||||
|
||||
/** 数值字符串仅去掉无意义零位,前后端共享输入比较口径;不进行浮点计算。 */
|
||||
export function normalizePricingInput(
|
||||
input: PricingPreviewRequest,
|
||||
): PricingPreviewRequest {
|
||||
const number = (value: string) =>
|
||||
typeof value !== 'string'
|
||||
? value
|
||||
: value
|
||||
.replace(/^0+(?=\d)/, '')
|
||||
.replace(/(\.\d*?)0+$/, '$1')
|
||||
.replace(/\.$/, '');
|
||||
const pricing = { ...input.pricing, fx: { ...input.pricing.fx } };
|
||||
for (const key of [
|
||||
'domestic_shipping_and_label_fee_cny',
|
||||
'ad_rate',
|
||||
'last_mile_rate',
|
||||
'withdrawal_rate',
|
||||
'other_rate',
|
||||
'desired_markup_rate',
|
||||
'list_price_discount_rate',
|
||||
'selling_price',
|
||||
'temporary_commission_rate',
|
||||
] as const) {
|
||||
const value = pricing[key];
|
||||
if (value !== null) pricing[key] = number(value);
|
||||
}
|
||||
if (pricing.fx.rate) pricing.fx.rate = number(pricing.fx.rate);
|
||||
return {
|
||||
...input,
|
||||
purchase_cost_cny: number(input.purchase_cost_cny),
|
||||
package: {
|
||||
...input.package,
|
||||
shipping_weight_g: number(input.package.shipping_weight_g),
|
||||
length_mm: number(input.package.length_mm),
|
||||
width_mm: number(input.package.width_mm),
|
||||
height_mm: number(input.package.height_mm),
|
||||
},
|
||||
pricing,
|
||||
};
|
||||
}
|
||||
|
||||
/** 输入比较不受对象键顺序、PostgreSQL JSONB 重排或数值格式影响。 */
|
||||
export function pricingInputKey(input: PricingPreviewRequest): string {
|
||||
return JSON.stringify(
|
||||
normalizePricingInput(input),
|
||||
(_key, value: unknown) =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
)
|
||||
: value,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
pricingParametersSchema,
|
||||
pricingSelectionSchema,
|
||||
type PricingParameters,
|
||||
type PricingOption,
|
||||
} from './pricing';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
productCopyTranslationSchema,
|
||||
@@ -246,6 +252,9 @@ export type ProductEditorSnapshot = {
|
||||
id: string;
|
||||
offer_id: string | null;
|
||||
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;
|
||||
@@ -477,7 +486,7 @@ export type ProductPublishPreflightRequest = z.infer<
|
||||
typeof productPublishPreflightRequestSchema
|
||||
>;
|
||||
|
||||
/** M3.1 intentionally covers editable product/profile/SKU fundamentals. */
|
||||
/** 商品、发布配置与 SKU(含报价确认意图)原子保存。 */
|
||||
export const saveProductEditorDraftRequestSchema = z
|
||||
.object({
|
||||
ai_source_material: productAiSourceMaterialSchema,
|
||||
@@ -533,6 +542,8 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
.max(100)
|
||||
.nullable(),
|
||||
copy_from_variant_id: uuid.nullable().optional(),
|
||||
pricing_draft: pricingParametersSchema.nullable().optional(),
|
||||
pricing_selection: pricingSelectionSchema.nullable().optional(),
|
||||
display_name: nullableText(1_000),
|
||||
source_sku: nullableText(500),
|
||||
specification_values: z
|
||||
@@ -571,7 +582,11 @@ export const saveProductEditorDraftRequestSchema = z
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
.max(100)
|
||||
.refine(
|
||||
(variants) => new Set(variants.map((v) => v.id ? `id:${v.id}` : `client:${v.client_key}`)).size === variants.length,
|
||||
'SKU 标识不能重复',
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
ALTER TABLE logistics_product_types ADD COLUMN min_actual_weight_inclusive BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE pricing_quotes ADD COLUMN product_id UUID, ADD COLUMN profile_id UUID, ADD COLUMN input_hash TEXT;
|
||||
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_variant_product_fk
|
||||
FOREIGN KEY (workspace_id, product_variant_id, product_id) REFERENCES product_variants(workspace_id,id,product_id);
|
||||
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_profile_product_fk
|
||||
FOREIGN KEY (workspace_id,profile_id,product_id,platform_id) REFERENCES product_platform_profiles(workspace_id,id,product_id,platform_id);
|
||||
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_binding_complete CHECK (
|
||||
(product_variant_id IS NULL AND product_id IS NULL AND profile_id IS NULL) OR
|
||||
(product_variant_id IS NOT NULL AND product_id IS NOT NULL AND profile_id IS NOT NULL));
|
||||
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_listing_identity UNIQUE(workspace_id,id,profile_id,product_variant_id,store_id,platform_id);
|
||||
ALTER TABLE pricing_quote_options ADD CONSTRAINT pricing_option_identity UNIQUE(workspace_id,quote_id,id);
|
||||
ALTER TABLE pricing_quotes DROP CONSTRAINT fk_pricing_quotes_selected_option;
|
||||
ALTER TABLE pricing_quotes ADD CONSTRAINT pricing_quote_selected_option_fk
|
||||
FOREIGN KEY(workspace_id,id,selected_option_id) REFERENCES pricing_quote_options(workspace_id,quote_id,id) DEFERRABLE INITIALLY DEFERRED;
|
||||
ALTER TABLE product_variant_platform_listings
|
||||
ADD COLUMN pricing_quote_id UUID,
|
||||
ADD COLUMN pricing_draft JSONB,
|
||||
ADD COLUMN pricing_valid BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE product_variant_platform_listings ADD CONSTRAINT listing_pricing_quote_fk
|
||||
FOREIGN KEY(workspace_id,pricing_quote_id,profile_id,product_variant_id,store_id,platform_id)
|
||||
REFERENCES pricing_quotes(workspace_id,id,profile_id,product_variant_id,store_id,platform_id) DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE TABLE pricing_previews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id), store_id UUID NOT NULL, platform_id UUID NOT NULL,
|
||||
input JSONB NOT NULL, input_hash TEXT NOT NULL, rules_hash TEXT NOT NULL, result JSONB NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now()+interval '15 minutes', created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY(workspace_id,store_id,platform_id) REFERENCES stores(workspace_id,id,platform_id),
|
||||
UNIQUE(workspace_id,id)
|
||||
);
|
||||
CREATE INDEX pricing_previews_expiry ON pricing_previews(expires_at);
|
||||
ALTER TABLE pricing_previews ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE pricing_previews FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY workspace_isolation ON pricing_previews USING(workspace_id=app_current_workspace_id()) WITH CHECK(workspace_id=app_current_workspace_id());
|
||||
|
||||
-- 通用商品输入影响所有平台报价,其他平台必须同步失效;当前保存可在复核后恢复有效状态。
|
||||
CREATE FUNCTION invalidate_variant_pricing() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='product_variants' THEN
|
||||
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE product_variant_id=NEW.id;
|
||||
ELSE
|
||||
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE product_id=NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER variant_pricing_changed AFTER UPDATE OF purchase_cost_cny,package_length_mm,package_width_mm,package_height_mm,shipping_weight_g,package_spec_status ON product_variants
|
||||
FOR EACH ROW WHEN (ROW(OLD.purchase_cost_cny,OLD.package_length_mm,OLD.package_width_mm,OLD.package_height_mm,OLD.shipping_weight_g,OLD.package_spec_status) IS DISTINCT FROM ROW(NEW.purchase_cost_cny,NEW.package_length_mm,NEW.package_width_mm,NEW.package_height_mm,NEW.shipping_weight_g,NEW.package_spec_status)) EXECUTE FUNCTION invalidate_variant_pricing();
|
||||
CREATE TRIGGER product_pricing_changed AFTER UPDATE OF default_package_length_mm,default_package_width_mm,default_package_height_mm,default_shipping_weight_g,package_spec_status ON products
|
||||
FOR EACH ROW WHEN (ROW(OLD.default_package_length_mm,OLD.default_package_width_mm,OLD.default_package_height_mm,OLD.default_shipping_weight_g,OLD.package_spec_status) IS DISTINCT FROM ROW(NEW.default_package_length_mm,NEW.default_package_width_mm,NEW.default_package_height_mm,NEW.default_shipping_weight_g,NEW.package_spec_status)) EXECUTE FUNCTION invalidate_variant_pricing();
|
||||
@@ -0,0 +1,26 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 固定函数查找路径,业务连接不依赖迁移会话的 search_path。
|
||||
ALTER FUNCTION invalidate_variant_pricing() SET search_path = atlas, public;
|
||||
|
||||
-- 防止数据库绕过接口拼接其他费率卡的运输规则。
|
||||
CREATE FUNCTION check_pricing_transport_card() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='logistics_rate_rules' THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM logistics_product_types p JOIN logistics_transport_methods t ON t.rate_card_id=p.rate_card_id WHERE p.id=NEW.product_type_id AND t.id=NEW.transport_method_id) THEN
|
||||
RAISE EXCEPTION '物流类型和运输方式必须属于同一费率卡' USING ERRCODE='23514';
|
||||
END IF;
|
||||
ELSIF TG_TABLE_NAME='pricing_policies' THEN
|
||||
IF NEW.default_transport_method_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM logistics_transport_methods WHERE id=NEW.default_transport_method_id AND rate_card_id=NEW.rate_card_id) THEN
|
||||
RAISE EXCEPTION '方案默认运输方式必须属于费率卡' USING ERRCODE='23514';
|
||||
END IF;
|
||||
ELSE
|
||||
IF NOT EXISTS(SELECT 1 FROM pricing_policies p JOIN logistics_transport_methods t ON t.rate_card_id=p.rate_card_id WHERE p.id=NEW.pricing_policy_id AND p.workspace_id=NEW.workspace_id AND t.id=NEW.transport_method_id) THEN
|
||||
RAISE EXCEPTION '方案比较方式必须属于费率卡' USING ERRCODE='23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER logistics_transport_card AFTER INSERT OR UPDATE ON logistics_rate_rules DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card();
|
||||
CREATE CONSTRAINT TRIGGER policy_transport_card AFTER INSERT OR UPDATE ON pricing_policies DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card();
|
||||
CREATE CONSTRAINT TRIGGER policy_comparison_card AFTER INSERT OR UPDATE ON pricing_policy_transport_methods DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_pricing_transport_card();
|
||||
@@ -0,0 +1,18 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
ALTER TABLE product_variant_platform_listings ADD CONSTRAINT valid_pricing_requires_quote CHECK(NOT pricing_valid OR pricing_quote_id IS NOT NULL);
|
||||
|
||||
-- 确认后只新增报价切换引用,不允许修改历史金额或规则快照。
|
||||
CREATE FUNCTION prevent_confirmed_pricing_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='pricing_quotes' THEN
|
||||
IF OLD.status='confirmed' THEN RAISE EXCEPTION '已确认报价不可改写' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF EXISTS(SELECT 1 FROM pricing_quotes WHERE id=OLD.quote_id AND workspace_id=OLD.workspace_id AND status='confirmed') THEN
|
||||
RAISE EXCEPTION '已确认候选快照不可改写' USING ERRCODE='23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER confirmed_quote_immutable BEFORE UPDATE ON pricing_quotes FOR EACH ROW EXECUTE FUNCTION prevent_confirmed_pricing_mutation();
|
||||
CREATE TRIGGER confirmed_option_immutable BEFORE UPDATE ON pricing_quote_options FOR EACH ROW EXECUTE FUNCTION prevent_confirmed_pricing_mutation();
|
||||
@@ -0,0 +1,9 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
CREATE FUNCTION invalidate_profile_pricing() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE profile_id=NEW.id;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER profile_pricing_changed AFTER UPDATE OF target_store_id,platform_product_category_id,fulfillment ON product_platform_profiles
|
||||
FOR EACH ROW WHEN (ROW(OLD.target_store_id,OLD.platform_product_category_id,OLD.fulfillment) IS DISTINCT FROM ROW(NEW.target_store_id,NEW.platform_product_category_id,NEW.fulfillment)) EXECUTE FUNCTION invalidate_profile_pricing();
|
||||
@@ -0,0 +1,37 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 来源为仓库 CEL 待复核表;仅建立草稿目录,禁止自动启用或填入虚构审核人。
|
||||
INSERT INTO logistics_providers(code,name,status) VALUES('CEL','CEL','active') ON CONFLICT(code) DO NOTHING;
|
||||
INSERT INTO logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from)
|
||||
SELECT p.id,f.id,'rfbs','RU','cel-rfbs-review-2026-09','CEL Ozon rFBS · 待真实账单复核','draft',now()
|
||||
FROM logistics_providers p CROSS JOIN platforms f WHERE p.code='CEL' AND upper(f.code::text)='OZON'
|
||||
ON CONFLICT(provider_id,platform_id,fulfillment,market_code,version) DO NOTHING;
|
||||
|
||||
INSERT INTO logistics_transport_methods(rate_card_id,code,name,status)
|
||||
SELECT c.id,v.code,v.name,'active' FROM logistics_rate_cards c CROSS JOIN (VALUES('express','特快'),('standard','标准'),('economy','经济')) v(code,name)
|
||||
WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(rate_card_id,code) DO NOTHING;
|
||||
|
||||
INSERT INTO logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_value_inclusive,max_value_inclusive,min_actual_weight_kg,min_actual_weight_inclusive,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,max_second_longest_side_cm,billing_basis,volume_divisor,volume_round_scale,billing_round_scale,max_volume_weight_kg,volume_limit_round_scale)
|
||||
SELECT c.id,v.code,v.code,'CNY',v.min_value,v.max_value,false,true,v.min_weight,v.min_inclusive,v.max_weight,v.max_sum,v.max_side,v.max_second,v.basis::billing_weight_basis,v.divisor,v.round_scale,3,v.max_volume,v.limit_scale
|
||||
FROM logistics_rate_cards c CROSS JOIN (VALUES
|
||||
('ExtraSmall',NULL::numeric,135,0.001,true,0.5,90,60,NULL::numeric,'actual_only',NULL::numeric,NULL::integer,NULL::numeric,NULL::integer),
|
||||
('Budget',NULL,135,0.5,false,30,150,60,NULL,'actual_only',NULL,NULL,NULL,NULL),
|
||||
('Small',135,635,0.001,true,2,150,60,NULL,'actual_only',NULL,NULL,NULL,NULL),
|
||||
('Big',135,635,2,false,30,310,150,NULL,'max_actual_volume',12000,3,31,0),
|
||||
('PremiumSmall',635,22525,0.001,true,5,250,150,NULL,'actual_only',NULL,NULL,NULL,NULL),
|
||||
('PremiumBig',635,22525,5,false,30,310,150,80,'max_actual_volume',12000,3,80,0)
|
||||
) v(code,min_value,max_value,min_weight,min_inclusive,max_weight,max_sum,max_side,max_second,basis,divisor,round_scale,max_volume,limit_scale)
|
||||
WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(rate_card_id,code) DO NOTHING;
|
||||
|
||||
INSERT INTO logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status)
|
||||
SELECT t.id,m.id,v.fixed_fee,v.per_kg,'draft' FROM logistics_product_types t
|
||||
JOIN logistics_rate_cards c ON c.id=t.rate_card_id JOIN logistics_transport_methods m ON m.rate_card_id=c.id
|
||||
JOIN (VALUES
|
||||
('ExtraSmall','express',3.12,46.8),('ExtraSmall','standard',3.12,36.4),('ExtraSmall','economy',3.12,26),
|
||||
('Budget','express',23.92,34.32),('Budget','standard',23.92,26),('Budget','economy',23.92,17.68),
|
||||
('Small','express',16.64,46.8),('Small','standard',16.64,36.4),('Small','economy',16.64,26),
|
||||
('Big','standard',37.44,26),('Big','economy',37.44,17.68),
|
||||
('PremiumSmall','express',22.88,46.8),('PremiumSmall','standard',22.88,36.4),('PremiumSmall','economy',22.88,26),
|
||||
('PremiumBig','standard',64.48,29.12),('PremiumBig','economy',64.48,23.92)
|
||||
) v(type_code,transport_code,fixed_fee,per_kg) ON t.code=v.type_code AND m.code=v.transport_code
|
||||
WHERE c.version='cel-rfbs-review-2026-09' ON CONFLICT(product_type_id,transport_method_id) DO NOTHING;
|
||||
@@ -0,0 +1,10 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 币种变化影响售价含义;历史快照保留,发布必须重新确认。
|
||||
CREATE FUNCTION invalidate_store_currency_pricing() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
BEGIN
|
||||
UPDATE product_variant_platform_listings SET pricing_valid=false WHERE store_id=NEW.id;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER store_currency_pricing_changed AFTER UPDATE OF currency_code ON stores
|
||||
FOR EACH ROW WHEN (OLD.currency_code IS DISTINCT FROM NEW.currency_code) EXECUTE FUNCTION invalidate_store_currency_pricing();
|
||||
@@ -0,0 +1,20 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 平台费率审核是系统级事实;证据与复算样本随启用事务一起保存。
|
||||
CREATE TABLE logistics_rate_card_reviews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rate_card_id UUID NOT NULL UNIQUE REFERENCES logistics_rate_cards(id) ON DELETE RESTRICT,
|
||||
reviewer_user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
idempotency_key UUID NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
rules_hash CHAR(64) NOT NULL,
|
||||
source_reference TEXT NOT NULL CHECK (length(trim(source_reference)) BETWEEN 3 AND 500),
|
||||
review_note TEXT NOT NULL CHECK (length(trim(review_note)) BETWEEN 20 AND 2000),
|
||||
samples JSONB NOT NULL CHECK (jsonb_typeof(samples) = 'array' AND jsonb_array_length(samples) >= 6),
|
||||
effective_from TIMESTAMPTZ NOT NULL,
|
||||
reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (reviewer_user_id, idempotency_key)
|
||||
);
|
||||
|
||||
-- 审核记录不可改写;后续资费变化须建立新费率版本并重新审核。
|
||||
REVOKE UPDATE, DELETE ON logistics_rate_card_reviews FROM atlas_app;
|
||||
@@ -0,0 +1,37 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 审核通过的规则只能由新版本替代;应用角色不能直接修改已经用于报价的运价。
|
||||
CREATE FUNCTION guard_reviewed_logistics_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
DECLARE
|
||||
card_id UUID;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='logistics_rate_cards' THEN
|
||||
card_id := OLD.id;
|
||||
ELSIF TG_TABLE_NAME IN ('logistics_product_types','logistics_transport_methods') THEN
|
||||
IF TG_OP='DELETE' THEN card_id := OLD.rate_card_id;
|
||||
ELSE card_id := NEW.rate_card_id; END IF;
|
||||
ELSE
|
||||
IF TG_OP='DELETE' THEN
|
||||
SELECT rate_card_id INTO card_id FROM logistics_product_types WHERE id=OLD.product_type_id;
|
||||
ELSE
|
||||
SELECT rate_card_id INTO card_id FROM logistics_product_types WHERE id=NEW.product_type_id;
|
||||
END IF;
|
||||
END IF;
|
||||
IF card_id IS NOT NULL THEN
|
||||
PERFORM 1 FROM logistics_rate_cards WHERE id=card_id FOR SHARE;
|
||||
IF EXISTS (SELECT 1 FROM logistics_rate_card_reviews WHERE rate_card_id=card_id) THEN
|
||||
RAISE EXCEPTION '已审核的物流费率不可改写,请建立新版本' USING ERRCODE='23514';
|
||||
END IF;
|
||||
END IF;
|
||||
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE TRIGGER reviewed_card_immutable BEFORE UPDATE OR DELETE ON logistics_rate_cards
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
|
||||
CREATE TRIGGER reviewed_type_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_product_types
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
|
||||
CREATE TRIGGER reviewed_method_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_transport_methods
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
|
||||
CREATE TRIGGER reviewed_rule_immutable BEFORE INSERT OR UPDATE OR DELETE ON logistics_rate_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_reviewed_logistics_mutation();
|
||||
@@ -0,0 +1,31 @@
|
||||
SET search_path TO atlas, public;
|
||||
|
||||
-- 更新规则归属时同时检查原费率卡和目标费率卡,避免把已审核规则移出后改写。
|
||||
CREATE OR REPLACE FUNCTION guard_reviewed_logistics_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path = atlas, public AS $$
|
||||
DECLARE
|
||||
old_card_id UUID;
|
||||
new_card_id UUID;
|
||||
current_card_id UUID;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='logistics_rate_cards' THEN
|
||||
old_card_id := OLD.id;
|
||||
ELSIF TG_TABLE_NAME IN ('logistics_product_types','logistics_transport_methods') THEN
|
||||
IF TG_OP<>'INSERT' THEN old_card_id := OLD.rate_card_id; END IF;
|
||||
IF TG_OP<>'DELETE' THEN new_card_id := NEW.rate_card_id; END IF;
|
||||
ELSE
|
||||
IF TG_OP<>'INSERT' THEN
|
||||
SELECT rate_card_id INTO old_card_id FROM logistics_product_types WHERE id=OLD.product_type_id;
|
||||
END IF;
|
||||
IF TG_OP<>'DELETE' THEN
|
||||
SELECT rate_card_id INTO new_card_id FROM logistics_product_types WHERE id=NEW.product_type_id;
|
||||
END IF;
|
||||
END IF;
|
||||
FOR current_card_id IN SELECT DISTINCT id FROM (VALUES(old_card_id),(new_card_id)) AS ids(id) WHERE id IS NOT NULL LOOP
|
||||
PERFORM 1 FROM logistics_rate_cards WHERE id=current_card_id FOR SHARE;
|
||||
IF EXISTS (SELECT 1 FROM logistics_rate_card_reviews WHERE rate_card_id=current_card_id) THEN
|
||||
RAISE EXCEPTION '已审核的物流费率不可改写,请建立新版本' USING ERRCODE='23514';
|
||||
END IF;
|
||||
END LOOP;
|
||||
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
Generated
+14
@@ -47,6 +47,9 @@ importers:
|
||||
'@nestjs/swagger':
|
||||
specifier: 12.0.1
|
||||
version: 12.0.1(@nestjs/common@12.0.1(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(reflect-metadata@0.2.2)
|
||||
decimal.js:
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0
|
||||
helmet:
|
||||
specifier: 8.3.0
|
||||
version: 8.3.0
|
||||
@@ -72,6 +75,12 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 26.3.0
|
||||
version: 26.3.0
|
||||
'@types/pg':
|
||||
specifier: 8.23.1
|
||||
version: 8.23.1
|
||||
pg:
|
||||
specifier: 8.23.0
|
||||
version: 8.23.0
|
||||
tsx:
|
||||
specifier: 4.23.13
|
||||
version: 4.23.13
|
||||
@@ -1965,6 +1974,9 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
define-lazy-prop@2.0.0:
|
||||
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -4864,6 +4876,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
define-lazy-prop@2.0.0: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
+2
-1
@@ -7,7 +7,8 @@
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
"persistent": true,
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"]
|
||||
|
||||
Reference in New Issue
Block a user