310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
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 = {
|
||
category_name?: string;
|
||
source?: string;
|
||
effective_from?: string | Date;
|
||
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,
|
||
...(c.id ? {commission_details: {
|
||
category_name: c.category_name ?? '',currency_code:c.currency_code,version:c.version,source:c.source ?? 'manual_rule',
|
||
min_price:c.min_price,max_price:c.max_price,min_inclusive:c.min_inclusive,max_inclusive:c.max_inclusive,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,
|
||
};
|
||
}
|