merge(dev): 全部门店可提现+企微提醒
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-03 15:43:34 +08:00
12 changed files with 60 additions and 87 deletions
-1
View File
@@ -29,7 +29,6 @@ export type StoreCreateForm = {
settlementRate?: number; settlementRate?: number;
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
withdrawWhitelistEnabled?: boolean;
}; };
const PHONE_RE = /^1\d{10}$/; const PHONE_RE = /^1\d{10}$/;
-20
View File
@@ -373,7 +373,6 @@ type StoreRow = {
coverUrl: string | null; coverUrl: string | null;
createdAt: string; createdAt: string;
visibilityWhitelistEnabled?: boolean; visibilityWhitelistEnabled?: boolean;
withdrawWhitelistEnabled?: boolean;
visibilityPhones?: string[]; visibilityPhones?: string[];
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { companyName: string }; partner?: { companyName: string };
@@ -593,7 +592,6 @@ export default function StoresPage() {
bankAccountNo: account?.bankAccountNo || undefined, bankAccountNo: account?.bankAccountNo || undefined,
bankBranch: account?.bankBranch || undefined, bankBranch: account?.bankBranch || undefined,
visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled,
withdrawWhitelistEnabled: !!d.withdrawWhitelistEnabled,
visibilityPhones: Array.isArray(d.visibilityPhones) visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[]) ? (d.visibilityPhones as string[])
: [], : [],
@@ -637,7 +635,6 @@ export default function StoresPage() {
bankAccountNo: v.bankAccountNo ?? null, bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null, bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
withdrawWhitelistEnabled: !!v.withdrawWhitelistEnabled,
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? []) visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim()) .map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean), .filter(Boolean),
@@ -814,7 +811,6 @@ export default function StoresPage() {
bankBranch: values.bankBranch.trim(), bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100, settlementRate: Number(values.settlementRate ?? 60) / 100,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled, visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
withdrawWhitelistEnabled: !!values.withdrawWhitelistEnabled,
visibilityPhones: (values.visibilityPhones ?? []) visibilityPhones: (values.visibilityPhones ?? [])
.map((p: string) => String(p || '').replace(/\D/g, '').trim()) .map((p: string) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean), .filter(Boolean),
@@ -1184,14 +1180,6 @@ export default function StoresPage() {
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}> <Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" /> <InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item> </Form.Item>
<Form.Item
name="withdrawWhitelistEnabled"
label="未出账提现白名单"
valuePropName="checked"
extra="FIN-001:开启后该门店可对未出账余额发起手动提现"
>
<Switch checkedChildren="允许" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="bankAccountName" label="结算户名"> <Form.Item name="bankAccountName" label="结算户名">
<Input placeholder="开户名" /> <Input placeholder="开户名" />
</Form.Item> </Form.Item>
@@ -1438,14 +1426,6 @@ export default function StoresPage() {
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}> <Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" /> <InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item> </Form.Item>
<Form.Item
name="withdrawWhitelistEnabled"
label="未出账提现白名单"
valuePropName="checked"
initialValue={false}
>
<Switch checkedChildren="允许" unCheckedChildren="关闭" />
</Form.Item>
<Alert <Alert
type="info" type="info"
showIcon showIcon
+1 -2
View File
@@ -77,7 +77,6 @@ export default function WithdrawPage() {
const canApply = const canApply =
!!summary?.isPrimary && !!summary?.isPrimary &&
!!summary.whitelistEnabled &&
summary.availableAmount > 0 && summary.availableAmount > 0 &&
!summary.hasPendingRequest && !summary.hasPendingRequest &&
summary.hasBankAccount && summary.hasBankAccount &&
@@ -125,8 +124,8 @@ export default function WithdrawPage() {
info info
</span> </span>
¥{formatMoney(summary?.dailyLimit ?? 5000)} ¥{formatMoney(summary?.dailyLimit ?? 5000)}
{summary && !summary.whitelistEnabled ? ' · 未开通未出账提现白名单' : ''}
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''} {summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
{!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''}
</p> </p>
</section> </section>
-5
View File
@@ -247,7 +247,6 @@ describe('sumUnbilledPayoutAmount', () => {
describe('validateStoreWithdraw', () => { describe('validateStoreWithdraw', () => {
const base = { const base = {
whitelistEnabled: true,
availableAmount: 1000, availableAmount: 1000,
requestAmount: 200, requestAmount: 200,
todayApplied: 0, todayApplied: 0,
@@ -256,10 +255,6 @@ describe('validateStoreWithdraw', () => {
hasBankAccount: true, hasBankAccount: true,
}; };
it('rejects non-whitelist stores', () => {
expect(validateStoreWithdraw({ ...base, whitelistEnabled: false }).ok).toBe(false);
});
it('rejects when daily limit exceeded', () => { it('rejects when daily limit exceeded', () => {
expect( expect(
validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok, validateStoreWithdraw({ ...base, todayApplied: 4900, requestAmount: 200, dailyLimit: 5000 }).ok,
+1 -5
View File
@@ -101,7 +101,6 @@ export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }>
} }
export type ValidateStoreWithdrawInput = { export type ValidateStoreWithdrawInput = {
whitelistEnabled: boolean;
availableAmount: number; availableAmount: number;
requestAmount: number; requestAmount: number;
todayApplied: number; todayApplied: number;
@@ -110,13 +109,10 @@ export type ValidateStoreWithdrawInput = {
hasBankAccount: boolean; hasBankAccount: boolean;
}; };
/** 门店未出账提现护栏(FIN-001/002 + 幂等/账户) */ /** 门店未出账提现护栏(FIN-002 单日上限 + 幂等/账户) */
export function validateStoreWithdraw( export function validateStoreWithdraw(
input: ValidateStoreWithdrawInput, input: ValidateStoreWithdrawInput,
): { ok: boolean; message?: string } { ): { ok: boolean; message?: string } {
if (!input.whitelistEnabled) {
return { ok: false, message: '该门店未开通未出账提现(需总部白名单)' };
}
if (!input.hasBankAccount) { if (!input.hasBankAccount) {
return { ok: false, message: '请先完善入驻收款账户后再提现' }; return { ok: false, message: '请先完善入驻收款账户后再提现' };
} }
-1
View File
@@ -23,7 +23,6 @@ export interface StoreWithdrawSummaryDto {
todayAppliedAmount: number; todayAppliedAmount: number;
dailyLimit: number; dailyLimit: number;
remainingDailyLimit: number; remainingDailyLimit: number;
whitelistEnabled: boolean;
isPrimary: boolean; isPrimary: boolean;
hasBankAccount: boolean; hasBankAccount: boolean;
hasPendingRequest: boolean; hasPendingRequest: boolean;
+8 -18
View File
@@ -172,30 +172,12 @@ async function main() {
const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId; const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId;
if (!shopStoreId) throw new Error('Shop storeId missing after login'); if (!shopStoreId) throw new Error('Shop storeId missing after login');
const noWhitelistMsg = await expectFail('SHOP_H5', '/shop/withdraw', {
method: 'POST',
token: shopToken,
body: JSON.stringify({}),
});
if (!String(noWhitelistMsg).includes('白名单')) {
throw new Error(`Expected FIN-001 whitelist reject, got: ${noWhitelistMsg}`);
}
await req('HQ_WEB', `/admin/stores/${shopStoreId}`, {
method: 'PUT',
token: admin.accessToken,
body: JSON.stringify({ withdrawWhitelistEnabled: true }),
});
const withdrawSummary = await req('SHOP_H5', '/shop/withdraw/summary', { const withdrawSummary = await req('SHOP_H5', '/shop/withdraw/summary', {
token: shopToken, token: shopToken,
}); });
if (!(withdrawSummary.availableAmount > 0)) { if (!(withdrawSummary.availableAmount > 0)) {
throw new Error('Expected available withdraw amount after redeem'); throw new Error('Expected available withdraw amount after redeem');
} }
if (!withdrawSummary.whitelistEnabled) {
throw new Error('Expected withdraw whitelist enabled');
}
const applied = await req('SHOP_H5', '/shop/withdraw', { const applied = await req('SHOP_H5', '/shop/withdraw', {
method: 'POST', method: 'POST',
@@ -215,6 +197,14 @@ async function main() {
throw new Error(`Expected pending-request reject, got: ${pendingDup}`); throw new Error(`Expected pending-request reject, got: ${pendingDup}`);
} }
// 锁定中的明细不可再出账(T+1 排除 withdrawItem
const summaryAfter = await req('SHOP_H5', '/shop/withdraw/summary', {
token: shopToken,
});
if (summaryAfter.availableAmount !== 0) {
throw new Error('Expected availableAmount=0 while withdraw pending');
}
await req('HQ_WEB', `/admin/store-withdrawals/${applied.id}/approve`, { await req('HQ_WEB', `/admin/store-withdrawals/${applied.id}/approve`, {
method: 'POST', method: 'POST',
token: admin.accessToken, token: admin.accessToken,
@@ -66,7 +66,7 @@ export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [
body: [ body: [
'门店账单:核销×60%,T+1 出账;HQ 确认打款。', '门店账单:核销×60%,T+1 出账;HQ 确认打款。',
'合伙人月账独立确认打款。', '合伙人月账独立确认打款。',
'未出账提现受白名单/单日上限等 FIN 护栏。', '门店可对未出账核销主动提现(受单日上限);申请后锁定明细不进入次日出账,HQ 审后打款并企微提醒。',
].join('\n'), ].join('\n'),
}, },
{ {
@@ -49,22 +49,13 @@ export class SettlementScheduler {
} }
} }
/** FIN-003:工作日 18:05 扫描超时未审门店提现 */ /** FIN-003:工作日 18:05 扫描超时未审门店提现(企微提醒在 SettlementService 内发送) */
@Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' }) @Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' })
async handleWithdrawOverdueAlert() { async handleWithdrawOverdueAlert() {
this.logger.log('Store withdraw overdue scan start'); this.logger.log('Store withdraw overdue scan start');
try { try {
const summary = await this.settlementService.scanOverdueStoreWithdrawals(); const summary = await this.settlementService.scanOverdueStoreWithdrawals();
this.logger.log(`Store withdraw overdue: ${JSON.stringify(summary)}`); this.logger.log(`Store withdraw overdue: ${JSON.stringify(summary)}`);
if (summary.overdueCount > 0) {
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现超时未审',
detail: `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
});
}
} catch (e) { } catch (e) {
this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e); this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e);
this.alert.notify({ this.alert.notify({
@@ -72,7 +63,7 @@ export class SettlementScheduler {
category: 'job', category: 'job',
title: '门店提现超时扫描失败', title: '门店提现超时扫描失败',
detail: e instanceof Error ? e.message : String(e), detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`, dedupeKey: `job_store_withdraw_overdue_fail|${new Date().toISOString().slice(0, 10)}`,
}); });
} }
} }
@@ -357,9 +357,6 @@ export class AdminStoresService {
...(dto.visibilityWhitelistEnabled !== undefined ...(dto.visibilityWhitelistEnabled !== undefined
? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled }
: {}), : {}),
...(dto.withdrawWhitelistEnabled !== undefined
? { withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled }
: {}),
...(latitude != null && longitude != null ? { latitude, longitude } : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}),
}, },
}); });
@@ -519,7 +516,6 @@ export class AdminStoresService {
openTime2: openTime2 || null, openTime2: openTime2 || null,
closeTime2: closeTime2 || null, closeTime2: closeTime2 || null,
visibilityWhitelistEnabled: whitelistEnabled, visibilityWhitelistEnabled: whitelistEnabled,
withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled,
...(latitude != null && longitude != null ? { latitude, longitude } : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN', status: 'OPEN',
auditStatus: 'APPROVED', auditStatus: 'APPROVED',
@@ -138,11 +138,6 @@ export class CreateStoreDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
visibilityPhones?: string[]; visibilityPhones?: string[];
/** FIN-001:允许未出账手动提现 */
@IsOptional()
@IsBoolean()
withdrawWhitelistEnabled?: boolean;
} }
export class UpdateStoreDto { export class UpdateStoreDto {
@@ -242,11 +237,6 @@ export class UpdateStoreDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
visibilityPhones?: string[]; visibilityPhones?: string[];
/** FIN-001:允许未出账手动提现 */
@IsOptional()
@IsBoolean()
withdrawWhitelistEnabled?: boolean;
} }
export class CreateStoreAccountDto { export class CreateStoreAccountDto {
@@ -14,6 +14,7 @@ import {
} from '@dukang/domain'; } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service'; import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
@@ -72,6 +73,7 @@ export class SettlementService {
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService, private readonly partnerCityService: PartnerCityService,
private readonly fulfillmentProviderService: FulfillmentProviderService, private readonly fulfillmentProviderService: FulfillmentProviderService,
private readonly alert: AlertService,
) {} ) {}
// ─── Store payout (line) ───────────────────────────── // ─── Store payout (line) ─────────────────────────────
@@ -168,11 +170,7 @@ export class SettlementService {
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) { async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
await this.assertShopStoreAccess(storeAccountId, storeId); await this.assertShopStoreAccess(storeAccountId, storeId);
const [store, account, available, pending, todayApplied] = await Promise.all([ const [account, available, pending, todayApplied] = await Promise.all([
this.prisma.store.findUniqueOrThrow({
where: { id: storeId },
select: { withdrawWhitelistEnabled: true },
}),
this.prisma.storeAccount.findUniqueOrThrow({ this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId }, where: { id: storeAccountId },
select: { select: {
@@ -205,7 +203,6 @@ export class SettlementService {
todayAppliedAmount: todayApplied, todayAppliedAmount: todayApplied,
dailyLimit, dailyLimit,
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)), remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
whitelistEnabled: store.withdrawWhitelistEnabled,
isPrimary: account.isPrimary === 1, isPrimary: account.isPrimary === 1,
hasBankAccount, hasBankAccount,
hasPendingRequest: !!pending, hasPendingRequest: !!pending,
@@ -227,7 +224,7 @@ export class SettlementService {
const [store, account, available, pending, todayApplied] = await Promise.all([ const [store, account, available, pending, todayApplied] = await Promise.all([
this.prisma.store.findUniqueOrThrow({ this.prisma.store.findUniqueOrThrow({
where: { id: storeId }, where: { id: storeId },
select: { withdrawWhitelistEnabled: true }, select: { id: true, name: true, phone: true, cityName: true },
}), }),
this.prisma.storeAccount.findUniqueOrThrow({ this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId }, where: { id: storeAccountId },
@@ -263,7 +260,6 @@ export class SettlementService {
: availableAmount; : availableAmount;
const guard = validateStoreWithdraw({ const guard = validateStoreWithdraw({
whitelistEnabled: store.withdrawWhitelistEnabled,
availableAmount, availableAmount,
requestAmount, requestAmount,
todayApplied, todayApplied,
@@ -334,6 +330,21 @@ export class SettlementService {
}, },
}); });
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现待审',
detail: [
`门店:${store.name}${store.cityName || '-'} / ${store.phone || '-'}`,
`单号:${created.withdrawNo}`,
`金额:¥${Number(created.amount).toFixed(2)}`,
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
'请尽快在 HQ「财务 → 门店提现审」处理。',
].join('\n'),
dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
dedupeTtlSec: 3600,
});
return serializeBigInt(created); return serializeBigInt(created);
} }
@@ -433,7 +444,6 @@ export class SettlementService {
name: true, name: true,
cityName: true, cityName: true,
phone: true, phone: true,
withdrawWhitelistEnabled: true,
}, },
}, },
storeAccount: { storeAccount: {
@@ -562,6 +572,34 @@ export class SettlementService {
/** FIN-003:工作日 18:00 扫描超时未审提现 */ /** FIN-003:工作日 18:00 扫描超时未审提现 */
async scanOverdueStoreWithdrawals() { async scanOverdueStoreWithdrawals() {
const summary = await this.getStoreWithdrawOverdueSummary(); const summary = await this.getStoreWithdrawOverdueSummary();
if (summary.overdueCount > 0) {
const overdueRows = await this.prisma.storeWithdrawRequest.findMany({
where: { status: 'PENDING_REVIEW' },
include: { store: { select: { name: true, phone: true } } },
orderBy: { appliedAt: 'asc' },
take: 20,
});
const now = new Date();
const lines = overdueRows
.filter((r) => isWithdrawOverdue(r.appliedAt, now))
.slice(0, 10)
.map(
(r) =>
`- ${r.store?.name || r.storeId} ${r.withdrawNo} ¥${Number(r.amount).toFixed(2)}`,
);
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现超时未审',
detail: [
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
...lines,
'请尽快在 HQ「财务 → 门店提现审」处理。',
].join('\n'),
dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
dedupeTtlSec: 6 * 3600,
});
}
return summary; return summary;
} }