400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
export interface ProductPricing {
|
||
price: number;
|
||
benefitAmount?: number | null;
|
||
}
|
||
|
||
export function calcBenefitAmount(product: ProductPricing): number {
|
||
return product.benefitAmount ?? product.price;
|
||
}
|
||
|
||
export type ProductSaleUnit = 'BOTTLE' | 'BOX';
|
||
|
||
/** 销售数量 → 瓶当量 */
|
||
export function toBottleQuantity(quantity: number, bottlesPerUnit = 1): number {
|
||
const qty = Math.floor(Number(quantity) || 0);
|
||
const per = Math.floor(Number(bottlesPerUnit) || 0);
|
||
if (qty <= 0 || per <= 0) return 0;
|
||
return qty * per;
|
||
}
|
||
|
||
/**
|
||
* 城市起购瓶数 → 最少销售单位数量(向上取整)
|
||
* 例:同城 2 瓶、整箱 6 瓶/箱 → minSaleQty = 1
|
||
*/
|
||
export function toMinSaleQuantity(minBottleQty: number, bottlesPerUnit = 1): number {
|
||
const minBottles = Math.floor(Number(minBottleQty) || 0);
|
||
const per = Math.floor(Number(bottlesPerUnit) || 0);
|
||
if (minBottles <= 0) return 1;
|
||
if (per <= 0) return minBottles;
|
||
return Math.max(1, Math.ceil(minBottles / per));
|
||
}
|
||
|
||
export function saleUnitLabel(saleUnit: ProductSaleUnit | string | null | undefined): string {
|
||
return saleUnit === 'BOX' ? '箱' : '瓶';
|
||
}
|
||
|
||
export function validateMinPurchase(
|
||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||
quantity: number,
|
||
localMinQty: number,
|
||
crossMinQty: number,
|
||
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
|
||
): { ok: boolean; message?: string } {
|
||
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
|
||
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
|
||
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
|
||
const unit = saleUnitLabel(saleUnit);
|
||
|
||
if (deliveryType === 'ON_SITE_PICKUP') {
|
||
const minBottles = localMinQty > 0 ? localMinQty : 2;
|
||
if (bottleQty < minBottles) {
|
||
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
||
if (bottleQty < minBottles) {
|
||
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||
return {
|
||
ok: false,
|
||
message:
|
||
deliveryType === 'LOCAL'
|
||
? `同城配送至少购买 ${minSale}${unit}`
|
||
: `跨城配送至少购买 ${minSale}${unit}`,
|
||
};
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
export const DEFAULT_STORE_SETTLEMENT_RATE = 0.6;
|
||
|
||
export function resolveSettlementRate(raw: unknown): number {
|
||
const n = Number(raw);
|
||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_SETTLEMENT_RATE;
|
||
}
|
||
|
||
export function validateRedeemAmount(
|
||
balance: number,
|
||
amount: number,
|
||
documentAmount?: number | null,
|
||
): { ok: boolean; message?: string } {
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
return { ok: false, message: '核销金额必须大于 0' };
|
||
}
|
||
if (!Number.isFinite(balance) || amount > balance) {
|
||
return { ok: false, message: '核销金额不能超过可用余额' };
|
||
}
|
||
if (documentAmount != null && Number.isFinite(documentAmount) && amount > documentAmount) {
|
||
return { ok: false, message: '核销金额不能超过该核销单可用金额' };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
export interface BenefitCouponBalance {
|
||
id: string;
|
||
balance: number;
|
||
createdAt: number;
|
||
}
|
||
|
||
/** FIFO 跨多张权益券分配核销金额(购酒按单发券,展示为总余额) */
|
||
export function allocateBenefitCoupons(
|
||
coupons: BenefitCouponBalance[],
|
||
amount: number,
|
||
documentAmount?: number | null,
|
||
): { ok: true; allocations: Array<{ couponId: string; amount: number }> } | { ok: false; message: string } {
|
||
const active = coupons
|
||
.filter((c) => c.balance > 0)
|
||
.sort((a, b) => a.createdAt - b.createdAt);
|
||
const totalBalance = active.reduce((sum, c) => sum + c.balance, 0);
|
||
const check = validateRedeemAmount(totalBalance, amount, documentAmount);
|
||
if (!check.ok) return { ok: false, message: check.message! };
|
||
|
||
let remaining = amount;
|
||
const allocations: Array<{ couponId: string; amount: number }> = [];
|
||
for (const coupon of active) {
|
||
if (remaining <= 0) break;
|
||
const take = Math.min(coupon.balance, remaining);
|
||
allocations.push({ couponId: coupon.id, amount: take });
|
||
remaining = Math.round((remaining - take) * 100) / 100;
|
||
}
|
||
|
||
if (remaining > 0) {
|
||
return { ok: false, message: '权益余额不足' };
|
||
}
|
||
|
||
return { ok: true, allocations };
|
||
}
|
||
|
||
export function calcBenefitSummary(
|
||
balances: number[],
|
||
): { totalBalance: number; maxRedeemAmount: number; activeCouponCount: number } {
|
||
const totalBalance = Math.round(balances.reduce((sum, b) => sum + b, 0) * 100) / 100;
|
||
return {
|
||
totalBalance,
|
||
maxRedeemAmount: totalBalance,
|
||
activeCouponCount: balances.length,
|
||
};
|
||
}
|
||
|
||
export function calcRedeemSettleAmount(amount: number, settlementRate: number): number {
|
||
return Math.round(amount * settlementRate * 100) / 100;
|
||
}
|
||
|
||
export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }>): number {
|
||
return Math.round(payouts.reduce((sum, p) => sum + Number(p.payoutAmount || 0), 0) * 100) / 100;
|
||
}
|
||
|
||
export type ValidateStoreWithdrawInput = {
|
||
availableAmount: number;
|
||
requestAmount: number;
|
||
todayApplied: number;
|
||
dailyLimit: number;
|
||
hasBankAccount: boolean;
|
||
};
|
||
|
||
/** 门店未出账提现护栏(FIN-002 单日上限 + 账户) */
|
||
export function validateStoreWithdraw(
|
||
input: ValidateStoreWithdrawInput,
|
||
): { ok: boolean; message?: string } {
|
||
if (!input.hasBankAccount) {
|
||
return { ok: false, message: '请先完善入驻收款账户后再提现' };
|
||
}
|
||
if (!(input.requestAmount > 0)) {
|
||
return { ok: false, message: '提现金额必须大于 0' };
|
||
}
|
||
if (input.requestAmount > input.availableAmount + 1e-9) {
|
||
return { ok: false, message: '提现金额不能超过可提未出账余额' };
|
||
}
|
||
const remaining = Math.round((input.dailyLimit - input.todayApplied) * 100) / 100;
|
||
if (remaining <= 0) {
|
||
return { ok: false, message: `已达单店单日提现上限 ¥${input.dailyLimit.toFixed(2)}` };
|
||
}
|
||
if (input.requestAmount > remaining + 1e-9) {
|
||
return {
|
||
ok: false,
|
||
message: `超过单日剩余额度 ¥${remaining.toFixed(2)}(上限 ¥${input.dailyLimit.toFixed(2)})`,
|
||
};
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
/**
|
||
* 按 FIFO 选取未出账 payout,使合计尽量等于 targetAmount(不超过目标)。
|
||
* 若无法精确凑齐,返回合计 ≤ target 的最长前缀子集。
|
||
*/
|
||
export function pickPayoutsForWithdrawAmount<T extends { payoutAmount: number }>(
|
||
payoutsAsc: T[],
|
||
targetAmount: number,
|
||
): { ok: true; selected: T[]; amount: number } | { ok: false; message: string } {
|
||
if (!(targetAmount > 0)) return { ok: false, message: '提现金额必须大于 0' };
|
||
const selected: T[] = [];
|
||
let sum = 0;
|
||
for (const p of payoutsAsc) {
|
||
const next = Math.round((sum + Number(p.payoutAmount)) * 100) / 100;
|
||
if (next <= targetAmount + 1e-9) {
|
||
selected.push(p);
|
||
sum = next;
|
||
if (Math.abs(sum - targetAmount) < 1e-9) break;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
if (!selected.length) {
|
||
return { ok: false, message: '无可匹配的未出账结算明细,请调整提现金额' };
|
||
}
|
||
if (Math.abs(sum - targetAmount) > 1e-9) {
|
||
return {
|
||
ok: false,
|
||
message: `无法按 ¥${targetAmount.toFixed(2)} 精确匹配明细,请按可提总额全额提现或调整金额`,
|
||
};
|
||
}
|
||
return { ok: true, selected, amount: sum };
|
||
}
|
||
|
||
const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||
|
||
export type BusinessHoursSegment = { open: string; close: string };
|
||
|
||
export function timeToMinutes(hhmm: string): number {
|
||
const [h, m] = hhmm.split(':').map(Number);
|
||
return h * 60 + m;
|
||
}
|
||
|
||
/** 校验 1~2 段营业时间,例如 09:00-22:00 或 09:00-14:00 + 17:00-21:00 */
|
||
export function validateBusinessHours(
|
||
segments: BusinessHoursSegment[],
|
||
): { ok: boolean; message?: string } {
|
||
const cleaned = segments
|
||
.map((s) => ({ open: String(s.open || '').trim(), close: String(s.close || '').trim() }))
|
||
.filter((s) => s.open || s.close);
|
||
if (cleaned.length < 1) return { ok: false, message: '请设置营业时间' };
|
||
if (cleaned.length > 2) return { ok: false, message: '营业时间最多支持 2 段' };
|
||
for (let i = 0; i < cleaned.length; i++) {
|
||
const s = cleaned[i];
|
||
if (!TIME_HM_RE.test(s.open) || !TIME_HM_RE.test(s.close)) {
|
||
return { ok: false, message: `第 ${i + 1} 段时间格式须为 HH:MM` };
|
||
}
|
||
if (timeToMinutes(s.open) >= timeToMinutes(s.close)) {
|
||
return { ok: false, message: `第 ${i + 1} 段结束时间须晚于开始时间` };
|
||
}
|
||
}
|
||
if (cleaned.length === 2 && timeToMinutes(cleaned[0].close) >= timeToMinutes(cleaned[1].open)) {
|
||
return { ok: false, message: '第二段开始时间须晚于第一段结束时间' };
|
||
}
|
||
return { ok: true };
|
||
}
|
||
|
||
export function formatBusinessHours(store: {
|
||
openTime?: string | null;
|
||
closeTime?: string | null;
|
||
openTime2?: string | null;
|
||
closeTime2?: string | null;
|
||
}): string {
|
||
const parts: string[] = [];
|
||
if (store.openTime && store.closeTime) {
|
||
parts.push(`${store.openTime}-${store.closeTime}`);
|
||
}
|
||
if (store.openTime2 && store.closeTime2) {
|
||
parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||
}
|
||
return parts.length ? parts.join(',') : '10:00-22:00';
|
||
}
|
||
|
||
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
|
||
export const BOTTLES_PER_BOX = 6;
|
||
|
||
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
|
||
export const XFX_AUTO_DISPATCH_MAX_BOXES = 10;
|
||
|
||
/** 按箱规向上取整得到箱数 */
|
||
export function calcOrderBoxCount(quantity: number, bottlesPerBox = BOTTLES_PER_BOX): number {
|
||
const qty = Math.floor(Number(quantity) || 0);
|
||
if (qty <= 0 || !(bottlesPerBox > 0)) return 0;
|
||
return Math.ceil(qty / bottlesPerBox);
|
||
}
|
||
|
||
/** 是否因大单拦截自动推承运商(默认 ≥10 箱 = 60 瓶) */
|
||
export function shouldHoldAutoCourierDispatch(
|
||
quantity: number,
|
||
options?: { bottlesPerBox?: number; maxBoxes?: number },
|
||
): boolean {
|
||
const bottlesPerBox = options?.bottlesPerBox ?? BOTTLES_PER_BOX;
|
||
const maxBoxes = options?.maxBoxes ?? XFX_AUTO_DISPATCH_MAX_BOXES;
|
||
const qty = Math.floor(Number(quantity) || 0);
|
||
return qty >= bottlesPerBox * maxBoxes;
|
||
}
|
||
|
||
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
|
||
export type LogisticsPricingRule = {
|
||
baseBottles: number;
|
||
baseFee: number;
|
||
extraBottleFee: number;
|
||
boxBottles?: number;
|
||
boxFee?: number;
|
||
};
|
||
|
||
function roundMoney(n: number): number {
|
||
return Math.round(n * 100) / 100;
|
||
}
|
||
|
||
function calcLogisticsBottleLadder(quantity: number, rule: LogisticsPricingRule): number {
|
||
const qty = Math.floor(quantity);
|
||
if (qty <= 0) return 0;
|
||
if (qty <= rule.baseBottles) return rule.baseFee;
|
||
return rule.baseFee + (qty - rule.baseBottles) * rule.extraBottleFee;
|
||
}
|
||
|
||
/**
|
||
* 按承运商计价标准计算单票物流费。
|
||
* 配置了箱规时:整箱按 boxFee,余瓶按「起送瓶数/加瓶」阶梯。
|
||
*/
|
||
export function calcLogisticsFeeByBottles(quantity: number, rule: LogisticsPricingRule): number {
|
||
const qty = Math.floor(Number(quantity) || 0);
|
||
if (qty <= 0) return 0;
|
||
if (!(rule.baseBottles > 0) || !(rule.baseFee >= 0) || !(rule.extraBottleFee >= 0)) {
|
||
throw new Error('计价标准不完整');
|
||
}
|
||
|
||
const boxBottles = rule.boxBottles && rule.boxBottles > 0 ? rule.boxBottles : 0;
|
||
const boxFee = rule.boxFee != null && rule.boxFee >= 0 ? rule.boxFee : null;
|
||
if (boxBottles > 0 && boxFee != null) {
|
||
const boxes = Math.floor(qty / boxBottles);
|
||
const rem = qty % boxBottles;
|
||
return roundMoney(boxes * boxFee + calcLogisticsBottleLadder(rem, rule));
|
||
}
|
||
return roundMoney(calcLogisticsBottleLadder(qty, rule));
|
||
}
|
||
|
||
export function generateUserNo(): string {
|
||
const suffix = Math.floor(10000000 + Math.random() * 90000000);
|
||
return `DK${suffix}`;
|
||
}
|
||
|
||
export function generateOrderNo(): string {
|
||
const now = new Date();
|
||
const y = now.getFullYear();
|
||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||
const d = String(now.getDate()).padStart(2, '0');
|
||
const rand = String(Math.floor(Math.random() * 100000)).padStart(5, '0');
|
||
return `DK${y}${m}${d}${rand}`;
|
||
}
|
||
|
||
export function generateCouponNo(): string {
|
||
return `BC${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||
}
|
||
|
||
export function generateRedeemNo(): string {
|
||
return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||
}
|
||
|
||
export function generateRedeemPendingNo(): string {
|
||
return `RP${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||
}
|
||
|
||
/** 弱网核销:网络类失败达到该次数后触发兜底 */
|
||
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
|
||
|
||
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
|
||
|
||
/** 门店端/服务端共用:区分网络类与业务类核销错误(仅 NETWORK 计入 5 次) */
|
||
export function classifyRedeemClientError(input: {
|
||
status?: number | null;
|
||
message?: string | null;
|
||
}): RedeemErrorClass {
|
||
const status = input.status;
|
||
const message = (input.message ?? '').toLowerCase();
|
||
if (status != null && status >= 500) return 'NETWORK';
|
||
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||
return 'NETWORK';
|
||
}
|
||
if (
|
||
/failed to fetch|network|timeout|timed out|econnreset|econnrefused|etimedout|abort|offline|连接|网络|超时/.test(
|
||
message,
|
||
)
|
||
) {
|
||
return 'NETWORK';
|
||
}
|
||
return 'BUSINESS';
|
||
}
|
||
|
||
export function orderTabToStatuses(tab: string): string[] | undefined {
|
||
switch (tab) {
|
||
case 'pending_pay':
|
||
return ['PENDING_PAY'];
|
||
case 'paid':
|
||
case 'pending_ship':
|
||
case 'pending_receive':
|
||
return ['PENDING_SHIP', 'OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE'];
|
||
case 'completed':
|
||
return ['COMPLETED'];
|
||
default:
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
export * from './city-partner';
|
||
export * from './dev-plan';
|
||
export * from './support-ticket';
|
||
export * from './phone';
|