feat(settlement): v4.0.6 酒厂 T+3 每 3 天出一期并与订单完全比对

含现场提货入账、零应付仍出账、核对补生成,以及线上历史账单修复 SQL。
This commit is contained in:
2026-08-31 17:27:38 +08:00
parent ac94f5f5de
commit f35e1a3ba5
15 changed files with 782 additions and 84 deletions
@@ -0,0 +1,195 @@
-- =============================================================================
-- 酒厂账单一次性修复(v4.0.6)
-- 库:生产 dukang_prod(也可在已同步的本地 dukang_haoke 先演练)
--
-- 规则(与代码一致):
-- 起点 WINERY_BILL_EPOCH_YMD = 2026-08-01
-- 周期 WINERY_SETTLEMENT_PERIOD_DAYS = 3
-- 出账日 8/4、8/7、8/10 … ;纳入窗口 [出账日-3, 出账日)
-- 订单:status=COMPLETED AND pay_status=PAID
-- delivery_type IN ('LOCAL','CROSS_CITY','ON_SITE_PICKUP')
-- 账期归属:completed_at(缺则 paid_at)的北京日历日
-- 应付 = ROUND(实付合计 × 0.3, 2);出账日无单仍留账单(无需打款)
-- 已打款账单不改明细
-- 起点之前已入账的订单(如 7/28 同城挂在 7/31)保持原账单,不迁到 8/4
--
-- 本脚本会:
-- 1) 补现场提货等缺 completed_at 的已完成单
-- 2) 按周期补齐出账日账单(含中间应付为 0 的空账)
-- 3) 把未入账 / 挂错期(如 8/29)的同城、跨城、现场提货订单迁入对应出账日
-- 4) 按明细重算未打款表头
-- 5) 删除未打款、无明细、且非周期日的空账单(7/29、8/1、8/29 等)
--
-- 建议:先部署含「每 3 天出一期」的 API,再跑本脚本。
-- 若必须先修数据:跑完后尽快发版,避免旧「每日只收 D-3 当天」任务回刷 8/28。
--
-- 用法(生产):
-- mysql -u... -p dukang_prod < fix-winery-bills-v406.sql
-- =============================================================================
START TRANSACTION;
-- 1) 现场提货等:支付即完成但未写 completed_at
UPDATE user_order
SET completed_at = paid_at
WHERE status = 'COMPLETED'
AND pay_status = 'PAID'
AND completed_at IS NULL
AND paid_at IS NOT NULL;
-- 2) 补齐周期出账日(8/4 起至「今天北京日」能落到的最后一张)
INSERT INTO winery_bill (
bill_no, bill_date, order_count, order_amount, winery_rate, winery_amount, status, created_at
)
WITH RECURSIVE issue_days AS (
SELECT DATE('2026-08-04') AS issue_ymd
UNION ALL
SELECT DATE_ADD(issue_ymd, INTERVAL 3 DAY)
FROM issue_days
WHERE DATE_ADD(issue_ymd, INTERVAL 3 DAY) <= DATE(DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR))
)
SELECT
CONCAT('WBFIX', DATE_FORMAT(d.issue_ymd, '%Y%m%d')) AS bill_no,
TIMESTAMP(DATE_ADD(d.issue_ymd, INTERVAL -8 HOUR)) AS bill_date,
0,
0.00,
0.3000,
0.00,
'UNPAID',
TIMESTAMP(DATE_ADD(d.issue_ymd, INTERVAL -8 HOUR))
FROM issue_days d
WHERE NOT EXISTS (
SELECT 1
FROM winery_bill b
WHERE b.bill_date = TIMESTAMP(DATE_ADD(d.issue_ymd, INTERVAL -8 HOUR))
);
-- 3) 起点日及之后:从未打款账单上拿掉挂错期的明细(已打款账单因 status 过滤不会动)
DELETE i
FROM winery_bill_item i
INNER JOIN winery_bill b ON b.id = i.winery_bill_id
INNER JOIN user_order o ON o.id = i.order_id
INNER JOIN winery_bill target
ON target.bill_date = TIMESTAMP(DATE_ADD(
DATE_ADD(
DATE('2026-08-01'),
INTERVAL (FLOOR(DATEDIFF(
DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')),
DATE('2026-08-01')
) / 3) + 1) * 3 DAY
),
INTERVAL -8 HOUR
))
WHERE b.status = 'UNPAID'
AND b.id <> target.id
AND o.status = 'COMPLETED'
AND o.pay_status = 'PAID'
AND o.delivery_type IN ('LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP')
AND COALESCE(o.completed_at, o.paid_at) IS NOT NULL
AND DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')) >= '2026-08-01';
-- 4) 把未入账的同城/跨城/现场提货订单写入对应周期账单
INSERT INTO winery_bill_item (
winery_bill_id, order_id, order_no, delivery_type, pay_amount, winery_amount, paid_at, created_at
)
SELECT
target.id,
o.id,
o.order_no,
o.delivery_type,
o.pay_amount,
ROUND(o.pay_amount * 0.3, 2),
COALESCE(o.paid_at, o.completed_at),
UTC_TIMESTAMP(3)
FROM user_order o
INNER JOIN winery_bill target
ON target.bill_date = TIMESTAMP(DATE_ADD(
DATE_ADD(
DATE('2026-08-01'),
INTERVAL (FLOOR(DATEDIFF(
DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')),
DATE('2026-08-01')
) / 3) + 1) * 3 DAY
),
INTERVAL -8 HOUR
))
LEFT JOIN winery_bill_item existing ON existing.order_id = o.id
WHERE o.status = 'COMPLETED'
AND o.pay_status = 'PAID'
AND o.delivery_type IN ('LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP')
AND COALESCE(o.completed_at, o.paid_at) IS NOT NULL
AND DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')) >= '2026-08-01'
AND existing.id IS NULL;
-- 5) 按明细重算所有未打款表头(应付为 0 的周期账单也保留)
UPDATE winery_bill b
LEFT JOIN (
SELECT
winery_bill_id,
COUNT(*) AS order_count,
ROUND(SUM(pay_amount), 2) AS order_amount
FROM winery_bill_item
GROUP BY winery_bill_id
) x ON x.winery_bill_id = b.id
SET
b.order_count = IFNULL(x.order_count, 0),
b.order_amount = IFNULL(x.order_amount, 0.00),
b.winery_rate = 0.3000,
b.winery_amount = ROUND(IFNULL(x.order_amount, 0) * 0.3, 2)
WHERE b.status = 'UNPAID';
-- 6) 清掉误生成的非周期日空账单(有明细的如 7/31 保留;8/1 虽 MOD=0 但未满 3 天,仍删)
DELETE b
FROM winery_bill b
LEFT JOIN winery_bill_item i ON i.winery_bill_id = b.id
WHERE b.status = 'UNPAID'
AND i.id IS NULL
AND NOT (
DATEDIFF(DATE(CONVERT_TZ(b.bill_date, '+00:00', '+08:00')), DATE('2026-08-01')) >= 3
AND MOD(DATEDIFF(DATE(CONVERT_TZ(b.bill_date, '+00:00', '+08:00')), DATE('2026-08-01')), 3) = 0
);
COMMIT;
-- -----------------------------------------------------------------------------
-- 核对(不改数据)
-- -----------------------------------------------------------------------------
SELECT '=== 账单列表 ===' AS s;
SELECT
DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')) AS bill_ymd,
bill_no,
order_count,
order_amount,
winery_amount,
status
FROM winery_bill
ORDER BY bill_date;
SELECT '=== 明细按配送类型 ===' AS s;
SELECT delivery_type, COUNT(*) AS cnt, ROUND(SUM(pay_amount), 2) AS pay_sum
FROM winery_bill_item
GROUP BY delivery_type;
SELECT '=== 应入账但未入账(应为 0 行)===' AS s;
SELECT o.id, o.order_no, o.delivery_type, o.pay_amount,
DATE(CONVERT_TZ(COALESCE(o.completed_at, o.paid_at), '+00:00', '+08:00')) AS attr_ymd
FROM user_order o
LEFT JOIN winery_bill_item i ON i.order_id = o.id
WHERE o.status = 'COMPLETED'
AND o.pay_status = 'PAID'
AND o.delivery_type IN ('LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP')
AND i.id IS NULL;
SELECT '=== 非周期日仍在的账单(仅允许起点前已入账的,如 7/31)===' AS s;
SELECT
DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')) AS bill_ymd,
bill_no,
order_count,
order_amount,
status
FROM winery_bill
WHERE NOT (
DATEDIFF(DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')), DATE('2026-08-01')) >= 3
AND MOD(DATEDIFF(DATE(CONVERT_TZ(bill_date, '+00:00', '+08:00')), DATE('2026-08-01')), 3) = 0
)
ORDER BY bill_date;
@@ -77,6 +77,7 @@ export const HqOperationAction = {
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
WINERY_BILL_RECONCILE: 'WINERY_BILL_RECONCILE',
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
@@ -210,6 +211,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
[HqOperationAction.WINERY_BILL_RECONCILE]: '酒厂对账单核对补生成',
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
@@ -1,12 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { shanghaiYmd } from '@dukang/domain';
import { addShanghaiDays, shanghaiYmd, startOfShanghaiDay } from '@dukang/domain';
import { SettlementService } from '../modules/settlement/settlement.service';
import { AlertService } from '../common/alert/alert.service';
/**
* 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单(T+33 天已完成订单)+ 门店日账单(昨日核销,出账日=今天)
* - 每日 08:00:酒厂 T+3 期账单(每 3 天出一期,纳入上期 3 天已完成已付订单)+ 近 45 日出账周期核对补漏 + 门店日账单
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/
@@ -23,8 +23,14 @@ export class SettlementScheduler {
async handleDailyBills() {
this.logger.log('Daily settlement bills job start');
try {
const winery = await this.settlementService.generateWineryBillForDay();
this.logger.log(`Winery bill: ${JSON.stringify(winery)}`);
const today = startOfShanghaiDay(new Date());
const reconcile = await this.settlementService.reconcileWineryBills({
dateFrom: shanghaiYmd(addShanghaiDays(today, -45)),
dateTo: shanghaiYmd(today),
});
this.logger.log(
`Winery reconcile: days=${reconcile.days} generated=${reconcile.generated} orphans=${reconcile.orphanOrders} cleaned=${reconcile.cleanedNonIssueBills}`,
);
} catch (e) {
this.logger.error('Winery bill job failed', e instanceof Error ? e.stack : e);
this.alert.notify({
@@ -1,5 +1,6 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsOptional, IsString } from 'class-validator';
import { parseShanghaiYmd } from '@dukang/domain';
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import { SettlementService } from './settlement.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
@@ -464,8 +465,23 @@ export class AdminWineryBillController {
}
@Post('generate')
generate() {
return this.settlementService.generateWineryBillForDay();
generate(@Body() body?: { date?: string }) {
const anchor = body?.date?.trim() ? parseShanghaiYmd(body.date.trim()) : new Date();
if (body?.date?.trim() && Number.isNaN(anchor.getTime())) {
throw new BadRequestException('日期格式无效,请使用 YYYY-MM-DD');
}
return this.settlementService.generateWineryBillForDay(anchor);
}
@Post('reconcile')
@HqOperation({
action: HqOperationAction.WINERY_BILL_RECONCILE,
refType: 'WINERY_BILL',
batch: true,
includeBody: true,
})
reconcile(@Body() body?: { dateFrom?: string; dateTo?: string }) {
return this.settlementService.reconcileWineryBills(body);
}
@Post('batch-confirm')
@@ -5,27 +5,31 @@ import {
DEFAULT_XFX_LOGISTICS_PRICING,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
PAYMENT_PROOF_IMAGE_MAX_COUNT,
WINERY_SETTLEMENT_LAG_DAYS,
WINERY_BILL_EPOCH_YMD,
WINERY_SETTLEMENT_PERIOD_DAYS,
WINERY_SETTLEMENT_RATE,
} from '@dukang/shared-types';
import {
addShanghaiDays,
calcLogisticsFeeByBottles,
calcRedeemSettleAmount,
isWineryIssueDay,
listWineryIssueDays,
parseShanghaiYmd,
pickPayoutsForWithdrawAmount,
previousShanghaiMonth,
resolveSettlementRate,
shanghaiLaggedIssueWindow,
shanghaiMonthLastInstant,
shanghaiMonthRange,
shanghaiPeriodYmds,
shanghaiT1DayWindow,
shanghaiWineryPeriodWindow,
shanghaiYearMonth,
shanghaiYmd,
startOfShanghaiDay,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
wineryIssueDateFromCompletedAt,
type LogisticsPricingRule,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -236,19 +240,25 @@ export class SettlementService implements OnModuleInit {
}
}
/** 酒厂账单日改为出账当天(不再存 T+3 完成日) */
/**
* 一次性:旧「billDate = 完成日」改为出账当天。
* 已是 T+3 周期出账日的账单禁止再按 createdAt 挪日,否则补生成的历史期账单会被改成今天。
*/
private async realignWineryBillIssueDates(): Promise<number> {
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
SELECT GET_LOCK('winery_bill_issue_date_align', 5) AS acquired
`;
if (!Number(locked[0]?.acquired)) return 0;
try {
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
const epochYmd = WINERY_BILL_EPOCH_YMD;
const bills = await this.prisma.wineryBill.findMany({
select: { id: true, billDate: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
let shifted = 0;
for (const b of bills) {
if (isWineryIssueDay(b.billDate, periodDays, epochYmd)) continue;
const issue = startOfShanghaiDay(b.createdAt);
if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue;
const clash = await this.prisma.wineryBill.findUnique({ where: { billDate: issue } });
@@ -2201,10 +2211,58 @@ export class SettlementService implements OnModuleInit {
// ─── Winery bills ────────────────────────────────────
async generateWineryBillForDay(anchor = new Date()) {
const { start, end, billDate } = shanghaiLaggedIssueWindow(anchor, WINERY_SETTLEMENT_LAG_DAYS);
const rate = WINERY_SETTLEMENT_RATE;
/** 酒厂账单纳入的配送类型:同城 / 跨城 / 现场提货 */
private static readonly WINERY_DELIVERY_TYPES = ['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'] as const;
/**
* 历史现场提货单支付即 COMPLETED 但未写 completedAt,补齐为 paidAt,否则核对补生成捞不到。
*/
private async backfillMissingCompletedAt() {
await this.prisma.$executeRaw`
UPDATE user_order
SET completed_at = paid_at
WHERE status = 'COMPLETED'
AND pay_status = 'PAID'
AND completed_at IS NULL
AND paid_at IS NOT NULL
`;
}
/** 期窗口内已付已完成订单(含同城/跨城/现场提货;completedAt 优先,缺省用 paidAt */
private wineryEligibleOrderWhere(start: Date, end: Date): Prisma.OrderWhereInput {
return {
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: { in: [...SettlementService.WINERY_DELIVERY_TYPES] },
OR: [
{ completedAt: { gte: start, lt: end } },
{ completedAt: null, paidAt: { gte: start, lt: end } },
],
};
}
/**
* 生成/重算某一出账日的酒厂对账单(T+3 = 每 3 天出一期)。
* - 仅在周期出账日生成:相对 epoch 每 3 天一张
* - 纳入上一期 3 个自然日内完成的同城/跨城/现场提货已付订单(含实付/应付为 0)
* - 出账日无订单也出账(无需打款),保证每隔三天有账单
* - 已打款账单不回刷;未打款账单可重算;冲突明细从其他未打款账单迁入
*/
async generateWineryBillForDay(anchor = new Date(), opts?: { notify?: boolean }) {
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
const epochYmd = WINERY_BILL_EPOCH_YMD;
const billDate = startOfShanghaiDay(anchor);
const issueYmd = shanghaiYmd(billDate);
const today = startOfShanghaiDay(new Date());
if (billDate.getTime() > today.getTime()) {
return { billDate: issueYmd, skipped: true, reason: '出账日未到' };
}
if (!isWineryIssueDay(billDate, periodDays, epochYmd)) {
return { billDate: issueYmd, skipped: true, reason: '非出账周期日(T+3 每 3 天出账)' };
}
const { start, end } = shanghaiWineryPeriodWindow(billDate, periodDays);
const rate = WINERY_SETTLEMENT_RATE;
const existing =
(await this.prisma.wineryBill.findUnique({ where: { billDate } })) ??
@@ -2215,21 +2273,31 @@ export class SettlementService implements OnModuleInit {
return { billDate: issueYmd, skipped: true, reason: '已打款' };
}
// T+3:纳入「今天 − lagDays」当天完成的同城/跨城订单;billDate = 出账日(今天)
const orders = await this.prisma.order.findMany({
where: {
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
},
// 手工单日出账时也补齐缺 completedAt 的现场单
await this.backfillMissingCompletedAt();
// 期窗口 [出账日−3, 出账日) 内:同城 / 跨城 / 现场提货
const candidates = await this.prisma.order.findMany({
where: this.wineryEligibleOrderWhere(start, end),
include: { city: { select: { name: true } } },
orderBy: { completedAt: 'asc' },
orderBy: [{ completedAt: 'asc' }, { paidAt: 'asc' }],
});
if (orders.length === 0 && !existing) {
return { billDate: issueYmd, skipped: true, reason: '无订单' };
// 已打款账单上的明细不可再迁;其余订单一律纳入本账
const candidateIds = candidates.map((o) => o.id);
const paidLockedIds = new Set<string>();
if (candidateIds.length) {
const locked = await this.prisma.wineryBillItem.findMany({
where: {
orderId: { in: candidateIds },
wineryBill: { status: 'PAID' },
...(existing ? { wineryBillId: { not: existing.id } } : {}),
},
select: { orderId: true },
});
for (const row of locked) paidLockedIds.add(row.orderId.toString());
}
const orders = candidates.filter((o) => !paidLockedIds.has(o.id.toString()));
const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0));
const wineryAmount = round2(orderAmount * rate);
@@ -2262,7 +2330,22 @@ export class SettlementService implements OnModuleInit {
await tx.wineryBillItem.deleteMany({ where: { wineryBillId: header.id } });
}
// 从未打款的其他账单迁出冲突明细,避免 orderId 唯一约束失败导致整日对不上
const affectedBillIds = new Set<bigint>();
if (orders.length > 0) {
const conflicts = await tx.wineryBillItem.findMany({
where: {
orderId: { in: orders.map((o) => o.id) },
wineryBillId: { not: header.id },
wineryBill: { status: 'UNPAID' },
},
select: { id: true, wineryBillId: true },
});
for (const c of conflicts) affectedBillIds.add(c.wineryBillId);
if (conflicts.length) {
await tx.wineryBillItem.deleteMany({ where: { id: { in: conflicts.map((c) => c.id) } } });
}
await tx.wineryBillItem.createMany({
data: orders.map((o) => ({
wineryBillId: header.id,
@@ -2271,50 +2354,189 @@ export class SettlementService implements OnModuleInit {
deliveryType: o.deliveryType,
payAmount: o.payAmount,
wineryAmount: round2(Number(o.payAmount) * rate),
// 明细仍保留支付时间;账期窗口按 completedAt 归属
// 明细时间:支付时间优先;账期归属用 completedAt(现场提货已与支付同时写入)
paidAt: o.paidAt ?? o.completedAt!,
})),
});
}
for (const otherId of affectedBillIds) {
await this.recalcWineryBillHeader(tx, otherId, rate);
}
return header;
});
const period = issueYmd;
const wineryBank = await loadWineryBankConfig(this.prisma);
const bankParts = wecomBankParts(wineryBank);
const cityNames = joinLimited(
orders.map((o) => o.city?.name || ''),
'',
);
const partnerNames = await this.partnerDashLabels(orders.map((o) => o.partnerAccountIdAtPay));
void this.wecomPush.dispatchEvent(
'finance.winery_bill',
{
period,
billCount: '1',
totalAmount: wineryAmount.toFixed(2),
cityNames,
partnerNames,
orderCount: String(orders.length),
orderAmount: orderAmount.toFixed(2),
rate: formatPercent(rate),
amount: wineryAmount.toFixed(2),
payee: bankParts.payee,
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(wineryBank),
},
{ handlePath: '/finance/winery-bills' },
);
if (opts?.notify !== false && wineryAmount > 0) {
const wineryBank = await loadWineryBankConfig(this.prisma);
const bankParts = wecomBankParts(wineryBank);
const cityNames = joinLimited(
orders.map((o) => o.city?.name || ''),
'城',
);
const partnerNames = await this.partnerDashLabels(orders.map((o) => o.partnerAccountIdAtPay));
void this.wecomPush.dispatchEvent(
'finance.winery_bill',
{
period,
billCount: '1',
totalAmount: wineryAmount.toFixed(2),
cityNames,
partnerNames,
orderCount: String(orders.length),
orderAmount: orderAmount.toFixed(2),
rate: formatPercent(rate),
amount: wineryAmount.toFixed(2),
payee: bankParts.payee,
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(wineryBank),
},
{ handlePath: '/finance/winery-bills' },
);
}
return serializeBigInt({
billDate: period,
skipped: false,
bill,
orderCount: orders.length,
orderAmount,
wineryAmount,
lockedOnPaid: paidLockedIds.size,
});
}
/** 按明细重算酒厂账单表头(应付为 0 保留账单,展示为无需打款) */
private async recalcWineryBillHeader(
tx: Prisma.TransactionClient,
wineryBillId: bigint,
rate = WINERY_SETTLEMENT_RATE,
) {
const items = await tx.wineryBillItem.findMany({ where: { wineryBillId } });
const orderAmount = round2(items.reduce((s, i) => s + Number(i.payAmount), 0));
const wineryAmount = round2(orderAmount * rate);
await tx.wineryBill.update({
where: { id: wineryBillId },
data: {
orderCount: items.length,
orderAmount,
wineryRate: rate,
wineryAmount,
},
});
}
/**
* 按出账日区间核对补生成酒厂账单:只处理 T+3 周期出账日,捞回未入账的同城/跨城/现场提货订单,并清理误生成的非周期日空账单。
*/
async reconcileWineryBills(body?: { dateFrom?: string; dateTo?: string }) {
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
const epochYmd = WINERY_BILL_EPOCH_YMD;
const today = startOfShanghaiDay(new Date());
const to = body?.dateTo ? parseShanghaiYmd(body.dateTo) : today;
const from = body?.dateFrom
? parseShanghaiYmd(body.dateFrom)
: addShanghaiDays(today, -60);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('日期格式无效,请使用 YYYY-MM-DD');
}
if (from.getTime() > to.getTime()) {
throw new BadRequestException('开始日期不能晚于结束日期');
}
// 先补历史现场提货等缺 completedAt 的已完成单,再按期入账
await this.backfillMissingCompletedAt();
const endIssue = to.getTime() > today.getTime() ? today : to;
const startIssue = from.getTime() > endIssue.getTime() ? endIssue : from;
const issueYmids = new Set(
listWineryIssueDays(startIssue, endIssue, periodDays, epochYmd).map(shanghaiYmd),
);
// 捞仍未入账的孤儿订单(同城/跨城/现场提货),按其完成日归属到对应出账日
const orphanLookbackStart = addShanghaiDays(startIssue, -periodDays - 90);
const orphanLookbackEnd = addShanghaiDays(today, 1);
const windowOrders = await this.prisma.order.findMany({
where: this.wineryEligibleOrderWhere(orphanLookbackStart, orphanLookbackEnd),
select: { id: true, completedAt: true, paidAt: true },
});
const billedSet = new Set(
windowOrders.length === 0
? []
: (
await this.prisma.wineryBillItem.findMany({
where: { orderId: { in: windowOrders.map((o) => o.id) } },
select: { orderId: true },
})
).map((r) => r.orderId.toString()),
);
let orphanCount = 0;
for (const o of windowOrders) {
const attributedAt = o.completedAt ?? o.paidAt;
if (!attributedAt || billedSet.has(o.id.toString())) continue;
const issue = wineryIssueDateFromCompletedAt(attributedAt, periodDays, epochYmd);
if (issue.getTime() > today.getTime()) continue;
issueYmids.add(shanghaiYmd(issue));
orphanCount += 1;
}
const sorted = [...issueYmids].sort();
const results: Array<{
billDate: string;
skipped: boolean;
reason?: string;
orderCount?: number;
wineryAmount?: number;
}> = [];
for (const ymd of sorted) {
const r = await this.generateWineryBillForDay(parseShanghaiYmd(ymd), { notify: false });
results.push({
billDate: String(r.billDate),
skipped: Boolean(r.skipped),
reason: 'reason' in r ? String(r.reason ?? '') : undefined,
orderCount: 'orderCount' in r ? Number(r.orderCount ?? 0) : undefined,
wineryAmount: 'wineryAmount' in r ? Number(r.wineryAmount ?? 0) : undefined,
});
}
// 清理旧「每日出账」留下的非周期日出账单(明细已迁走后为空)
let cleaned = 0;
const strayBills = await this.prisma.wineryBill.findMany({
where: {
status: 'UNPAID',
billDate: { gte: startIssue, lte: endIssue },
},
include: { _count: { select: { items: true } } },
});
for (const b of strayBills) {
if (isWineryIssueDay(b.billDate, periodDays, epochYmd)) continue;
if (b._count.items > 0) {
await this.prisma.$transaction(async (tx) => {
await this.recalcWineryBillHeader(tx, b.id);
});
continue;
}
await this.prisma.wineryBill.delete({ where: { id: b.id } });
cleaned += 1;
}
return {
dateFrom: shanghaiYmd(startIssue),
dateTo: shanghaiYmd(endIssue),
days: results.length,
generated: results.filter((r) => !r.skipped).length,
skipped: results.filter((r) => r.skipped).length,
orphanOrders: orphanCount,
cleanedNonIssueBills: cleaned,
results,
};
}
async listAdminWineryBills(query: {
page?: number;
pageSize?: number;
@@ -2469,7 +2691,13 @@ export class SettlementService implements OnModuleInit {
csvEscape(b.billNo),
shanghaiYmd(b.billDate),
csvEscape(item.orderNo),
item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType,
item.deliveryType === 'LOCAL'
? '同城'
: item.deliveryType === 'CROSS_CITY'
? '跨城'
: item.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: item.deliveryType,
Number(item.payAmount),
Number(b.wineryRate),
Number(item.wineryAmount),
@@ -448,6 +448,7 @@ export class TradeService {
status: toStatus,
payStatus: 'PAID',
paidAt: now,
...(toStatus === 'COMPLETED' ? { completedAt: now } : {}),
payExternalNo: externalNo,
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
@@ -636,6 +637,7 @@ export class TradeService {
status: toStatus,
payStatus: 'PAID',
paidAt: now,
...(toStatus === 'COMPLETED' ? { completedAt: now } : {}),
payExternalNo: params.transactionId,
partnerAccountIdAtPay: paySnapshot.partnerAccountId,
orderCommissionRateAtPay: paySnapshot.orderCommissionRate,
@@ -2651,6 +2653,7 @@ export class TradeService {
status: toStatus,
payStatus: 'PAID',
paidAt: now,
...(toStatus === 'COMPLETED' ? { completedAt: now } : {}),
payExternalNo: externalNo,
partnerAccountIdAtPay: order.partnerAccountIdAtPay,
orderCommissionRateAtPay: order.orderCommissionRateAtPay,