feat(pricing): 开发定价方案

This commit is contained in:
R524809
2026-09-28 17:34:33 +08:00
parent a29dd92676
commit 84b230e69a
54 changed files with 6264 additions and 236 deletions
+302
View File
@@ -0,0 +1,302 @@
import { normalizePricingInput } from '@atlas/contracts';
import { randomUUID, createHash } from 'node:crypto';
import { Decimal } from 'decimal.js';
import type { PricingOption, PricingPreviewRequest } from '@atlas/contracts';
Decimal.set({ precision: 40, rounding: Decimal.ROUND_HALF_UP });
export const D = (value: Decimal.Value) => new Decimal(value);
export function fingerprint(value: unknown): string {
const sort = (v: unknown): unknown =>
v instanceof Date
? v.toISOString()
: Array.isArray(v)
? v.map(sort)
: v && typeof v === 'object'
? Object.fromEntries(
Object.entries(v)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, x]) => [k, sort(x)]),
)
: v;
return createHash('sha256')
.update(JSON.stringify(sort(value)))
.digest('hex');
}
export type LogisticsRule = {
id: string;
product_type_id: string;
transport_method_id: string;
transport_name: string;
product_type_name: string;
fixed_fee_cny: string;
per_billable_kg_fee_cny: string;
min_charge_cny: string | null;
surcharge_rules: unknown[];
merchandise_currency_code: string;
min_merchandise_value: string | null;
max_merchandise_value: string | null;
min_value_inclusive: boolean;
max_value_inclusive: boolean;
min_actual_weight_kg: string;
min_actual_weight_inclusive: boolean;
max_actual_weight_kg: string | null;
max_sum_dimensions_cm: string | null;
max_longest_side_cm: string | null;
max_second_longest_side_cm: string | null;
billing_basis: string;
volume_divisor: string | null;
volume_condition_sum_gt_cm: string | null;
volume_round_scale: number | null;
billing_round_scale: number | null;
max_volume_weight_kg: string | null;
volume_limit_round_scale: number | null;
};
export type CommissionRule = {
id: string | null;
currency_code: string;
min_price: string | null;
max_price: string | null;
min_inclusive: boolean;
max_inclusive: boolean;
commission_rate: string;
version: string;
};
const inRange = (
n: Decimal,
min: string | null,
max: string | null,
includeMin: boolean,
includeMax: boolean,
) =>
(min === null || (includeMin ? n.gte(min) : n.gt(min))) &&
(max === null || (includeMax ? n.lte(max) : n.lt(max)));
const ceil = (n: Decimal, scale: number | null) =>
scale === null ? n : n.toDecimalPlaces(scale, Decimal.ROUND_CEIL);
export const normalizeInput = normalizePricingInput;
/** 审核样本与正式报价共用包装适用性和运费计算,避免两套规则产生不同结果。 */
export function calculateLogisticsFee(
r: LogisticsRule,
packageSpec: PricingPreviewRequest['package'],
): { billable: Decimal; logistics: Decimal } | { reason: string } {
if (r.surcharge_rules.length)
return { reason: '此规则含尚未支持的附加费,需完成审核后使用' };
const weight = D(packageSpec.shipping_weight_g).div(1000);
const sides = [
packageSpec.length_mm,
packageSpec.width_mm,
packageSpec.height_mm,
]
.map((v) => D(v).div(10))
.sort((a, b) => b.cmp(a));
const sum = sides.reduce((a, b) => a.add(b), D(0));
if (
!inRange(
weight,
r.min_actual_weight_kg,
r.max_actual_weight_kg,
r.min_actual_weight_inclusive,
true,
) ||
(r.max_sum_dimensions_cm && sum.gt(r.max_sum_dimensions_cm)) ||
(r.max_longest_side_cm && sides[0].gt(r.max_longest_side_cm)) ||
(r.max_second_longest_side_cm &&
sides[1].gt(r.max_second_longest_side_cm))
)
return { reason: '包装重量或尺寸超出物流范围' };
let billable = weight;
if (r.billing_basis !== 'actual_only') {
if (!r.volume_divisor || D(r.volume_divisor).lte(0))
return { reason: '体积重规则无效' };
const volume = sides
.reduce((a, b) => a.mul(b), D(1))
.div(r.volume_divisor);
if (
r.max_volume_weight_kg &&
ceil(volume, r.volume_limit_round_scale).gt(r.max_volume_weight_kg)
)
return { reason: '体积重超限' };
if (
r.billing_basis === 'max_actual_volume' ||
(r.volume_condition_sum_gt_cm &&
sum.gt(r.volume_condition_sum_gt_cm))
)
billable = Decimal.max(weight, ceil(volume, r.volume_round_scale));
}
billable = ceil(billable, r.billing_round_scale);
const logistics = Decimal.max(
billable.mul(r.per_billable_kg_fee_cny).add(r.fixed_fee_cny),
r.min_charge_cny ?? '0',
);
return { billable, logistics };
}
export function logisticsMerchandiseFits(
r: LogisticsRule,
valueCny: string,
): boolean {
return (
r.merchandise_currency_code === 'CNY' &&
inRange(
D(valueCny),
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
);
}
/** 枚举自洽候选;先舍入目标币种售价,再回验佣金档与货值档,避免跨档误报。 */
export function calculatePricing(
input: PricingPreviewRequest,
currency: string,
fx: string,
rules: LogisticsRule[],
commissions: CommissionRule[],
) {
if (!['CNY', 'RUB'].includes(currency))
throw new Error('门店币种仅支持 CNY / RUB');
const p = input.pricing,
exchange = D(fx),
purchase = D(input.purchase_cost_cny),
domestic = D(p.domestic_shipping_and_label_fee_cny);
const options: PricingOption[] = [],
unavailable_options: Array<{
transport_method_id: string;
reason: string;
}> = [];
for (const transport of p.transport_method_ids) {
const reasons = new Set<string>();
for (const r of rules.filter(
(r) => r.transport_method_id === transport,
)) {
const fee = calculateLogisticsFee(r, input.package);
if ('reason' in fee) {
reasons.add(fee.reason);
continue;
}
const { billable, logistics } = fee;
for (const c of commissions) {
const totalRate = D(c.commission_rate)
.add(p.ad_rate)
.add(p.last_mile_rate)
.add(p.withdrawal_rate)
.add(p.other_rate);
if (totalRate.gte(1)) {
reasons.add('总扣费率达到或超过 100%');
continue;
}
const factor = currency === 'RUB' ? exchange : D(1);
const sale =
p.mode === 'fixed_sale'
? D(p.selling_price!)
: purchase
.mul(D(1).add(p.desired_markup_rate))
.add(logistics)
.add(domestic)
.div(D(1).sub(totalRate))
.mul(factor);
const rounded = sale.toDecimalPlaces(
2,
p.mode === 'fixed_sale'
? Decimal.ROUND_HALF_UP
: Decimal.ROUND_CEIL,
),
cny = rounded.div(factor),
rub = cny.mul(exchange);
if (
rounded.lte(0) ||
!inRange(
c.currency_code === 'RUB' ? rub : cny,
c.min_price,
c.max_price,
c.min_inclusive,
c.max_inclusive,
) ||
!inRange(
r.merchandise_currency_code === 'RUB' ? rub : cny,
r.min_merchandise_value,
r.max_merchandise_value,
r.min_value_inclusive,
r.max_value_inclusive,
)
) {
reasons.add('售价不在该物流货值区间或佣金档内');
continue;
}
const profit = cny
.mul(D(1).sub(totalRate))
.sub(purchase)
.sub(logistics)
.sub(domestic),
list = rounded
.div(D(1).sub(p.list_price_discount_rate))
.toDecimalPlaces(2, Decimal.ROUND_CEIL);
if (
[
rounded,
list,
rub,
list.div(factor).mul(exchange),
profit.mul(exchange),
logistics,
].some((v) => v.abs().gte('100000000000000'))
) {
reasons.add('金额超过可保存范围,请调整费率或输入');
continue;
}
options.push({
id: randomUUID(),
can_confirm: profit.gte(0),
transport_method_id: transport,
transport_name: r.transport_name,
product_type_id: r.product_type_id,
product_type_name: r.product_type_name,
logistics_rate_rule_id: r.id,
commission_rule_id: c.id,
commission_rate: c.commission_rate,
selling_price: rounded.toFixed(2),
list_price: list.toFixed(2),
currency_code: currency,
selling_price_rub: rub.toFixed(4),
list_price_rub: list.div(factor).mul(exchange).toFixed(4),
merchandise_value_cny: cny.toFixed(4),
profit_cny: profit.toFixed(2),
profit_rub: profit.mul(exchange).toFixed(4),
margin_rate: profit.div(cny).toFixed(6),
billable_weight_kg: billable.toFixed(3),
fees: {
logistics_cny: logistics.toFixed(4),
domestic_cny: domestic.toFixed(4),
commission_cny: cny.mul(c.commission_rate).toFixed(4),
ad_cny: cny.mul(p.ad_rate).toFixed(4),
last_mile_cny: cny.mul(p.last_mile_rate).toFixed(4),
withdrawal_cny: cny.mul(p.withdrawal_rate).toFixed(4),
other_cny: cny.mul(p.other_rate).toFixed(4),
},
warnings: [
...(profit.lt(0) ? ['亏损报价不能确认'] : []),
...(profit.lt(purchase.mul(p.desired_markup_rate))
? ['未达到目标加价利润']
: []),
...(!c.id ? ['使用临时佣金率'] : []),
],
});
}
}
if (!options.some((o) => o.transport_method_id === transport))
unavailable_options.push({
transport_method_id: transport,
reason: [...reasons].join(';') || '没有可用规则',
});
}
options.sort((a, b) => D(a.selling_price).cmp(b.selling_price));
const recommended =
options.find(
(o) => o.transport_method_id === p.default_transport_method_id,
) ?? (!p.default_transport_method_id ? options[0] : undefined);
return {
options,
unavailable_options,
recommended_option_id: recommended?.id ?? null,
};
}