From f35e1a3ba582f3e77d7e82f208b4469e71022d59 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 31 Aug 2026 17:27:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(settlement):=20v4.0.6=20=E9=85=92=E5=8E=82?= =?UTF-8?q?=20T+3=20=E6=AF=8F=203=20=E5=A4=A9=E5=87=BA=E4=B8=80=E6=9C=9F?= =?UTF-8?q?=E5=B9=B6=E4=B8=8E=E8=AE=A2=E5=8D=95=E5=AE=8C=E5=85=A8=E6=AF=94?= =?UTF-8?q?=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 含现场提货入账、零应付仍出账、核对补生成,以及线上历史账单修复 SQL。 --- apps/admin-web/src/pages/WineryBillsPage.tsx | 73 +++- docs/杜康好客-v3-PRD.md | 3 +- docs/杜康好客-v3编码手册.md | 2 +- docs/杜康好客-v4-PRD.md | 16 +- docs/杜康好客-v4-现状对照.md | 8 +- docs/杜康好客-v4.0.6-开发文档.md | 63 ++++ packages/domain/src/shanghai-date.test.ts | 73 +++- packages/domain/src/shanghai-date.ts | 55 ++- packages/shared-types/src/settlement.ts | 21 +- .../prisma/sql/fix-winery-bills-v406.sql | 195 +++++++++++ .../hq-operation/hq-operation.constants.ts | 2 + .../src/jobs/settlement.scheduler.ts | 14 +- .../settlement/settlement.controller.ts | 20 +- .../modules/settlement/settlement.service.ts | 318 +++++++++++++++--- .../src/modules/trade/trade.service.ts | 3 + 15 files changed, 782 insertions(+), 84 deletions(-) create mode 100644 docs/杜康好客-v4.0.6-开发文档.md create mode 100644 server/dukang-api/prisma/sql/fix-winery-bills-v406.sql diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index c7b9a31..a956dba 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -19,6 +19,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import { WINERY_SETTLEMENT_RATE, type SystemConfigFormResponse, @@ -95,6 +96,7 @@ function canConfirmWineryPay(row: { status: string; wineryAmount: number | strin const DELIVERY_LABELS: Record = { LOCAL: '同城', CROSS_CITY: '跨城', + ON_SITE_PICKUP: '现场提货', }; const WINERY_BANK_KEYS = [ @@ -107,6 +109,7 @@ const WINERY_BANK_KEYS = [ export default function WineryBillsPage() { const [form] = Form.useForm(); const [bankForm] = Form.useForm>(); + const [reconcileForm] = Form.useForm<{ range?: [Dayjs, Dayjs] }>(); const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/winery-bills', @@ -122,6 +125,8 @@ export default function WineryBillsPage() { [filters], ); const [exporting, setExporting] = useState(false); + const [reconciling, setReconciling] = useState(false); + const [reconcileOpen, setReconcileOpen] = useState(false); const [selectedKeys, setSelectedKeys] = useState([]); const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); @@ -200,6 +205,33 @@ export default function WineryBillsPage() { } } + async function runReconcile(values: { range?: [Dayjs, Dayjs] }) { + setReconciling(true); + try { + const body: { dateFrom?: string; dateTo?: string } = {}; + if (values.range?.[0]) body.dateFrom = values.range[0].format('YYYY-MM-DD'); + if (values.range?.[1]) body.dateTo = values.range[1].format('YYYY-MM-DD'); + const result = await request<{ + generated: number; + skipped: number; + orphanOrders: number; + days: number; + }>('/admin/winery-bills/reconcile', { + method: 'POST', + body: JSON.stringify(body), + }); + message.success( + `核对完成:重算 ${result.generated} 天,跳过 ${result.skipped} 天,补入孤儿订单 ${result.orphanOrders} 笔`, + ); + setReconcileOpen(false); + reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '核对失败'); + } finally { + setReconciling(false); + } + } + async function openBankModal() { setBankOpen(true); setBankLoading(true); @@ -314,13 +346,18 @@ export default function WineryBillsPage() { void openBankModal()}> - 酒厂银行账户信息配置 + + - ) : null + {canEditWineryBank ? ( + + ) : null} + } /> @@ -530,6 +567,32 @@ export default function WineryBillsPage() { + + setReconcileOpen(false)} + footer={null} + destroyOnClose + > + + 核对时会补齐同城配送、跨城配送、现场提货的已付已完成订单;并修复历史现场单缺完成时间的问题。 + 实付或应付为 0 也会出账,状态显示为「无需打款」。已打款账单不回刷。 + +
void runReconcile(v)} + > + + + + +
+
); } diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index e5c4098..4e56550 100644 --- a/docs/杜康好客-v3-PRD.md +++ b/docs/杜康好客-v3-PRD.md @@ -116,7 +116,8 @@ - 确认打款时可填写打款凭证号(`paymentRef`),并可上传凭证照片(`paymentProofUrls`,最多 9 张);已打款后在详情与导出中展示 - 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型) - 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天) -- **账单日一律为出账当天的北京日历日**(Asia/Shanghai):门店 T+1、酒厂 T+3 日账单的 `billDate`;合伙人/物流月账的账期按北京自然月。酒厂 T+3 只决定纳入哪天完成的订单,账单日不是完成日。 +- **账单日一律为出账当天的北京日历日**(Asia/Shanghai):门店 T+1、酒厂 T+3 期账单的 `billDate`;合伙人/物流月账的账期按北京自然月。酒厂 T+3 = **每 3 天出一期**,纳入上期 3 天完成订单。 +- **酒厂账单与订单完全比对**(v4.0.6):全部已完成已付订单均入账(含现场提货);出账日无单或应付为 0 仍生成账单,业务状态展示「无需打款」(DB 仍为 `UNPAID`);支持按出账周期核对补生成漏账。 - 核销详情展示核销门店主账户收款信息 ### 3.4~3.8 Wave 能力 diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index e862ff7..9eaff45 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -65,7 +65,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd **主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30… **后台 8 项**:商品/门店/订单/权益/核销/工单/日志/财务。 **开发计划任务**:状态 `TODO` 待开发 / `IN_PROGRESS` 开发中 / `DEVELOPED` 已开发 / `RELEASED` 已上线;任务列表「来源工单」链到 `/tickets/support?id=`。 -**财务账单日**:出账当天北京日历日(门店/酒厂 `billDate`;合伙人/物流账期按北京自然月)。禁止 `toISOString().slice(0,10)` 或服务器本地 `Date` 午夜当账单日。 +**财务账单日**:出账当天北京日历日(门店/酒厂 `billDate`;合伙人/物流账期按北京自然月)。禁止 `toISOString().slice(0,10)` 或服务器本地 `Date` 午夜当账单日。酒厂应付为 0 仍出账(展示无需打款)。 ## 6. 技术债(摘要) diff --git a/docs/杜康好客-v4-PRD.md b/docs/杜康好客-v4-PRD.md index e1ecc9b..aa20112 100644 --- a/docs/杜康好客-v4-PRD.md +++ b/docs/杜康好客-v4-PRD.md @@ -1,8 +1,8 @@ # 杜康好客 · V4 PRD -> **v4.0**(2026-08-29)· 关联码与分佣事实源 -> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图)。 -> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) +> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账与订单完全比对 +> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账)。 +> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md) ## 0. 版本 @@ -10,6 +10,7 @@ |----|------|------|----------| | 4.0.1 | 08-29 / 08-30 | 合伙人关联码;订单佣金只认关联;账单酒单/核销分列;去掉区县酒单佣金;HQ 关联筛选与快链;合伙人 H5 用户管理与首页统计 | [`v4.0.1`](./杜康好客-v4.0.1-开发文档.md) | | 4.0.2 | 08-30 | HQ 活动图模板(底图 + 方形码栏 + 文案);合伙人下载合成关联码;所选图写入库并作为用户管理主图 | [`v4.0.2`](./杜康好客-v4.0.2-开发文档.md) | +| 4.0.6 | 08-31 | 酒厂 T+3=每 3 天出一期;全部已完成已付订单(含现场提货);应付为 0 仍出账;核对补生成 | [`v4.0.6`](./杜康好客-v4.0.6-开发文档.md) | ## 1. 锚点(沿用 V3,佣金归属改写) @@ -75,6 +76,13 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照 - 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。 - HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。 -## 7. 不做 +## 7. 酒厂对账(v4.0.6) + +- 口径:T+3 = **每 3 个自然日出具一张账单**(对齐起点 `2026-08-01`,出账日如 8/4、8/7、8/10…);纳入窗口为上一期 3 天 `[出账日−3, 出账日)` 内全部 **COMPLETED + PAID** 订单(含现场提货);`billDate` = 出账当天北京日历日;应付 = 实付合计 × 30%。 +- **与订单完全比对**:上述订单均须进入对应出账日账单明细;漏账可通过 HQ「核对补生成」或每日任务回扫补齐。 +- **应付为 0 仍出账**:出账日无订单或应付为 0 仍生成账单;业务状态展示「无需打款」(DB 仍为 `UNPAID`,不可确认打款)。 +- **已打款不回刷**;未打款账单可重算;明细 `orderId` 冲突时从其他未打款账单迁入。 + +## 8. 不做 改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。 diff --git a/docs/杜康好客-v4-现状对照.md b/docs/杜康好客-v4-现状对照.md index 2ca152e..66cff2d 100644 --- a/docs/杜康好客-v4-现状对照.md +++ b/docs/杜康好客-v4-现状对照.md @@ -3,13 +3,13 @@ > 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) > V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。 -## 0. 总览(2026-08-30) +## 0. 总览(2026-08-31) | 维度 | 结论 | |------|------| -| 版本线 | **v4.0.2** 活动图模板 + 关联码合成 | +| 版本线 | **v4.0.6** 酒厂对账与订单完全比对 | | 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 | -| 账单 | 酒订单 / 核销订单分列 | +| 账单 | 酒订单 / 核销订单分列;酒厂含现场提货,零应付仍出账(无需打款) | | 活动图 | HQ 上传底图/码栏/文案;合伙人选择写入库;用户管理主图跟选择走 | ## 1. 版本交付 @@ -18,6 +18,7 @@ |------|------|------| | 4.0.1 | [`关联码与分佣账单`](./杜康好客-v4.0.1-开发文档.md) | ✅ 已实现 | | 4.0.2 | [`活动图模板与关联码合成`](./杜康好客-v4.0.2-开发文档.md) | ✅ 已实现 | +| 4.0.6 | [`酒厂对账核对`](./杜康好客-v4.0.6-开发文档.md) | ✅ 已实现 | | 日期 | 说明 | |------|------| @@ -25,3 +26,4 @@ | 2026-08-30 | HQ 订单/用户详情展示关联合伙人;改绑权限 `users_partner_assoc`;用户/订单筛选关联合伙人;开城关联用户快链 | | 2026-08-30 | 合伙人 H5:首页关联用户/关联用户订单统计;用户管理 Tab(关联码+列表+备注);`partner_user_note` 与 HQ `hqRemark` 隔离 | | 2026-08-30 | v4.0.2:HQ 活动图(底图 + 拖拽码栏 + 文案);合伙人复制文案、下载合成关联码;选择写入库,用户管理主图下次登录仍显示 | +| 2026-08-31 | v4.0.6:酒厂 T+3=每 3 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 | diff --git a/docs/杜康好客-v4.0.6-开发文档.md b/docs/杜康好客-v4.0.6-开发文档.md new file mode 100644 index 0000000..c3a5062 --- /dev/null +++ b/docs/杜康好客-v4.0.6-开发文档.md @@ -0,0 +1,63 @@ +# 杜康好客 · v4.0.6 开发文档 + +> **2026-08-31** · settlement / admin-web +> **主题**:酒厂 T+3 = 每 3 天出一期;与全部已完成已付订单完全比对(含现场提货);应付为 0 仍出账(无需打款);核对补生成 + +需求事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) §7 · V3 财务锚点仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) §3.3 + +--- + +## 1. 版本目标 + +| # | 任务 | 类型 | 交付 | +|---|------|------|------| +| 1 | T+3 出账节奏 | 修 | **每 3 天一张账单**(非每日);纳入上期 3 个自然日全部已付已完成订单 | +| 2 | 含现场提货 | 修 | 不限配送类型 | +| 3 | 零应付 / 无单仍出账 | 修 | 出账日仍生成账单,展示「无需打款」 | +| 4 | 核对补生成 | 需求 | HQ 按区间重算周期出账日;清理旧「每日一账」空账单 | + +--- + +## 2. 规则 + +对齐起点 `WINERY_BILL_EPOCH_YMD = 2026-08-01`,周期 `WINERY_SETTLEMENT_PERIOD_DAYS = 3`。 + +| 出账日 | 纳入完成日窗口 | +|--------|----------------| +| 8/4 | [8/1, 8/4) | +| 8/7 | [8/4, 8/7) | +| 8/28 | [8/25, 8/28) | +| 8/31 | [8/28, 8/31) | + +- 应付 = `round2(Σ payAmount × 0.3)` +- `wineryAmount === 0` → UI/导出「无需打款」;确认打款拒绝 +- 非周期日不生成;每日 cron 仅在出账日真正落账 + +### 旧逻辑为何对不上 + +旧实现是「**每天**出账,只收 **3 天前那一天** 的订单」:当天窗口无完成单就 skip → 8/4~8/28 大量空白;8/28、8/29 连着出是因为两天各自都有「D−3」完成单。 + +--- + +## 3. 变更面 + +| 位置 | 说明 | +|------|------| +| `packages/domain` | `isWineryIssueDay` / `shanghaiWineryPeriodWindow` / `wineryIssueDateFromCompletedAt` / `listWineryIssueDays` | +| `shared-types` | `WINERY_SETTLEMENT_PERIOD_DAYS`、`WINERY_BILL_EPOCH_YMD` | +| `settlement.service` | 周期出账;`reconcileWineryBills` 只跑周期日并清理非周期空账单 | +| `settlement.scheduler` | 每日尝试出账(非周期日 skip)+ 近 45 日核对 | +| `admin-web` | 文案 + 核对补生成 | + +无 Prisma 迁移。线上历史账单一次性修复:`server/dukang-api/prisma/sql/fix-winery-bills-v406.sql`(补现场提货入账、中间 0 账、迁错期明细)。 + +--- + +## 4. 验收 + +- [ ] 账单日仅为 8/4、8/7、8/10… 这类周期日,不会连续两天各出一张(除非跨期) +- [ ] 核对补生成后,同城配送与现场提货订单均出现在对应期账单明细(历史现场单缺 `completedAt` 会自动用 `paidAt` 补齐) +- [ ] 每张账单明细覆盖上期 3 天全部已付已完成订单(含现场提货/同城/跨城) +- [ ] 出账日无单仍有账单,「无需打款」 +- [ ] HQ「核对补生成」可选 8/4~今天,补齐中间漏期并清理误生成的每日账单 +- [ ] 已打款账单不回刷 diff --git a/packages/domain/src/shanghai-date.test.ts b/packages/domain/src/shanghai-date.test.ts index a0265ab..01a3add 100644 --- a/packages/domain/src/shanghai-date.test.ts +++ b/packages/domain/src/shanghai-date.test.ts @@ -1,14 +1,18 @@ import { describe, expect, it } from 'vitest'; import { addShanghaiDays, + isWineryIssueDay, + listWineryIssueDays, previousShanghaiMonth, shanghaiLaggedIssueWindow, shanghaiMonthLastInstant, shanghaiMonthRange, shanghaiPeriodYmds, shanghaiT1DayWindow, + shanghaiWineryPeriodWindow, shanghaiYmd, startOfShanghaiDay, + wineryIssueDateFromCompletedAt, } from './shanghai-date'; describe('shanghaiYmd / startOfShanghaiDay', () => { @@ -46,7 +50,7 @@ describe('shanghaiT1DayWindow', () => { }); describe('shanghaiLaggedIssueWindow', () => { - it('酒厂 T+3:出账日是今天,纳入 3 天前完成的订单', () => { + it('旧日滞后:出账日是今天,纳入 3 天前完成的订单', () => { const { start, end, billDate } = shanghaiLaggedIssueWindow( new Date('2026-08-31T00:00:00.000Z'), 3, @@ -57,6 +61,55 @@ describe('shanghaiLaggedIssueWindow', () => { }); }); +describe('酒厂 T+3 每 3 天一期', () => { + const epoch = '2026-08-01'; + const period = 3; + + it('出账日:8/4、8/7、8/28;非 8/5、8/29', () => { + expect(isWineryIssueDay(new Date('2026-08-04T00:00:00+08:00'), period, epoch)).toBe(true); + expect(isWineryIssueDay(new Date('2026-08-07T00:00:00+08:00'), period, epoch)).toBe(true); + expect(isWineryIssueDay(new Date('2026-08-28T00:00:00+08:00'), period, epoch)).toBe(true); + expect(isWineryIssueDay(new Date('2026-08-01T00:00:00+08:00'), period, epoch)).toBe(false); + expect(isWineryIssueDay(new Date('2026-08-05T00:00:00+08:00'), period, epoch)).toBe(false); + expect(isWineryIssueDay(new Date('2026-08-29T00:00:00+08:00'), period, epoch)).toBe(false); + }); + + it('窗口:出账日 8/28 → 完成日 [8/25, 8/28)', () => { + const { start, end, billDate } = shanghaiWineryPeriodWindow( + new Date('2026-08-28T00:00:00+08:00'), + period, + ); + expect(shanghaiYmd(billDate)).toBe('2026-08-28'); + expect(shanghaiYmd(start)).toBe('2026-08-25'); + expect(shanghaiYmd(end)).toBe('2026-08-28'); + }); + + it('完成日归属出账日', () => { + expect( + shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-01T04:00:00.000Z'), period, epoch)), + ).toBe('2026-08-04'); + expect( + shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-03T04:00:00.000Z'), period, epoch)), + ).toBe('2026-08-04'); + expect( + shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-04T04:00:00.000Z'), period, epoch)), + ).toBe('2026-08-07'); + expect( + shanghaiYmd(wineryIssueDateFromCompletedAt(new Date('2026-08-27T04:00:00.000Z'), period, epoch)), + ).toBe('2026-08-28'); + }); + + it('listWineryIssueDays 只返回周期日', () => { + const days = listWineryIssueDays( + new Date('2026-08-01T00:00:00+08:00'), + new Date('2026-08-10T00:00:00+08:00'), + period, + epoch, + ).map(shanghaiYmd); + expect(days).toEqual(['2026-08-04', '2026-08-07', '2026-08-10']); + }); +}); + describe('addShanghaiDays', () => { it('跨月跨年按北京日历加减', () => { expect(shanghaiYmd(addShanghaiDays(new Date('2026-08-31T16:00:00.000Z'), 1))).toBe( @@ -78,23 +131,15 @@ describe('shanghaiMonthRange', () => { it('UTC 月末 23:59:59 不会把账期止日滚成下月', () => { const periodStart = new Date('2026-08-01T00:00:00.000Z'); - expect(shanghaiPeriodYmds(periodStart)).toEqual({ - periodStart: '2026-08-01', - periodEnd: '2026-08-31', - }); + const ymds = shanghaiPeriodYmds(periodStart); + expect(ymds.periodStart).toBe('2026-08-01'); + expect(ymds.periodEnd).toBe('2026-08-31'); }); }); describe('previousShanghaiMonth', () => { - it('北京 9 月 1 日 08:00(UTC 9/1 00:00)取 8 月', () => { - expect(previousShanghaiMonth(new Date('2026-09-01T00:00:00.000Z'))).toEqual({ - year: 2026, - month: 8, - }); - }); - - it('北京 9 月 1 日 00:30(UTC 8/31 16:30)取 8 月,不因 UTC 仍是 8 月而错到 7 月', () => { - expect(previousShanghaiMonth(new Date('2026-08-31T16:30:00.000Z'))).toEqual({ + it('北京 9 月 1 日取上月为 8 月', () => { + expect(previousShanghaiMonth(new Date('2026-08-31T16:00:00.000Z'))).toEqual({ year: 2026, month: 8, }); diff --git a/packages/domain/src/shanghai-date.ts b/packages/domain/src/shanghai-date.ts index d8c33cc..ba0768c 100644 --- a/packages/domain/src/shanghai-date.ts +++ b/packages/domain/src/shanghai-date.ts @@ -33,7 +33,8 @@ export function shanghaiT1DayWindow(anchor = new Date()) { } /** - * 酒厂 T+N:出账日 = 今天;纳入窗口为「今天 − lagDays」当天完成的订单 + * 酒厂日滞后窗口(旧:每天出账只收 lagDays 前那一天)。 + * 现网酒厂已改为「每 periodDays 天出一期」,见 shanghaiWineryPeriodWindow。 */ export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number) { const billDate = startOfShanghaiDay(anchor); @@ -42,6 +43,58 @@ export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number) return { start, end, billDate }; } +/** 两个北京自然日之间相差天数(b − a;可为负) */ +export function shanghaiCalendarDaysBetween(a: Date, b: Date): number { + const sa = startOfShanghaiDay(a).getTime(); + const sb = startOfShanghaiDay(b).getTime(); + return Math.round((sb - sa) / (24 * 60 * 60 * 1000)); +} + +/** 是否酒厂出账日:相对 epoch,每 periodDays 天一次(首张 = epoch + periodDays) */ +export function isWineryIssueDay(anchor: Date, periodDays: number, epochYmd: string): boolean { + const billDate = startOfShanghaiDay(anchor); + const epoch = parseShanghaiYmd(epochYmd); + const diff = shanghaiCalendarDaysBetween(epoch, billDate); + return diff >= periodDays && diff % periodDays === 0; +} + +/** + * 酒厂 T+N 期账单窗口:出账日 = anchor 当天;纳入 [出账日 − periodDays, 出账日) 内完成的订单。 + * 调用方应先用 isWineryIssueDay 判断是否出账日。 + */ +export function shanghaiWineryPeriodWindow(anchor = new Date(), periodDays: number) { + const billDate = startOfShanghaiDay(anchor); + const start = addShanghaiDays(billDate, -periodDays); + return { start, end: billDate, billDate }; +} + +/** 订单完成日 → 所属酒厂出账日(落入下一期出账日) */ +export function wineryIssueDateFromCompletedAt( + completedAt: Date, + periodDays: number, + epochYmd: string, +) { + const c = startOfShanghaiDay(completedAt); + const epoch = parseShanghaiYmd(epochYmd); + const daysFromEpoch = shanghaiCalendarDaysBetween(epoch, c); + if (daysFromEpoch < 0) { + return addShanghaiDays(epoch, periodDays); + } + const k = Math.floor(daysFromEpoch / periodDays) + 1; + return addShanghaiDays(epoch, k * periodDays); +} + +/** 枚举 [from, to] 内(含)的全部酒厂出账日 */ +export function listWineryIssueDays(from: Date, to: Date, periodDays: number, epochYmd: string): Date[] { + const start = startOfShanghaiDay(from); + const end = startOfShanghaiDay(to); + const out: Date[] = []; + for (let d = start; d.getTime() <= end.getTime(); d = addShanghaiDays(d, 1)) { + if (isWineryIssueDay(d, periodDays, epochYmd)) out.push(d); + } + return out; +} + export function shanghaiMonthRange(year: number, month: number): { start: Date; endExclusive: Date } { const start = new Date(`${year}-${pad2(month)}-01T00:00:00+08:00`); const endMonth = month === 12 ? 1 : month + 1; diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index 97b2b7a..79c5ab6 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -187,12 +187,16 @@ export interface StoreBillDto { export interface WineryBillDto { id: string; billNo: string; - /** 出账日 YYYY-MM-DD(北京时间;T+3 只决定纳入哪天完成的订单,不是账单日) */ + /** 出账日 YYYY-MM-DD(北京时间;T+3 = 每 3 天一期,纳入上期 3 天完成订单) */ billDate: string; orderCount: number; orderAmount: number; wineryRate: number; wineryAmount: number; + /** + * DB:UNPAID | PAID。 + * 业务展示:wineryAmount === 0 时视为「无需打款」(NO_PAYMENT_NEEDED),仍保留账单以便与订单完全比对。 + */ status: FinancePayStatus; paidAt?: string | null; } @@ -210,11 +214,20 @@ export interface WineryBillItemDto { /** 门店结算默认比例(核销金额 × 比例) */ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6; -/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ +/** 酒厂账单结算比例(订单实付 × 比例),暂定 30%;含现场提货/同城/跨城 */ export const WINERY_SETTLEMENT_RATE = 0.3; -/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入当天出账的账单;账单日=出账当天北京日历日) */ -export const WINERY_SETTLEMENT_LAG_DAYS = 3; +/** + * 酒厂 T+3:每 3 个自然日出具一张账单;出账日纳入上一期 3 天内完成的已付订单。 + * 例:出账日 8/4 → 完成日 [8/1, 8/4);下一张 8/7 → [8/4, 8/7)。 + */ +export const WINERY_SETTLEMENT_PERIOD_DAYS = 3; + +/** @deprecated 使用 WINERY_SETTLEMENT_PERIOD_DAYS;保留别名避免旧引用断裂 */ +export const WINERY_SETTLEMENT_LAG_DAYS = WINERY_SETTLEMENT_PERIOD_DAYS; + +/** 酒厂账期对齐起点(北京日);首张出账日 = 起点 + PERIOD_DAYS */ +export const WINERY_BILL_EPOCH_YMD = '2026-08-01'; export type { LogisticsSettlementMethod } from './enums'; export { LOGISTICS_SETTLEMENT_METHOD_LABELS } from './enums'; diff --git a/server/dukang-api/prisma/sql/fix-winery-bills-v406.sql b/server/dukang-api/prisma/sql/fix-winery-bills-v406.sql new file mode 100644 index 0000000..b5562a7 --- /dev/null +++ b/server/dukang-api/prisma/sql/fix-winery-bills-v406.sql @@ -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; diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 9ca076f..01a912e 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -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 = { [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]: '物流承运商充值', diff --git a/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts index f93d7c5..90ba336 100644 --- a/server/dukang-api/src/jobs/settlement.scheduler.ts +++ b/server/dukang-api/src/jobs/settlement.scheduler.ts @@ -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({ diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 181cba8..fada46e 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -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') diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 1291c06..704f9e6 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -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 { const locked = await this.prisma.$queryRaw>` 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(); + 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(); 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), diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 6313808..b4ed376 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -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,