149 lines
6.4 KiB
TypeScript
149 lines
6.4 KiB
TypeScript
import type {
|
||
BillingConditionValue,
|
||
BillingRuleConditions,
|
||
BillingParameterSnapshot,
|
||
BillingPriceRule,
|
||
BillingQuote,
|
||
BillingSelectedParameterTier,
|
||
BillingUnit,
|
||
GenerationCapability,
|
||
GenerationProvider
|
||
} from "@/lib/types";
|
||
|
||
export const BILLING_CURRENCY = "CNY" as const;
|
||
|
||
export const BILLING_UNIT_OPTIONS: Array<{ value: BillingUnit; label: string }> = [
|
||
{ value: "request", label: "按次" },
|
||
{ value: "image", label: "按张" },
|
||
{ value: "video_second", label: "按秒" }
|
||
];
|
||
|
||
export function calculateBillingAmountFen(standardUnitPriceFen: number, quantity: number, markupMultiplier: number): number {
|
||
if (!Number.isFinite(standardUnitPriceFen) || standardUnitPriceFen < 0) throw new Error("标准单价必须是非负金额。");
|
||
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("计费数量必须大于 0。");
|
||
if (!Number.isFinite(markupMultiplier) || markupMultiplier < 1) throw new Error("上浮倍率不能低于 1.00。");
|
||
return Math.max(0, Math.ceil(standardUnitPriceFen * quantity * markupMultiplier));
|
||
}
|
||
|
||
export function quoteFromPriceRule(input: {
|
||
rule: BillingPriceRule;
|
||
provider: GenerationProvider;
|
||
capability: GenerationCapability;
|
||
reqKey: string;
|
||
quantity: number;
|
||
parameters?: BillingParameterSnapshot;
|
||
conditions?: BillingRuleConditions;
|
||
quantitySource?: BillingPriceRule["quantitySource"];
|
||
}): BillingQuote {
|
||
const { rule } = input;
|
||
if (rule.provider !== input.provider || rule.capability !== input.capability) {
|
||
throw new Error("计费规则与生成服务不匹配。");
|
||
}
|
||
const parameterPricing = resolveBillingParameterPricing(rule, input.parameters);
|
||
if (!parameterPricing) throw new Error("当前生成参数没有对应的平台标准价格。");
|
||
const standardUnitPriceFen = parameterPricing.standardUnitPriceFen;
|
||
const markupMultiplier = parameterPricing.markupMultiplier;
|
||
return {
|
||
priceRuleId: rule.id,
|
||
provider: input.provider,
|
||
capability: input.capability,
|
||
reqKey: input.reqKey,
|
||
variantKey: rule.variantKey,
|
||
unit: rule.unit,
|
||
quantity: input.quantity,
|
||
standardUnitPriceFen,
|
||
markupMultiplier,
|
||
amountFen: calculateBillingAmountFen(standardUnitPriceFen, input.quantity, markupMultiplier),
|
||
currency: BILLING_CURRENCY,
|
||
conditions: input.conditions ?? rule.conditions,
|
||
quantitySource: input.quantitySource ?? rule.quantitySource,
|
||
parameters: input.parameters,
|
||
baseStandardUnitPriceFen: rule.parameterDimensions?.length ? rule.standardUnitPriceFen : undefined,
|
||
parameterTiers: parameterPricing.parameterTiers.length ? parameterPricing.parameterTiers : undefined,
|
||
source: rule.source
|
||
};
|
||
}
|
||
|
||
export type BillingParameterPricing = {
|
||
standardUnitPriceFen: number;
|
||
markupMultiplier: number;
|
||
parameterTiers: BillingSelectedParameterTier[];
|
||
};
|
||
|
||
export function resolveBillingParameterPricing(rule: BillingPriceRule, parameters: BillingParameterSnapshot = {}): BillingParameterPricing | null {
|
||
const dimensions = rule.parameterDimensions?.filter((dimension) => dimension.tiers.length) || [];
|
||
if (!dimensions.length) {
|
||
return {
|
||
standardUnitPriceFen: rule.standardUnitPriceFen,
|
||
markupMultiplier: rule.markupMultiplier,
|
||
parameterTiers: []
|
||
};
|
||
}
|
||
|
||
let standardFactor = 1;
|
||
let markupMultiplier = rule.parameterDimensions?.length ? 1 : rule.markupMultiplier;
|
||
const parameterTiers: BillingSelectedParameterTier[] = [];
|
||
for (const dimension of dimensions) {
|
||
const actualValue = parameters[dimension.key] ?? dimension.defaultValue ?? dimension.baselineValue;
|
||
const tier = dimension.tiers.find((candidate) => candidate.enabled && billingConditionMatchesValue(candidate.match ?? candidate.value, actualValue));
|
||
if (!tier) return null;
|
||
const factor = Number(tier.standardFactor);
|
||
const tierMarkup = Number(tier.markupMultiplier);
|
||
if (!Number.isFinite(factor) || factor <= 0 || !Number.isFinite(tierMarkup) || tierMarkup < 1) return null;
|
||
standardFactor *= factor;
|
||
// A combination has one customer-facing uplift. The highest selected
|
||
// tier multiplier wins so editing one expensive tier never lowers the
|
||
// configured uplift of another selected tier.
|
||
markupMultiplier = Math.max(markupMultiplier, tierMarkup);
|
||
parameterTiers.push({
|
||
...tier,
|
||
dimensionKey: dimension.key,
|
||
dimensionLabel: dimension.label,
|
||
actualValue,
|
||
standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * factor)
|
||
});
|
||
}
|
||
return {
|
||
standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * standardFactor),
|
||
markupMultiplier,
|
||
parameterTiers
|
||
};
|
||
}
|
||
|
||
export function billingConditionMatchesValue(condition: BillingConditionValue, actual: string | number | boolean): boolean {
|
||
if (typeof condition === "object" && condition !== null && !Array.isArray(condition)) {
|
||
if (Array.isArray(condition.values) && !condition.values.some((value) => billingScalarEquals(value, actual))) return false;
|
||
const numericActual = Number(actual);
|
||
if (condition.min !== undefined && (!Number.isFinite(numericActual) || numericActual < condition.min)) return false;
|
||
if (condition.max !== undefined && (!Number.isFinite(numericActual) || numericActual > condition.max)) return false;
|
||
return true;
|
||
}
|
||
return billingScalarEquals(condition as string | number | boolean, actual);
|
||
}
|
||
|
||
function billingScalarEquals(left: string | number | boolean, right: string | number | boolean): boolean {
|
||
if (typeof left === "number" || typeof right === "number") return Number(left) === Number(right);
|
||
if (typeof left === "boolean" || typeof right === "boolean") return Boolean(left) === Boolean(right);
|
||
return String(left).trim().toLowerCase() === String(right).trim().toLowerCase();
|
||
}
|
||
|
||
export function formatBillingAmount(fen: number, currency = BILLING_CURRENCY): string {
|
||
const amount = Number(fen || 0) / 100;
|
||
const formatted = new Intl.NumberFormat("zh-CN", {
|
||
style: "currency",
|
||
currency,
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
}).format(Math.abs(amount));
|
||
return amount < 0 ? `−${formatted}` : formatted;
|
||
}
|
||
|
||
export function billingUnitLabel(unit: BillingUnit): string {
|
||
return BILLING_UNIT_OPTIONS.find((item) => item.value === unit)?.label || unit;
|
||
}
|
||
|
||
export function billingQuantityLabel(quantity: number, unit: BillingUnit): string {
|
||
const value = Number.isInteger(quantity) ? String(quantity) : quantity.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||
return `${value}${unit === "video_second" ? " 秒" : unit === "image" ? " 张" : " 次"}`;
|
||
}
|