diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index 555d155..37fa198 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -29,7 +29,6 @@ export type StoreCreateForm = { settlementRate?: number; visibilityWhitelistEnabled?: boolean; visibilityPhones?: string[]; - withdrawWhitelistEnabled?: boolean; }; const PHONE_RE = /^1\d{10}$/; diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 0e710f5..1785d61 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -373,7 +373,6 @@ type StoreRow = { coverUrl: string | null; createdAt: string; visibilityWhitelistEnabled?: boolean; - withdrawWhitelistEnabled?: boolean; visibilityPhones?: string[]; cityRef?: { name: string; code: string }; partner?: { companyName: string }; @@ -593,7 +592,6 @@ export default function StoresPage() { bankAccountNo: account?.bankAccountNo || undefined, bankBranch: account?.bankBranch || undefined, visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled, - withdrawWhitelistEnabled: !!d.withdrawWhitelistEnabled, visibilityPhones: Array.isArray(d.visibilityPhones) ? (d.visibilityPhones as string[]) : [], @@ -637,7 +635,6 @@ export default function StoresPage() { bankAccountNo: v.bankAccountNo ?? null, bankBranch: v.bankBranch ?? null, visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, - withdrawWhitelistEnabled: !!v.withdrawWhitelistEnabled, visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? []) .map((p) => String(p || '').replace(/\D/g, '').trim()) .filter(Boolean), @@ -814,7 +811,6 @@ export default function StoresPage() { bankBranch: values.bankBranch.trim(), settlementRate: Number(values.settlementRate ?? 60) / 100, visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled, - withdrawWhitelistEnabled: !!values.withdrawWhitelistEnabled, visibilityPhones: (values.visibilityPhones ?? []) .map((p: string) => String(p || '').replace(/\D/g, '').trim()) .filter(Boolean), @@ -1184,14 +1180,6 @@ export default function StoresPage() { - - - @@ -1438,14 +1426,6 @@ export default function StoresPage() { - - - 0 && !summary.hasPendingRequest && summary.hasBankAccount && @@ -125,8 +124,8 @@ export default function WithdrawPage() { info 单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)} - {summary && !summary.whitelistEnabled ? ' · 未开通未出账提现白名单' : ''} {summary?.hasPendingRequest ? ' · 已有待审核申请' : ''} + {!summary?.hasBankAccount ? ' · 请先完善收款账户' : ''}

diff --git a/packages/domain/src/index.test.ts b/packages/domain/src/index.test.ts index 5d2f8d5..b130e8f 100644 --- a/packages/domain/src/index.test.ts +++ b/packages/domain/src/index.test.ts @@ -247,7 +247,6 @@ describe('sumUnbilledPayoutAmount', () => { describe('validateStoreWithdraw', () => { const base = { - whitelistEnabled: true, availableAmount: 1000, requestAmount: 200, todayApplied: 0, @@ -256,10 +255,6 @@ describe('validateStoreWithdraw', () => { 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, diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 99d5957..3727d74 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -101,7 +101,6 @@ export function sumUnbilledPayoutAmount(payouts: Array<{ payoutAmount: number }> } export type ValidateStoreWithdrawInput = { - whitelistEnabled: boolean; availableAmount: number; requestAmount: number; todayApplied: number; @@ -110,13 +109,10 @@ export type ValidateStoreWithdrawInput = { hasBankAccount: boolean; }; -/** 门店未出账提现护栏(FIN-001/002 + 幂等/账户) */ +/** 门店未出账提现护栏(FIN-002 单日上限 + 幂等/账户) */ export function validateStoreWithdraw( input: ValidateStoreWithdrawInput, ): { ok: boolean; message?: string } { - if (!input.whitelistEnabled) { - return { ok: false, message: '该门店未开通未出账提现(需总部白名单)' }; - } if (!input.hasBankAccount) { return { ok: false, message: '请先完善入驻收款账户后再提现' }; } diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index 64361b8..fca95d8 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -23,7 +23,6 @@ export interface StoreWithdrawSummaryDto { todayAppliedAmount: number; dailyLimit: number; remainingDailyLimit: number; - whitelistEnabled: boolean; isPrimary: boolean; hasBankAccount: boolean; hasPendingRequest: boolean; diff --git a/scripts/smoke-v3.mjs b/scripts/smoke-v3.mjs index 78b935e..948b3c6 100644 --- a/scripts/smoke-v3.mjs +++ b/scripts/smoke-v3.mjs @@ -172,30 +172,12 @@ async function main() { const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId; 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', { token: shopToken, }); if (!(withdrawSummary.availableAmount > 0)) { 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', { method: 'POST', @@ -215,6 +197,14 @@ async function main() { 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`, { method: 'POST', token: admin.accessToken, diff --git a/server/dukang-api/src/integrations/wecom/wecom-handbook.ts b/server/dukang-api/src/integrations/wecom/wecom-handbook.ts index d9d2a18..c37a720 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-handbook.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-handbook.ts @@ -66,7 +66,7 @@ export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [ body: [ '门店账单:核销×60%,T+1 出账;HQ 确认打款。', '合伙人月账独立确认打款。', - '未出账提现受白名单/单日上限等 FIN 护栏。', + '门店可对未出账核销主动提现(受单日上限);申请后锁定明细不进入次日出账,HQ 审后打款并企微提醒。', ].join('\n'), }, { diff --git a/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts index 07a7037..03aa0f6 100644 --- a/server/dukang-api/src/jobs/settlement.scheduler.ts +++ b/server/dukang-api/src/jobs/settlement.scheduler.ts @@ -49,22 +49,13 @@ export class SettlementScheduler { } } - /** FIN-003:工作日 18:05 扫描超时未审门店提现 */ + /** FIN-003:工作日 18:05 扫描超时未审门店提现(企微提醒在 SettlementService 内发送) */ @Cron('5 18 * * 1-5', { timeZone: 'Asia/Shanghai' }) async handleWithdrawOverdueAlert() { this.logger.log('Store withdraw overdue scan start'); try { const summary = await this.settlementService.scanOverdueStoreWithdrawals(); 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) { this.logger.error('Store withdraw overdue scan failed', e instanceof Error ? e.stack : e); this.alert.notify({ @@ -72,7 +63,7 @@ export class SettlementScheduler { category: 'job', title: '门店提现超时扫描失败', 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)}`, }); } } diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 5735df4..ea5b1a6 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -357,9 +357,6 @@ export class AdminStoresService { ...(dto.visibilityWhitelistEnabled !== undefined ? { visibilityWhitelistEnabled: !!dto.visibilityWhitelistEnabled } : {}), - ...(dto.withdrawWhitelistEnabled !== undefined - ? { withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled } - : {}), ...(latitude != null && longitude != null ? { latitude, longitude } : {}), }, }); @@ -519,7 +516,6 @@ export class AdminStoresService { openTime2: openTime2 || null, closeTime2: closeTime2 || null, visibilityWhitelistEnabled: whitelistEnabled, - withdrawWhitelistEnabled: !!dto.withdrawWhitelistEnabled, ...(latitude != null && longitude != null ? { latitude, longitude } : {}), status: 'OPEN', auditStatus: 'APPROVED', diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 6016b0e..6adcf75 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -138,11 +138,6 @@ export class CreateStoreDto { @IsArray() @IsString({ each: true }) visibilityPhones?: string[]; - - /** FIN-001:允许未出账手动提现 */ - @IsOptional() - @IsBoolean() - withdrawWhitelistEnabled?: boolean; } export class UpdateStoreDto { @@ -242,11 +237,6 @@ export class UpdateStoreDto { @IsArray() @IsString({ each: true }) visibilityPhones?: string[]; - - /** FIN-001:允许未出账手动提现 */ - @IsOptional() - @IsBoolean() - withdrawWhitelistEnabled?: boolean; } export class CreateStoreAccountDto { diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index d3736df..bb43b3b 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -14,6 +14,7 @@ import { } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; +import { AlertService } from '../../common/alert/alert.service'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; @@ -72,6 +73,7 @@ export class SettlementService { private readonly analyticsService: AnalyticsService, private readonly partnerCityService: PartnerCityService, private readonly fulfillmentProviderService: FulfillmentProviderService, + private readonly alert: AlertService, ) {} // ─── Store payout (line) ───────────────────────────── @@ -168,11 +170,7 @@ export class SettlementService { async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) { await this.assertShopStoreAccess(storeAccountId, storeId); - const [store, account, available, pending, todayApplied] = await Promise.all([ - this.prisma.store.findUniqueOrThrow({ - where: { id: storeId }, - select: { withdrawWhitelistEnabled: true }, - }), + const [account, available, pending, todayApplied] = await Promise.all([ this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId }, select: { @@ -205,7 +203,6 @@ export class SettlementService { todayAppliedAmount: todayApplied, dailyLimit, remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)), - whitelistEnabled: store.withdrawWhitelistEnabled, isPrimary: account.isPrimary === 1, hasBankAccount, hasPendingRequest: !!pending, @@ -227,7 +224,7 @@ export class SettlementService { const [store, account, available, pending, todayApplied] = await Promise.all([ this.prisma.store.findUniqueOrThrow({ where: { id: storeId }, - select: { withdrawWhitelistEnabled: true }, + select: { id: true, name: true, phone: true, cityName: true }, }), this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId }, @@ -263,7 +260,6 @@ export class SettlementService { : availableAmount; const guard = validateStoreWithdraw({ - whitelistEnabled: store.withdrawWhitelistEnabled, availableAmount, requestAmount, 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); } @@ -433,7 +444,6 @@ export class SettlementService { name: true, cityName: true, phone: true, - withdrawWhitelistEnabled: true, }, }, storeAccount: { @@ -562,6 +572,34 @@ export class SettlementService { /** FIN-003:工作日 18:00 扫描超时未审提现 */ async scanOverdueStoreWithdrawals() { 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; }