feat(pricing): 开发定价方案
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user