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 };
@@ -32,6 +32,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
{ key: 'system_settings_finance', label: '财务结算', group: '系统设置' },
] as const;
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
@@ -61,6 +62,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
app: 'system_settings_app',
deploy: 'system_settings_deploy',
winery_bank: 'system_settings_winery_bank',
finance: 'system_settings_finance',
};
export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values(
@@ -127,6 +129,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'invoices',
'logs',
'system_settings_winery_bank',
'system_settings_finance',
],
CUSTOMER_SERVICE: [
'dashboard',
+44
View File
@@ -1,5 +1,49 @@
export type FinancePayStatus = 'UNPAID' | 'PAID';
/** 门店未出账提现单日上限默认值(FIN-002,可配置) */
export const DEFAULT_STORE_WITHDRAW_DAILY_LIMIT = 5000;
export type StoreWithdrawStatus = 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
export const STORE_WITHDRAW_STATUS_LABELS: Record<StoreWithdrawStatus, string> = {
PENDING_REVIEW: '待审核',
REJECTED: '已驳回',
PAID: '已结算',
};
export interface StoreWithdrawBankAccountDto {
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
}
export interface StoreWithdrawSummaryDto {
availableAmount: number;
pendingReviewAmount: number;
todayAppliedAmount: number;
dailyLimit: number;
remainingDailyLimit: number;
whitelistEnabled: boolean;
isPrimary: boolean;
hasBankAccount: boolean;
hasPendingRequest: boolean;
bankAccount?: StoreWithdrawBankAccountDto | null;
}
export interface StoreWithdrawRequestDto {
id: string;
withdrawNo: string;
storeId: string;
amount: number;
payoutCount: number;
status: StoreWithdrawStatus;
rejectReason?: string | null;
appliedAt: string;
reviewedAt?: string | null;
paidAt?: string | null;
paymentRef?: string | null;
}
export interface StorePayoutDto {
id: string;
redeemAmount: number;