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( 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';