feat(settlement): v4.0.6 酒厂 T+3 每 3 天出一期并与订单完全比对
含现场提货入账、零应付仍出账、核对补生成,以及线上历史账单修复 SQL。
This commit is contained in:
@@ -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+3:3 天前已完成订单)+ 门店日账单(昨日核销,出账日=今天)
|
||||
* - 每日 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,
|
||||
|
||||
Reference in New Issue
Block a user