This commit is contained in:
@@ -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<string, string> = {
|
||||
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<Record<string, string>>();
|
||||
const [reconcileForm] = Form.useForm<{ range?: [Dayjs, Dayjs] }>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/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<React.Key[]>([]);
|
||||
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() {
|
||||
<AdminListHeader
|
||||
title="酒厂对账单"
|
||||
settings={settingsButton}
|
||||
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
|
||||
description={`T+3:每 3 天出一期(相对 2026-08-01),纳入上期 3 天同城/跨城/现场提货已付已完成订单(实付 × ${ratePct}%);应付为 0 仍出账「无需打款」`}
|
||||
actions={
|
||||
canEditWineryBank ? (
|
||||
<Button type="default" onClick={() => void openBankModal()}>
|
||||
酒厂银行账户信息配置
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => setReconcileOpen(true)}>
|
||||
核对补生成
|
||||
</Button>
|
||||
) : null
|
||||
{canEditWineryBank ? (
|
||||
<Button type="default" onClick={() => void openBankModal()}>
|
||||
酒厂银行账户信息配置
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -530,6 +567,32 @@ export default function WineryBillsPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="核对补生成酒厂账单"
|
||||
open={reconcileOpen}
|
||||
onCancel={() => setReconcileOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
核对时会补齐同城配送、跨城配送、现场提货的已付已完成订单;并修复历史现场单缺完成时间的问题。
|
||||
实付或应付为 0 也会出账,状态显示为「无需打款」。已打款账单不回刷。
|
||||
</Typography.Paragraph>
|
||||
<Form
|
||||
form={reconcileForm}
|
||||
layout="vertical"
|
||||
initialValues={{ range: [dayjs().subtract(60, 'day'), dayjs()] }}
|
||||
onFinish={(v) => void runReconcile(v)}
|
||||
>
|
||||
<Form.Item name="range" label="出账日区间(可留空则默认近 60 天)">
|
||||
<DatePicker.RangePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={reconciling}>
|
||||
开始核对
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 能力
|
||||
|
||||
+1
-1
@@ -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. 技术债(摘要)
|
||||
|
||||
|
||||
+12
-4
@@ -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 端/门店端活动图;预生成每人缓存图。
|
||||
|
||||
@@ -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 天出一期(非每日);含现场提货;零应付仍出账;核对补生成 |
|
||||
|
||||
@@ -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~今天,补齐中间漏期并清理误生成的每日账单
|
||||
- [ ] 已打款账单不回刷
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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