feat(settlement): 门店未出账手动提现与总部审核(OPT-010)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,17 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { DEFAULT_XFX_LOGISTICS_PRICING, WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
||||
import { calcLogisticsFeeByBottles, type LogisticsPricingRule } from '@dukang/domain';
|
||||
import {
|
||||
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
|
||||
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||
WINERY_SETTLEMENT_RATE,
|
||||
} from '@dukang/shared-types';
|
||||
import {
|
||||
calcLogisticsFeeByBottles,
|
||||
pickPayoutsForWithdrawAmount,
|
||||
sumUnbilledPayoutAmount,
|
||||
validateStoreWithdraw,
|
||||
type LogisticsPricingRule,
|
||||
} from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
@@ -33,6 +43,28 @@ function round2(n: number) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function getStoreWithdrawDailyLimit(): number {
|
||||
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
|
||||
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
|
||||
}
|
||||
|
||||
/** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */
|
||||
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
||||
const day = appliedAt.getDay(); // 0 Sun … 6 Sat
|
||||
if (day === 0 || day === 6) return false;
|
||||
const deadline = new Date(
|
||||
appliedAt.getFullYear(),
|
||||
appliedAt.getMonth(),
|
||||
appliedAt.getDate(),
|
||||
18,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
return now.getTime() > deadline.getTime();
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(
|
||||
@@ -99,6 +131,440 @@ export class SettlementService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
// ─── Store withdraw (未出账手动提现) ─────────────────
|
||||
|
||||
private async assertShopStoreAccess(storeAccountId: bigint, storeId: bigint) {
|
||||
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
});
|
||||
}
|
||||
|
||||
private async listAvailableUnbilledPayouts(storeId: bigint) {
|
||||
return this.prisma.storePayout.findMany({
|
||||
where: {
|
||||
storeId,
|
||||
status: 'PENDING',
|
||||
storeBillId: null,
|
||||
withdrawItem: null,
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
|
||||
const start = startOfDay(now);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
const agg = await this.prisma.storeWithdrawRequest.aggregate({
|
||||
where: {
|
||||
storeId,
|
||||
status: { in: ['PENDING_REVIEW', 'PAID'] },
|
||||
appliedAt: { gte: start, lt: end },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
return Number(agg._sum.amount ?? 0);
|
||||
}
|
||||
|
||||
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 },
|
||||
}),
|
||||
this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
select: {
|
||||
isPrimary: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
}),
|
||||
this.listAvailableUnbilledPayouts(storeId),
|
||||
this.prisma.storeWithdrawRequest.findFirst({
|
||||
where: { storeId, status: 'PENDING_REVIEW' },
|
||||
select: { id: true, amount: true },
|
||||
}),
|
||||
this.todayWithdrawAppliedAmount(storeId),
|
||||
]);
|
||||
|
||||
const availableAmount = sumUnbilledPayoutAmount(
|
||||
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
||||
);
|
||||
const dailyLimit = getStoreWithdrawDailyLimit();
|
||||
const hasBankAccount = !!(
|
||||
account.bankAccountName?.trim() &&
|
||||
account.bankAccountNo?.trim()
|
||||
);
|
||||
|
||||
return {
|
||||
availableAmount,
|
||||
pendingReviewAmount: pending ? Number(pending.amount) : 0,
|
||||
todayAppliedAmount: todayApplied,
|
||||
dailyLimit,
|
||||
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
|
||||
whitelistEnabled: store.withdrawWhitelistEnabled,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
hasBankAccount,
|
||||
hasPendingRequest: !!pending,
|
||||
bankAccount: {
|
||||
bankAccountName: account.bankAccountName,
|
||||
bankAccountNo: account.bankAccountNo,
|
||||
bankBranch: account.bankBranch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createStoreWithdrawRequest(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
dto?: { amount?: number },
|
||||
) {
|
||||
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||
|
||||
const [store, account, available, pending, todayApplied] = await Promise.all([
|
||||
this.prisma.store.findUniqueOrThrow({
|
||||
where: { id: storeId },
|
||||
select: { withdrawWhitelistEnabled: true },
|
||||
}),
|
||||
this.prisma.storeAccount.findUniqueOrThrow({
|
||||
where: { id: storeAccountId },
|
||||
select: {
|
||||
isPrimary: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
},
|
||||
}),
|
||||
this.listAvailableUnbilledPayouts(storeId),
|
||||
this.prisma.storeWithdrawRequest.findFirst({
|
||||
where: { storeId, status: 'PENDING_REVIEW' },
|
||||
select: { id: true },
|
||||
}),
|
||||
this.todayWithdrawAppliedAmount(storeId),
|
||||
]);
|
||||
|
||||
if (account.isPrimary !== 1) {
|
||||
throw new BadRequestException('仅门店主账号可申请提现');
|
||||
}
|
||||
|
||||
const availableAmount = sumUnbilledPayoutAmount(
|
||||
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
||||
);
|
||||
const dailyLimit = getStoreWithdrawDailyLimit();
|
||||
const hasBankAccount = !!(
|
||||
account.bankAccountName?.trim() &&
|
||||
account.bankAccountNo?.trim()
|
||||
);
|
||||
const requestAmount =
|
||||
dto?.amount != null && Number.isFinite(Number(dto.amount))
|
||||
? round2(Number(dto.amount))
|
||||
: availableAmount;
|
||||
|
||||
const guard = validateStoreWithdraw({
|
||||
whitelistEnabled: store.withdrawWhitelistEnabled,
|
||||
availableAmount,
|
||||
requestAmount,
|
||||
todayApplied,
|
||||
dailyLimit,
|
||||
hasPendingRequest: !!pending,
|
||||
hasBankAccount,
|
||||
});
|
||||
if (!guard.ok) throw new BadRequestException(guard.message);
|
||||
|
||||
const picked = pickPayoutsForWithdrawAmount(
|
||||
available.map((p) => ({ id: p.id, payoutAmount: Number(p.payoutAmount) })),
|
||||
requestAmount,
|
||||
);
|
||||
if (!picked.ok) throw new BadRequestException(picked.message);
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const stillPending = await tx.storeWithdrawRequest.findFirst({
|
||||
where: { storeId, status: 'PENDING_REVIEW' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (stillPending) {
|
||||
throw new BadRequestException('已有待审核提现申请,请等待处理完成');
|
||||
}
|
||||
|
||||
const payoutIds = picked.selected.map((p) => p.id);
|
||||
const locked = await tx.storePayout.findMany({
|
||||
where: {
|
||||
id: { in: payoutIds },
|
||||
storeId,
|
||||
status: 'PENDING',
|
||||
storeBillId: null,
|
||||
withdrawItem: null,
|
||||
},
|
||||
select: { id: true, payoutAmount: true },
|
||||
});
|
||||
if (locked.length !== payoutIds.length) {
|
||||
throw new BadRequestException('可提余额已变化,请刷新后重试');
|
||||
}
|
||||
const amount = round2(locked.reduce((s, p) => s + Number(p.payoutAmount), 0));
|
||||
|
||||
const req = await tx.storeWithdrawRequest.create({
|
||||
data: {
|
||||
withdrawNo: generateBillNo('SW'),
|
||||
storeId,
|
||||
storeAccountId,
|
||||
amount,
|
||||
payoutCount: locked.length,
|
||||
status: 'PENDING_REVIEW',
|
||||
},
|
||||
});
|
||||
await tx.storeWithdrawPayoutItem.createMany({
|
||||
data: locked.map((p) => ({
|
||||
withdrawRequestId: req.id,
|
||||
storePayoutId: p.id,
|
||||
})),
|
||||
});
|
||||
return req;
|
||||
});
|
||||
|
||||
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
|
||||
storeId,
|
||||
eventName: 'store_withdraw_applied',
|
||||
refType: 'STORE_WITHDRAW',
|
||||
refId: created.id,
|
||||
extraJson: {
|
||||
amount: Number(created.amount),
|
||||
payoutCount: created.payoutCount,
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(created);
|
||||
}
|
||||
|
||||
async listShopWithdrawRequests(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
query: { page?: number; pageSize?: number; status?: string },
|
||||
) {
|
||||
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreWithdrawRequestWhereInput = { storeId };
|
||||
if (query.status) {
|
||||
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
|
||||
}
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.storeWithdrawRequest.findMany({
|
||||
where,
|
||||
orderBy: { appliedAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.storeWithdrawRequest.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async listAdminStoreWithdrawals(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.StoreWithdrawRequestWhereInput = {};
|
||||
if (query.status) {
|
||||
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
|
||||
}
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.dateFrom || query.dateTo) {
|
||||
where.appliedAt = {};
|
||||
if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom);
|
||||
if (query.dateTo) {
|
||||
const end = new Date(query.dateTo);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
where.appliedAt.lte = end;
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total, aggregates] = await Promise.all([
|
||||
this.prisma.storeWithdrawRequest.findMany({
|
||||
where,
|
||||
orderBy: [{ appliedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.storeWithdrawRequest.count({ where }),
|
||||
this.prisma.storeWithdrawRequest.aggregate({
|
||||
where,
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const now = new Date();
|
||||
const mapped = items.map((row) => ({
|
||||
...row,
|
||||
overdue:
|
||||
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt, now) : false,
|
||||
}));
|
||||
|
||||
return serializeBigInt({
|
||||
items: mapped,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
summary: {
|
||||
count: aggregates._count,
|
||||
totalAmount: Number(aggregates._sum.amount ?? 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminStoreWithdrawal(id: bigint) {
|
||||
const row = await this.prisma.storeWithdrawRequest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
store: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
cityName: true,
|
||||
phone: true,
|
||||
withdrawWhitelistEnabled: true,
|
||||
},
|
||||
},
|
||||
storeAccount: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
storePayout: {
|
||||
include: {
|
||||
redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('提现申请不存在');
|
||||
return serializeBigInt({
|
||||
...row,
|
||||
overdue:
|
||||
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false,
|
||||
});
|
||||
}
|
||||
|
||||
async approveStoreWithdraw(
|
||||
id: bigint,
|
||||
hqAccountId: bigint,
|
||||
dto?: { paymentRef?: string },
|
||||
) {
|
||||
const row = await this.prisma.storeWithdrawRequest.findUnique({
|
||||
where: { id },
|
||||
include: { items: { select: { storePayoutId: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('提现申请不存在');
|
||||
if (row.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('仅待审核提现可审核通过');
|
||||
}
|
||||
|
||||
const paidAt = new Date();
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const req = await tx.storeWithdrawRequest.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'PAID',
|
||||
reviewedAt: paidAt,
|
||||
reviewedByHqId: hqAccountId,
|
||||
paidAt,
|
||||
paymentRef: dto?.paymentRef?.trim() || null,
|
||||
},
|
||||
});
|
||||
await tx.storePayout.updateMany({
|
||||
where: {
|
||||
id: { in: row.items.map((i) => i.storePayoutId) },
|
||||
status: 'PENDING',
|
||||
},
|
||||
data: { status: 'PAID', paidAt },
|
||||
});
|
||||
return req;
|
||||
});
|
||||
|
||||
this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', {
|
||||
storeId: row.storeId,
|
||||
eventName: 'store_withdraw_paid',
|
||||
refType: 'STORE_WITHDRAW',
|
||||
refId: id,
|
||||
extraJson: {
|
||||
amount: Number(row.amount),
|
||||
paymentRef: dto?.paymentRef,
|
||||
},
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async rejectStoreWithdraw(id: bigint, hqAccountId: bigint, reason: string) {
|
||||
const row = await this.prisma.storeWithdrawRequest.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!row) throw new NotFoundException('提现申请不存在');
|
||||
if (row.status !== 'PENDING_REVIEW') {
|
||||
throw new BadRequestException('仅待审核提现可驳回');
|
||||
}
|
||||
const rejectReason = reason.trim();
|
||||
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const req = await tx.storeWithdrawRequest.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectReason,
|
||||
reviewedAt: new Date(),
|
||||
reviewedByHqId: hqAccountId,
|
||||
},
|
||||
});
|
||||
// 释放 payout 锁定,允许再次提现
|
||||
await tx.storeWithdrawPayoutItem.deleteMany({ where: { withdrawRequestId: id } });
|
||||
return req;
|
||||
});
|
||||
|
||||
return serializeBigInt(updated);
|
||||
}
|
||||
|
||||
async getStoreWithdrawOverdueSummary() {
|
||||
const pending = await this.prisma.storeWithdrawRequest.findMany({
|
||||
where: { status: 'PENDING_REVIEW' },
|
||||
select: { id: true, appliedAt: true, amount: true },
|
||||
});
|
||||
const now = new Date();
|
||||
const overdue = pending.filter((r) => isWithdrawOverdue(r.appliedAt, now));
|
||||
return {
|
||||
pendingCount: pending.length,
|
||||
overdueCount: overdue.length,
|
||||
overdueAmount: round2(overdue.reduce((s, r) => s + Number(r.amount), 0)),
|
||||
pendingAmount: round2(pending.reduce((s, r) => s + Number(r.amount), 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/** FIN-003:工作日 18:00 扫描超时未审提现 */
|
||||
async scanOverdueStoreWithdrawals() {
|
||||
const summary = await this.getStoreWithdrawOverdueSummary();
|
||||
return summary;
|
||||
}
|
||||
|
||||
async listAdminStorePayouts(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -277,6 +743,9 @@ export class SettlementService {
|
||||
where: {
|
||||
storeBillId: null,
|
||||
createdAt: { gte: start, lt: end },
|
||||
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
|
||||
withdrawItem: null,
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: { store: { select: { id: true, settlementRate: true } } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user