fix(settlement): 门店 T+1 出账日改为窗口中的今天

HQ 门店账单列名改为出账日;billDate 记今日(昨日 00:00–今日 00:00 右端),启动时把旧数据从核销自然日对齐过来。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 14:12:35 +08:00
parent da19c39965
commit 3b669f7e38
6 changed files with 65 additions and 18 deletions
+5 -5
View File
@@ -297,8 +297,8 @@ export default function StoreBillsPage() {
},
{
title: (
<Tooltip title="T+1 为账单日(对应昨日核销自然日窗口,不是该账单里最后一笔核销时间);手动提现为申请时间">
<Tooltip title="T+1 为出账日(昨日 00:00–今日 00:00 窗口中的今天,不是该账单里最后一笔核销时间);手动提现为申请时间">
</Tooltip>
),
dataIndex: 'date',
@@ -395,7 +395,7 @@ export default function StoreBillsPage() {
<>
T+1 沿
= T+1 =
T+1
T+1 00:00 00:00
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<div>
<Typography.Text type="danger">
@@ -476,7 +476,7 @@ export default function StoreBillsPage() {
}))}
/>
</Form.Item>
<Form.Item name="range" label="账日">
<Form.Item name="range" label="账日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
@@ -550,7 +550,7 @@ export default function StoreBillsPage() {
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账日">
<Descriptions.Item label="账日">
{String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="应付">
+1
View File
@@ -112,6 +112,7 @@
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
- 核销详情展示核销门店主账户收款信息
### 3.4~3.8 Wave 能力
+1
View File
@@ -146,6 +146,7 @@ export interface StoreBillDto {
id: string;
billNo: string;
storeId: string;
/** 出账日 YYYY-MM-DDT+1 窗口「昨日 00:00–今日 00:00」中的今天) */
billDate: string;
redeemCount: number;
redeemAmount: number;
@@ -91,12 +91,12 @@ async function main() {
await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } });
await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } });
// ─── 1. 门店账单(昨日未打款 + 前日已打款)───
// ─── 1. 门店账单(今日出账未打款 + 昨日出账已打款)───
const storeBillUnpaid = await prisma.storeBill.create({
data: {
billNo: billNo('SB', `${yesterday.getTime()}U`),
storeId: store.id,
billDate: yesterday,
billDate: today,
redeemCount: 5,
redeemAmount: 1500,
settlementRate: 0.6,
@@ -108,7 +108,7 @@ async function main() {
data: {
billNo: billNo('SB', `${dayBefore.getTime()}P`),
storeId: store.id,
billDate: dayBefore,
billDate: yesterday,
redeemCount: 3,
redeemAmount: 800,
settlementRate: 0.6,
@@ -5,7 +5,7 @@ import { AlertService } from '../common/alert/alert.service';
/**
* 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销)
* - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销,出账日=今天
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/
@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
@@ -55,11 +55,16 @@ function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
/** 核销窗口:昨日 00:00 ≤ t < 今日 00:00;出账日 = 今日 00:00(窗口右端) */
function dayWindow(anchor = new Date()) {
const end = startOfDay(anchor);
const start = new Date(end);
start.setDate(start.getDate() - 1);
return { start, end, billDate: start };
return { start, end, billDate: end };
}
function shanghaiYmd(d: Date): string {
return d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
}
function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) {
@@ -131,7 +136,9 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
}
@Injectable()
export class SettlementService {
export class SettlementService implements OnModuleInit {
private readonly logger = new Logger(SettlementService.name);
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
@@ -141,6 +148,37 @@ export class SettlementService {
private readonly wecomPush: WecomMessagePushService,
) {}
async onModuleInit() {
try {
const shifted = await this.realignStoreBillIssueDates();
if (shifted > 0) this.logger.log(`Store bill dates aligned to issue day: ${shifted}`);
} catch (e) {
this.logger.warn(`Store bill date align skipped: ${e instanceof Error ? e.message : e}`);
}
}
/**
* 旧数据 billDate=核销自然日(窗口左端);现改为出账日(窗口右端=今天)。
* 按 created_at 日历日对比,只把仍早一天的行 +1;从晚到早更新避免 (storeId,billDate) 冲突。
*/
private async realignStoreBillIssueDates(): Promise<number> {
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
SELECT GET_LOCK('store_bill_issue_date_align', 5) AS acquired
`;
if (!Number(locked[0]?.acquired)) return 0;
try {
const shifted = await this.prisma.$executeRaw`
UPDATE store_bill
SET bill_date = DATE_ADD(bill_date, INTERVAL 1 DAY)
WHERE DATE(bill_date) < DATE(created_at)
ORDER BY bill_date DESC
`;
return Number(shifted);
} finally {
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('store_bill_issue_date_align')`;
}
}
private notifyPartnerBillDigest(
period: string,
rows: Array<{
@@ -975,7 +1013,7 @@ export class SettlementService {
// ─── Store bills (daily header) ──────────────────────
/** 生成指定自然日窗口的门店对账单(默认昨日) */
/** 生成昨日核销窗口的门店对账单billDate = 出账日(今日) */
async generateStoreBillsForDay(anchor = new Date()) {
const { start, end, billDate } = dayWindow(anchor);
const payouts = await this.prisma.storePayout.findMany({
@@ -1100,7 +1138,7 @@ export class SettlementService {
});
}
const period = billDate.toISOString().slice(0, 10);
const period = shanghaiYmd(billDate);
if (createdBills.length) {
const banks = await loadStorePrimaryBanksMap(
this.prisma,
@@ -1265,6 +1303,7 @@ export class SettlementService {
return serializeBigInt({
items: slice.map((r) => ({
...r,
date: r.kind === 'T1_BILL' ? shanghaiYmd(r.date) : r.date,
bankAccount:
r.kind === 'T1_BILL'
? bankMap.get(String(r.storeId)) ?? null
@@ -1319,7 +1358,13 @@ export class SettlementService {
totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
return serializeBigInt({
items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })),
total,
page,
pageSize,
summary,
});
}
async getAdminStoreBill(id: bigint) {
@@ -1335,7 +1380,7 @@ export class SettlementService {
});
if (!bill) throw new NotFoundException('门店对账单不存在');
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
return serializeBigInt({ ...bill, storeAccount });
return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), storeAccount });
}
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
@@ -1393,7 +1438,7 @@ export class SettlementService {
);
const header = [
'账单号',
'账日',
'账日',
'门店',
'城市',
'核销笔数',
@@ -1411,7 +1456,7 @@ export class SettlementService {
const bank = bankMap.get(String(b.storeId));
return [
csvEscape(b.billNo),
b.billDate.toISOString().slice(0, 10),
shanghaiYmd(b.billDate),
csvEscape(b.store.name),
csvEscape(b.store.cityName ?? ''),
b.redeemCount,