feat(settlement): 统一门店账单列表合并 T+1 与手动提现

总部财务以门店账单为唯一入口;新增 store-settlements API,去掉独立提现审菜单并更新企微引导文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:04:45 +08:00
parent 26472f8678
commit 54fca99208
8 changed files with 561 additions and 71 deletions
@@ -224,6 +224,25 @@ export class AdminStorePayoutController {
}
}
@Controller('admin/store-settlements')
@UseGuards(HqAuthGuard)
export class AdminStoreSettlementController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminStoreSettlements({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
kind: query.kind,
status: query.status,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
});
}
}
@Controller('admin/store-bills')
@UseGuards(HqAuthGuard)
export class AdminStoreBillController {
@@ -9,6 +9,7 @@ import {
AdminPartnerBillController,
AdminStoreBillController,
AdminStorePayoutController,
AdminStoreSettlementController,
AdminStoreWithdrawController,
AdminWineryBillController,
PartnerMeController,
@@ -26,6 +27,7 @@ import {
ShopWithdrawController,
AdminStoreWithdrawController,
AdminStorePayoutController,
AdminStoreSettlementController,
AdminStoreBillController,
AdminPartnerBillController,
AdminWineryBillController,
@@ -339,7 +339,7 @@ export class SettlementService {
`单号:${created.withdrawNo}`,
`金额:¥${Number(created.amount).toFixed(2)}`,
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
'请尽快在 HQ「财务 → 门店提现」处理。',
'请尽快在 HQ「财务 → 门店账单(手动提现」处理。',
].join('\n'),
dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
dedupeTtlSec: 3600,
@@ -594,7 +594,7 @@ export class SettlementService {
detail: [
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
...lines,
'请尽快在 HQ「财务 → 门店提现」处理。',
'请尽快在 HQ「财务 → 门店账单(手动提现」处理。',
].join('\n'),
dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
dedupeTtlSec: 6 * 3600,
@@ -857,6 +857,152 @@ export class SettlementService {
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
}
/**
* 总部「门店账单」统一列表:T+1 StoreBill + 手动提现 StoreWithdrawRequest
* 中小数据量内存合并后分页。
*/
async listAdminStoreSettlements(query: {
page?: number;
pageSize?: number;
kind?: string;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const kind = query.kind === 'T1_BILL' || query.kind === 'WITHDRAW' ? query.kind : undefined;
const status = query.status?.trim() || undefined;
const includeBills =
!kind || kind === 'T1_BILL'
? !status || status === 'UNPAID' || status === 'PAID'
: false;
const includeWithdraws =
!kind || kind === 'WITHDRAW'
? !status ||
status === 'PENDING_REVIEW' ||
status === 'REJECTED' ||
status === 'PAID'
: false;
const billWhere = includeBills
? this.buildStoreBillWhere({
status: status === 'UNPAID' || status === 'PAID' ? status : undefined,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
})
: null;
const withdrawWhere: Prisma.StoreWithdrawRequestWhereInput | null = includeWithdraws
? (() => {
const where: Prisma.StoreWithdrawRequestWhereInput = {};
if (status === 'PENDING_REVIEW' || status === 'REJECTED' || status === 'PAID') {
where.status = status;
}
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;
}
}
return where;
})()
: null;
const bills = billWhere
? await this.prisma.storeBill.findMany({
where: billWhere,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
})
: [];
const withdraws = withdrawWhere
? await this.prisma.storeWithdrawRequest.findMany({
where: withdrawWhere,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
})
: [];
const now = new Date();
type UnifiedRow = {
kind: 'T1_BILL' | 'WITHDRAW';
id: bigint;
billNo: string;
storeId: bigint;
amount: number;
status: string;
date: Date;
overdue?: boolean;
redeemCount?: number;
redeemAmount?: number;
settlementRate?: number;
payoutCount?: number;
store?: { id: bigint; name: string; cityName: string; phone: string | null };
};
const rows: UnifiedRow[] = [
...bills.map((b) => ({
kind: 'T1_BILL' as const,
id: b.id,
billNo: b.billNo,
storeId: b.storeId,
amount: Number(b.payoutAmount),
status: b.status,
date: b.billDate,
redeemCount: b.redeemCount,
redeemAmount: Number(b.redeemAmount),
settlementRate: Number(b.settlementRate),
store: b.store,
})),
...withdraws.map((w) => ({
kind: 'WITHDRAW' as const,
id: w.id,
billNo: w.withdrawNo,
storeId: w.storeId,
amount: Number(w.amount),
status: w.status,
date: w.appliedAt,
overdue: w.status === 'PENDING_REVIEW' ? isWithdrawOverdue(w.appliedAt, now) : false,
payoutCount: w.payoutCount,
store: w.store,
})),
];
rows.sort((a, b) => {
const dt = b.date.getTime() - a.date.getTime();
if (dt !== 0) return dt;
return Number(b.id - a.id);
});
const total = rows.length;
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0);
const payoutAmount = rows.reduce((s, r) => s + r.amount, 0);
return serializeBigInt({
items: slice,
total,
page,
pageSize,
summary: {
count: total,
redeemAmount,
payoutAmount,
totalAmount: payoutAmount,
},
});
}
async listAdminStoreBills(query: {
page?: number;
pageSize?: number;