feat(settlement): 门店未出账手动提现与总部审核(OPT-010)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 15:32:01 +08:00
parent 666bcb8633
commit f3353bbce5
29 changed files with 1733 additions and 15 deletions
+58
View File
@@ -15,6 +15,9 @@ import {
classifyRedeemClientError,
generateRedeemPendingNo,
REDEEM_WEAKNET_FAIL_THRESHOLD,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
pickPayoutsForWithdrawAmount,
} from './index';
describe('calcBenefitAmount', () => {
@@ -235,3 +238,58 @@ describe('generateRedeemPendingNo', () => {
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
});
});
describe('sumUnbilledPayoutAmount', () => {
it('sums payout amounts', () => {
expect(sumUnbilledPayoutAmount([{ payoutAmount: 60 }, { payoutAmount: 40.5 }])).toBe(100.5);
});
});
describe('validateStoreWithdraw', () => {
const base = {
whitelistEnabled: true,
availableAmount: 1000,
requestAmount: 200,
todayApplied: 0,
dailyLimit: 5000,
hasPendingRequest: false,
hasBankAccount: true,
};
it('rejects non-whitelist stores', () => {
expect(validateStoreWithdraw({ ...base, whitelistEnabled: false }).ok).toBe(false);
});
it('rejects when daily limit exceeded', () => {
expect(
validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok,
).toBe(false);
});
it('rejects pending request / missing bank / over available', () => {
expect(validateStoreWithdraw({ ...base, hasPendingRequest: true }).ok).toBe(false);
expect(validateStoreWithdraw({ ...base, hasBankAccount: false }).ok).toBe(false);
expect(validateStoreWithdraw({ ...base, requestAmount: 1001 }).ok).toBe(false);
});
it('accepts valid request', () => {
expect(validateStoreWithdraw(base).ok).toBe(true);
});
});
describe('pickPayoutsForWithdrawAmount', () => {
it('picks FIFO exact match', () => {
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }, { payoutAmount: 30 }];
const r = pickPayoutsForWithdrawAmount(rows, 100);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.selected).toHaveLength(2);
expect(r.amount).toBe(100);
}
});
it('rejects non-exact match', () => {
const rows = [{ payoutAmount: 60 }, { payoutAmount: 40 }];
expect(pickPayoutsForWithdrawAmount(rows, 50).ok).toBe(false);
});
});
+79
View File
@@ -96,6 +96,85 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: 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 = {
whitelistEnabled: boolean;
availableAmount: number;
requestAmount: number;
todayApplied: number;
dailyLimit: number;
hasPendingRequest: boolean;
hasBankAccount: boolean;
};
/** 门店未出账提现护栏(FIN-001/002 + 幂等/账户) */
export function validateStoreWithdraw(
input: ValidateStoreWithdrawInput,
): { ok: boolean; message?: string } {
if (!input.whitelistEnabled) {
return { ok: false, message: '该门店未开通未出账提现(需总部白名单)' };
}
if (!input.hasBankAccount) {
return { ok: false, message: '请先完善入驻收款账户后再提现' };
}
if (input.hasPendingRequest) {
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 };