Files
dukang/packages/domain/src/index.ts
T
jacy a01217539c
CI / verify (pull_request) Waiting to run
v3.5.4版本提交
2026-08-21 15:48:15 +08:00

400 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
/** 校验 12 段营业时间,例如 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';