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