From e26206b93caa96b98e60e122d8dac049df580dda Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 31 Aug 2026 14:22:49 +0800 Subject: [PATCH] =?UTF-8?q?v4.0.3=E7=89=88=E6=9C=AC=20=E6=80=BB=E9=83=A8?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=EF=BC=8C=E5=BC=80=E5=8F=91=E8=AE=A1=E5=88=92?= =?UTF-8?q?-=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8-=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E6=94=AF=E6=8C=81=E9=93=BE=E6=8E=A5=20?= =?UTF-8?q?=E6=80=BB=E9=83=A8=E7=AB=AF-=E5=BC=80=E5=8F=91=E8=AE=A1?= =?UTF-8?q?=E5=88=92-=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8-=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=BB=BB=E5=8A=A1-=E5=A2=9E=E5=8A=A0=E2=80=9C?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E4=B8=AD=E2=80=9D=E7=8A=B6=E6=80=81=20?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E6=97=A5=E6=9C=9F=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/admin.css | 4 + apps/admin-web/src/pages/DevPlanTasksPage.tsx | 18 +- apps/admin-web/src/pages/PartnerBillsPage.tsx | 2 +- .../src/pages/SupportTicketsPage.tsx | 13 +- apps/admin-web/src/pages/WineryBillsPage.tsx | 7 +- apps/mini-user/src/pages/home/index.tsx | 2 +- .../src/pages/product-detail/index.tsx | 44 +++ apps/mini-user/src/styles/product-detail.css | 85 +++++- docs/杜康好客-v3-PRD.md | 3 +- docs/杜康好客-v3编码手册.md | 4 +- packages/domain/src/dev-plan.ts | 2 +- packages/domain/src/index.ts | 1 + packages/domain/src/shanghai-date.test.ts | 102 +++++++ packages/domain/src/shanghai-date.ts | 76 +++++ packages/shared-types/src/dev-plan.ts | 6 +- packages/shared-types/src/settlement.ts | 3 +- .../migrate-dev-plan-task-in-progress.sql | 3 + server/dukang-api/prisma/schema.prisma | 1 + .../wecom/wecom-bot-ai.service.ts | 2 +- .../wecom/wecom-bot-capability.service.ts | 11 +- .../src/jobs/settlement.scheduler.ts | 5 +- .../src/modules/dev-plan/dev-plan.service.ts | 4 +- .../modules/settlement/settlement.service.ts | 263 ++++++++++-------- 23 files changed, 522 insertions(+), 139 deletions(-) create mode 100644 packages/domain/src/shanghai-date.test.ts create mode 100644 packages/domain/src/shanghai-date.ts create mode 100644 server/dukang-api/prisma/migrate-dev-plan-task-in-progress.sql diff --git a/apps/admin-web/src/admin.css b/apps/admin-web/src/admin.css index bcd21d8..9cabcf6 100644 --- a/apps/admin-web/src/admin.css +++ b/apps/admin-web/src/admin.css @@ -220,6 +220,10 @@ body.admin-col-resizing * { cursor: pointer; } +a.admin-primary-link { + color: inherit; +} + .admin-primary-link:hover { color: #1677ff; } diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx index c3d23de..40fc1c3 100644 --- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Button, Form, @@ -46,6 +47,7 @@ const STATUS_OPTIONS = (Object.keys(DEV_PLAN_TASK_STATUS_LABELS) as DevPlanTaskS const STATUS_COLOR: Record = { TODO: 'default', + IN_PROGRESS: 'orange', DEVELOPED: 'blue', RELEASED: 'green', }; @@ -289,7 +291,19 @@ export default function DevPlanTasksPage() { '—' ), }, - { title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' }, + { + title: '来源工单', + dataIndex: 'supportTicketNo', + width: 140, + render: (v, row) => + row.supportTicketId && v ? ( + + {v} + + ) : ( + v || '—' + ), + }, { title: '关联版本', dataIndex: 'versions', @@ -377,7 +391,7 @@ export default function DevPlanTasksPage() { + diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index 7c4464e..aca95bc 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -282,7 +282,7 @@ export default function PartnerBillsPage() { title: '账期', width: 200, render: (_, r) => - `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`, + `${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`, }, { title: '酒单佣金', diff --git a/apps/admin-web/src/pages/SupportTicketsPage.tsx b/apps/admin-web/src/pages/SupportTicketsPage.tsx index a94774b..0248790 100644 --- a/apps/admin-web/src/pages/SupportTicketsPage.tsx +++ b/apps/admin-web/src/pages/SupportTicketsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Button, Card, @@ -27,6 +28,7 @@ import { } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import { + DEV_PLAN_TASK_STATUS_LABELS, DEV_PLAN_TASK_TYPE_LABELS, SUPPORT_TICKET_STATUS_LABELS, SUPPORT_TICKET_TYPE_LABELS, @@ -89,6 +91,8 @@ const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTask type TicketRow = SupportTicketDto & { linkedTasks?: SupportTicketLinkedTaskDto[] }; export default function SupportTicketsPage() { + const [searchParams] = useSearchParams(); + const deepLinkId = searchParams.get('id')?.trim() || searchParams.get('ticketId')?.trim() || ''; const [profile, setProfile] = useState(null); const [filters, setFilters] = useState>({}); const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -162,6 +166,11 @@ export default function SupportTicketsPage() { setDrawerOpen(true); } + useEffect(() => { + if (!deepLinkId) return; + void openDetail(deepLinkId).catch(() => message.error('工单不存在或已删除')); + }, [deepLinkId]); + async function submitCreate() { const values = await createForm.validateFields(); setCreating(true); @@ -914,7 +923,9 @@ export default function SupportTicketsPage() { > {t.content} - {t.status} + + {DEV_PLAN_TASK_STATUS_LABELS[t.status] ?? t.status} + ))} diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index c5dec89..c7b9a31 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -13,6 +13,7 @@ import { Statistic, Table, Tag, + Tooltip, Typography, message, } from 'antd'; @@ -249,7 +250,11 @@ export default function WineryBillsPage() { ), }, { - title: '账单日', + title: ( + + 账单日 + + ), dataIndex: 'billDate', width: 110, render: (v) => String(v || '').slice(0, 10), diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index 093dde6..2c88454 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -83,7 +83,7 @@ function formatBenefitCorner(p: Product): string { } export default function HomePage() { - const [showSplash, setShowSplash] = useState(() => !hasHomeSplashPlayed()); + const [showSplash, setShowSplash] = useState(() => false); // !hasHomeSplashPlayed() const [activeAroma, setActiveAroma] = useState('QINGXIANG'); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(true); diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx index 95aa5f7..d403c1a 100644 --- a/apps/mini-user/src/pages/product-detail/index.tsx +++ b/apps/mini-user/src/pages/product-detail/index.tsx @@ -35,8 +35,15 @@ import { toWeappShareTimeline, } from '../../lib/wechat-share'; import iconHome from '../../assets/tabbar/home.png'; +import iconStoreBenefit from '../../assets/icons/store-benefit-y.png'; import { usePageView } from '../../lib/usePageView'; +/** 封面斜角权益额:SKU benefitAmount ?? 商品 benefitDisplay ?? 售价 */ +function formatBenefitCorner(amount: number): string { + if (!Number.isFinite(amount) || amount <= 0) return ''; + return String(Math.round(amount)); +} + type Product = ProductImageSource & { id: string; name: string; @@ -157,6 +164,14 @@ export default function ProductDetailPage() { }, [product, specEnabled, skus, selected, attrs]); const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0); + const benefitCorner = formatBenefitCorner( + Number( + activeSku?.benefitAmount ?? + product?.benefitDisplay ?? + product?.benefitAmount ?? + displayPrice, + ), + ); const fulfillment = activeSku ? { allowOnlinePurchase: activeSku.allowOnlinePurchase, @@ -271,6 +286,20 @@ export default function ProductDetailPage() { + {benefitCorner ? ( + + + + + + {benefitCorner} + + + ) : null} @@ -288,6 +317,21 @@ export default function ProductDetailPage() { {activeSku.specText} ) : null} + {allowOnline || allowOnSite ? ( + + {allowOnline ? ( + + 同城订单24小时内送达 + + ) : null} + {allowOnSite ? ( + + 支持现场取货 + + ) : null} + + ) : null} + {specEnabled ? ( {attrs.map((attr) => ( diff --git a/apps/mini-user/src/styles/product-detail.css b/apps/mini-user/src/styles/product-detail.css index 67e8a78..3e94e6d 100644 --- a/apps/mini-user/src/styles/product-detail.css +++ b/apps/mini-user/src/styles/product-detail.css @@ -8,9 +8,64 @@ } .product-detail-hero { + position: relative; + overflow: hidden; background: var(--color-card); } +.product-detail-benefit-ribbon-clip { + position: absolute; + top: 0; + left: 0; + width: 192px; + height: 192px; + overflow: hidden; + pointer-events: none; + z-index: 2; +} + +.product-detail-benefit-ribbon { + position: absolute; + top: 40px; + left: -52px; + width: 256px; + height: 48px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + overflow: hidden; + background: #8b1a20; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28); + transform: rotate(-45deg); +} + +.product-detail-benefit-ribbon-dk { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 1px; + display: flex; + align-items: center; + justify-content: center; +} + +.product-detail-benefit-ribbon-dk-icon { + width: 28px; + height: 28px; + display: block; +} + +.product-detail-benefit-ribbon-num { + flex-shrink: 0; + color: #dcb46f; + font-size: 28px; + font-weight: 700; + line-height: 48px; + letter-spacing: 0.02em; +} + .detail-carousel-wrap { position: relative; width: 100%; @@ -129,7 +184,35 @@ font-size: 14px; line-height: 20px; color: var(--color-on-surface-variant); - margin-bottom: 24px; + margin-bottom: 8px; +} + +.product-detail-fulfill-tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 8px 0 16px; +} + +.product-detail-fulfill-tag { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 4px; + font-size: 12px; + line-height: 18px; + font-weight: 500; +} + +.product-detail-fulfill-tag--local { + background: #f5d56a; + color: #1a1a1a; +} + +.product-detail-fulfill-tag--pickup { + background: #f8e4e4; + color: #a61d24; } .product-detail-promo { diff --git a/docs/杜康好客-v3-PRD.md b/docs/杜康好客-v3-PRD.md index 12e122f..e5c4098 100644 --- a/docs/杜康好客-v3-PRD.md +++ b/docs/杜康好客-v3-PRD.md @@ -116,6 +116,7 @@ - 确认打款时可填写打款凭证号(`paymentRef`),并可上传凭证照片(`paymentProofUrls`,最多 9 张);已打款后在详情与导出中展示 - 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型) - 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天) +- **账单日一律为出账当天的北京日历日**(Asia/Shanghai):门店 T+1、酒厂 T+3 日账单的 `billDate`;合伙人/物流月账的账期按北京自然月。酒厂 T+3 只决定纳入哪天完成的订单,账单日不是完成日。 - 核销详情展示核销门店主账户收款信息 ### 3.4~3.8 Wave 能力 @@ -131,7 +132,7 @@ | § | 主题 | 文档 | |---|------|------| | 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 | -| 3.10 | 开发计划/任务/版本/技术支持联动;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 | +| 3.10 | 开发计划/任务/版本/技术支持联动;任务状态含开发中;来源工单可点进技术支持;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 | | 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 | | 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 | diff --git a/docs/杜康好客-v3编码手册.md b/docs/杜康好客-v3编码手册.md index 9da39d3..e862ff7 100644 --- a/docs/杜康好客-v3编码手册.md +++ b/docs/杜康好客-v3编码手册.md @@ -63,7 +63,9 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd ## 5. 验收用例(必过) **主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30… -**后台 8 项**:商品/门店/订单/权益/核销/工单/日志/财务。 +**后台 8 项**:商品/门店/订单/权益/核销/工单/日志/财务。 +**开发计划任务**:状态 `TODO` 待开发 / `IN_PROGRESS` 开发中 / `DEVELOPED` 已开发 / `RELEASED` 已上线;任务列表「来源工单」链到 `/tickets/support?id=`。 +**财务账单日**:出账当天北京日历日(门店/酒厂 `billDate`;合伙人/物流账期按北京自然月)。禁止 `toISOString().slice(0,10)` 或服务器本地 `Date` 午夜当账单日。 ## 6. 技术债(摘要) diff --git a/packages/domain/src/dev-plan.ts b/packages/domain/src/dev-plan.ts index 4b338ac..4ff5c88 100644 --- a/packages/domain/src/dev-plan.ts +++ b/packages/domain/src/dev-plan.ts @@ -1,4 +1,4 @@ -export type DevPlanTaskStatus = 'TODO' | 'DEVELOPED' | 'RELEASED'; +export type DevPlanTaskStatus = 'TODO' | 'IN_PROGRESS' | 'DEVELOPED' | 'RELEASED'; export type DevPlanVersionStatus = 'PENDING' | 'IN_PROGRESS' | 'TESTING' | 'RELEASED'; export function computeDurationMinutes( diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index d1623c3..8d37bac 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -409,3 +409,4 @@ export * from './city-partner'; export * from './dev-plan'; export * from './support-ticket'; export * from './phone'; +export * from './shanghai-date'; diff --git a/packages/domain/src/shanghai-date.test.ts b/packages/domain/src/shanghai-date.test.ts new file mode 100644 index 0000000..a0265ab --- /dev/null +++ b/packages/domain/src/shanghai-date.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { + addShanghaiDays, + previousShanghaiMonth, + shanghaiLaggedIssueWindow, + shanghaiMonthLastInstant, + shanghaiMonthRange, + shanghaiPeriodYmds, + shanghaiT1DayWindow, + shanghaiYmd, + startOfShanghaiDay, +} from './shanghai-date'; + +describe('shanghaiYmd / startOfShanghaiDay', () => { + it('UTC 16:00 已是北京次日 00:00,账单日取北京日历日', () => { + const utc = new Date('2026-08-30T16:00:00.000Z'); + expect(shanghaiYmd(utc)).toBe('2026-08-31'); + expect(startOfShanghaiDay(utc).toISOString()).toBe('2026-08-30T16:00:00.000Z'); + }); + + it('UTC 15:59 仍是北京当日', () => { + const utc = new Date('2026-08-30T15:59:59.000Z'); + expect(shanghaiYmd(utc)).toBe('2026-08-30'); + expect(startOfShanghaiDay(utc).toISOString()).toBe('2026-08-29T16:00:00.000Z'); + }); + + it('不依赖服务器本地时区:输入 ISO 即得到北京自然日 00:00', () => { + const noonUtc = new Date('2026-08-30T04:00:00.000Z'); // 北京 12:00 + expect(shanghaiYmd(noonUtc)).toBe('2026-08-30'); + expect(startOfShanghaiDay(noonUtc).toISOString()).toBe('2026-08-29T16:00:00.000Z'); + }); +}); + +describe('shanghaiT1DayWindow', () => { + it('北京 8 月 30 日 08:00 出账:出账日 30 日,窗口为 29 日全天', () => { + const { start, end, billDate } = shanghaiT1DayWindow(new Date('2026-08-30T00:00:00.000Z')); + expect(shanghaiYmd(billDate)).toBe('2026-08-30'); + expect(shanghaiYmd(start)).toBe('2026-08-29'); + expect(end.toISOString()).toBe(billDate.toISOString()); + }); + + it('北京 8 月 31 日 00:05:出账日仍是 31 日,不是 UTC 的 30 日', () => { + const { billDate } = shanghaiT1DayWindow(new Date('2026-08-30T16:05:00.000Z')); + expect(shanghaiYmd(billDate)).toBe('2026-08-31'); + }); +}); + +describe('shanghaiLaggedIssueWindow', () => { + it('酒厂 T+3:出账日是今天,纳入 3 天前完成的订单', () => { + const { start, end, billDate } = shanghaiLaggedIssueWindow( + new Date('2026-08-31T00:00:00.000Z'), + 3, + ); + expect(shanghaiYmd(billDate)).toBe('2026-08-31'); + expect(shanghaiYmd(start)).toBe('2026-08-28'); + expect(shanghaiYmd(end)).toBe('2026-08-29'); + }); +}); + +describe('addShanghaiDays', () => { + it('跨月跨年按北京日历加减', () => { + expect(shanghaiYmd(addShanghaiDays(new Date('2026-08-31T16:00:00.000Z'), 1))).toBe( + '2026-09-02', + ); + expect(shanghaiYmd(addShanghaiDays(new Date('2026-01-01T00:00:00.000Z'), -1))).toBe( + '2025-12-31', + ); + }); +}); + +describe('shanghaiMonthRange', () => { + it('8 月账期为北京 8/1 00:00 至 9/1 00:00(不含)', () => { + const { start, endExclusive } = shanghaiMonthRange(2026, 8); + expect(start.toISOString()).toBe('2026-07-31T16:00:00.000Z'); + expect(endExclusive.toISOString()).toBe('2026-08-31T16:00:00.000Z'); + expect(shanghaiYmd(shanghaiMonthLastInstant(2026, 8))).toBe('2026-08-31'); + }); + + 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', + }); + }); +}); + +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({ + year: 2026, + month: 8, + }); + }); +}); diff --git a/packages/domain/src/shanghai-date.ts b/packages/domain/src/shanghai-date.ts new file mode 100644 index 0000000..d8c33cc --- /dev/null +++ b/packages/domain/src/shanghai-date.ts @@ -0,0 +1,76 @@ +/** 财务账单日 / 账期一律按中国北京时间(Asia/Shanghai,无夏令时) */ + +export const SHANGHAI_TIME_ZONE = 'Asia/Shanghai'; + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +export function shanghaiYmd(d: Date): string { + return d.toLocaleDateString('sv-SE', { timeZone: SHANGHAI_TIME_ZONE }); +} + +export function parseShanghaiYmd(ymd: string): Date { + return new Date(`${ymd}T00:00:00+08:00`); +} + +export function startOfShanghaiDay(d: Date = new Date()): Date { + return parseShanghaiYmd(shanghaiYmd(d)); +} + +export function addShanghaiDays(d: Date, days: number): Date { + const [y, m, day] = shanghaiYmd(startOfShanghaiDay(d)).split('-').map(Number); + const utc = new Date(Date.UTC(y, m - 1, day + days)); + const ymd = `${utc.getUTCFullYear()}-${pad2(utc.getUTCMonth() + 1)}-${pad2(utc.getUTCDate())}`; + return new Date(`${ymd}T00:00:00+08:00`); +} + +/** 门店 T+1:核销窗口 [昨日 00:00, 今日 00:00);出账日 = 今天 */ +export function shanghaiT1DayWindow(anchor = new Date()) { + const billDate = startOfShanghaiDay(anchor); + const start = addShanghaiDays(billDate, -1); + return { start, end: billDate, billDate }; +} + +/** + * 酒厂 T+N:出账日 = 今天;纳入窗口为「今天 − lagDays」当天完成的订单 + */ +export function shanghaiLaggedIssueWindow(anchor = new Date(), lagDays: number) { + const billDate = startOfShanghaiDay(anchor); + const start = addShanghaiDays(billDate, -lagDays); + const end = addShanghaiDays(start, 1); + return { start, end, billDate }; +} + +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; + const endYear = month === 12 ? year + 1 : year; + const endExclusive = new Date(`${endYear}-${pad2(endMonth)}-01T00:00:00+08:00`); + return { start, endExclusive }; +} + +export function shanghaiMonthLastInstant(year: number, month: number): Date { + return new Date(shanghaiMonthRange(year, month).endExclusive.getTime() - 1); +} + +export function previousShanghaiMonth(anchor = new Date()): { year: number; month: number } { + const [y, m] = shanghaiYmd(anchor).split('-').map(Number); + if (m === 1) return { year: y - 1, month: 12 }; + return { year: y, month: m - 1 }; +} + +export function shanghaiYearMonth(anchor = new Date()): { year: number; month: number } { + const [y, m] = shanghaiYmd(anchor).split('-').map(Number); + return { year: y, month: m }; +} + +/** 账期展示:以 periodStart 所在北京月的首末日,避免 UTC 23:59:59 滚到下月 */ +export function shanghaiPeriodYmds(periodStart: Date): { periodStart: string; periodEnd: string } { + const [y, m] = shanghaiYmd(periodStart).split('-').map(Number); + const { start, endExclusive } = shanghaiMonthRange(y, m); + return { + periodStart: shanghaiYmd(start), + periodEnd: shanghaiYmd(new Date(endExclusive.getTime() - 1)), + }; +} diff --git a/packages/shared-types/src/dev-plan.ts b/packages/shared-types/src/dev-plan.ts index 50990c4..0d0119b 100644 --- a/packages/shared-types/src/dev-plan.ts +++ b/packages/shared-types/src/dev-plan.ts @@ -22,11 +22,11 @@ export const DEV_PLAN_TASK_TYPE_LABELS: Record = { /** 开发计划任务状态 */ -export type DevPlanTaskStatusDto = 'TODO' | 'DEVELOPED' | 'RELEASED'; +export type DevPlanTaskStatusDto = 'TODO' | 'IN_PROGRESS' | 'DEVELOPED' | 'RELEASED'; -export const DEV_PLAN_TASK_STATUSES = ['TODO', 'DEVELOPED', 'RELEASED'] as const; +export const DEV_PLAN_TASK_STATUSES = ['TODO', 'IN_PROGRESS', 'DEVELOPED', 'RELEASED'] as const; @@ -34,6 +34,8 @@ export const DEV_PLAN_TASK_STATUS_LABELS: Record = TODO: '待开发', + IN_PROGRESS: '开发中', + DEVELOPED: '已开发', RELEASED: '已上线', diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index 38c1ae7..97b2b7a 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -187,6 +187,7 @@ export interface StoreBillDto { export interface WineryBillDto { id: string; billNo: string; + /** 出账日 YYYY-MM-DD(北京时间;T+3 只决定纳入哪天完成的订单,不是账单日) */ billDate: string; orderCount: number; orderAmount: number; @@ -212,7 +213,7 @@ export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6; /** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ export const WINERY_SETTLEMENT_RATE = 0.3; -/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入账单) */ +/** 酒厂账单出账滞后自然日(T+3:订单完成日 + 3 天后纳入当天出账的账单;账单日=出账当天北京日历日) */ export const WINERY_SETTLEMENT_LAG_DAYS = 3; export type { LogisticsSettlementMethod } from './enums'; diff --git a/server/dukang-api/prisma/migrate-dev-plan-task-in-progress.sql b/server/dukang-api/prisma/migrate-dev-plan-task-in-progress.sql new file mode 100644 index 0000000..7929204 --- /dev/null +++ b/server/dukang-api/prisma/migrate-dev-plan-task-in-progress.sql @@ -0,0 +1,3 @@ +-- 开发计划任务增加「开发中」状态 +ALTER TABLE `dev_plan_task` + MODIFY COLUMN `status` ENUM('TODO', 'IN_PROGRESS', 'DEVELOPED', 'RELEASED') NOT NULL DEFAULT 'TODO'; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 53bfdd5..3697518 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -126,6 +126,7 @@ enum DevPlanTaskType { enum DevPlanTaskStatus { TODO + IN_PROGRESS DEVELOPED RELEASED } diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts b/server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts index 30fc7d1..e17e009 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-bot-ai.service.ts @@ -28,7 +28,7 @@ const TOOL_INSTRUCTION = [ '- finance_payout · finance_withdrawal', '- dev_plan_tasks [状态] · dev_plan_versions [版本号]', '- dev_plan_task_create <描述>', - '- dev_plan_task_update_status <任务编号> ', + '- dev_plan_task_update_status <任务编号> ', '- dev_plan_version_create <版本号>', '- dev_plan_version_update_status <版本号> ', '- dev_plan_version_link_tasks <版本号> <任务编号1,任务编号2>', diff --git a/server/dukang-api/src/integrations/wecom/wecom-bot-capability.service.ts b/server/dukang-api/src/integrations/wecom/wecom-bot-capability.service.ts index 0314457..f1ed4de 100644 --- a/server/dukang-api/src/integrations/wecom/wecom-bot-capability.service.ts +++ b/server/dukang-api/src/integrations/wecom/wecom-bot-capability.service.ts @@ -117,7 +117,7 @@ export class WecomBotCapabilityService { ) { const cmds: string[] = []; if (wecomBotHasPermission(bot, 'dev_plan.task.create')) cmds.push('`新建任务 BUG|需求|优化 <描述>`'); - if (wecomBotHasPermission(bot, 'dev_plan.task.update_status')) cmds.push('`修改任务 <编号> TODO|DEVELOPED|RELEASED`'); + if (wecomBotHasPermission(bot, 'dev_plan.task.update_status')) cmds.push('`修改任务 <编号> TODO|IN_PROGRESS|DEVELOPED|RELEASED`'); lines.push('**开发任务管理**', cmds.join(' · '), ''); } if ( @@ -1234,19 +1234,20 @@ export class WecomBotCapabilityService { private async updateDevPlanTaskStatus(bot: WecomBotRuntimeConfig, wecomUserId: string, input: string) { this.requirePerm(bot, 'dev_plan.task.update_status'); const trimmed = input.trim(); - if (!trimmed) return '请提供任务编号和状态,格式:`修改任务 STxxx TODO|DEVELOPED|RELEASED`'; + if (!trimmed) return '请提供任务编号和状态,格式:`修改任务 STxxx TODO|IN_PROGRESS|DEVELOPED|RELEASED`'; const parts = trimmed.split(/\s+/).filter(Boolean); - if (parts.length < 2) return '格式:`修改任务 <任务编号> <状态>`\n状态:TODO(待开发)· DEVELOPED(已开发)· RELEASED(已发布)'; + if (parts.length < 2) return '格式:`修改任务 <任务编号> <状态>`\n状态:TODO(待开发)· IN_PROGRESS(开发中)· DEVELOPED(已开发)· RELEASED(已上线)'; const [taskNo, rawStatus] = parts; const STATUS_ALIAS: Record = { TODO: 'TODO', todo: 'TODO', 待开发: 'TODO', + IN_PROGRESS: 'IN_PROGRESS', in_progress: 'IN_PROGRESS', 开发中: 'IN_PROGRESS', DEVELOPED: 'DEVELOPED', developed: 'DEVELOPED', 已开发: 'DEVELOPED', - RELEASED: 'RELEASED', released: 'RELEASED', 已发布: 'RELEASED', + RELEASED: 'RELEASED', released: 'RELEASED', 已发布: 'RELEASED', 已上线: 'RELEASED', }; const status = STATUS_ALIAS[rawStatus]; - if (!status) return `未知状态「${rawStatus}」。可用:TODO(待开发)/ DEVELOPED(已开发)/ RELEASED(已发布)`; + if (!status) return `未知状态「${rawStatus}」。可用:TODO(待开发)/ IN_PROGRESS(开发中)/ DEVELOPED(已开发)/ RELEASED(已上线)`; const row = await this.prisma.devPlanTask.findFirst({ where: { taskNo } }); if (!row) return `未找到任务「${taskNo}」`; diff --git a/server/dukang-api/src/jobs/settlement.scheduler.ts b/server/dukang-api/src/jobs/settlement.scheduler.ts index 91b8915..f93d7c5 100644 --- a/server/dukang-api/src/jobs/settlement.scheduler.ts +++ b/server/dukang-api/src/jobs/settlement.scheduler.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; +import { shanghaiYmd } from '@dukang/domain'; import { SettlementService } from '../modules/settlement/settlement.service'; import { AlertService } from '../common/alert/alert.service'; @@ -31,7 +32,7 @@ export class SettlementScheduler { category: 'settlement', title: '酒厂日账单任务失败', detail: e instanceof Error ? e.message : String(e), - dedupeKey: `job_winery_bill|${new Date().toISOString().slice(0, 10)}`, + dedupeKey: `job_winery_bill|${shanghaiYmd(new Date())}`, }); } try { @@ -44,7 +45,7 @@ export class SettlementScheduler { category: 'settlement', title: '门店日账单任务失败', detail: e instanceof Error ? e.message : String(e), - dedupeKey: `job_store_bill|${new Date().toISOString().slice(0, 10)}`, + dedupeKey: `job_store_bill|${shanghaiYmd(new Date())}`, }); } } diff --git a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts index af8f22e..aa72f16 100644 --- a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts +++ b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts @@ -506,9 +506,9 @@ export class DevPlanService { const completed = taskCompletedAtOnStatus( - existing.status as 'TODO' | 'DEVELOPED' | 'RELEASED', + existing.status as DevPlanTaskStatus, - dto.status as 'TODO' | 'DEVELOPED' | 'RELEASED', + dto.status as DevPlanTaskStatus, existing.completedAt, diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 36518e5..1291c06 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -9,10 +9,21 @@ import { WINERY_SETTLEMENT_RATE, } from '@dukang/shared-types'; import { + addShanghaiDays, calcLogisticsFeeByBottles, calcRedeemSettleAmount, + parseShanghaiYmd, pickPayoutsForWithdrawAmount, + previousShanghaiMonth, resolveSettlementRate, + shanghaiLaggedIssueWindow, + shanghaiMonthLastInstant, + shanghaiMonthRange, + shanghaiPeriodYmds, + shanghaiT1DayWindow, + shanghaiYearMonth, + shanghaiYmd, + startOfShanghaiDay, sumUnbilledPayoutAmount, validateStoreWithdraw, type LogisticsPricingRule, @@ -64,30 +75,8 @@ function paymentProofUrlsInput(urls?: string[]): Prisma.InputJsonValue | typeof return parsed.length ? (parsed as Prisma.InputJsonValue) : Prisma.JsonNull; } -/** 上海时区自然日 00:00(用本地 Date 构造;服务器需设 Asia/Shanghai 或等价) */ -function startOfDay(d: Date) { - return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); -} - -/** 核销窗口:昨日 00:00 ≤ t < 今日 00:00;出账日 = 今日 00:00(窗口右端) */ -function dayWindow(anchor = new Date()) { - const end = startOfDay(anchor); - const start = new Date(end); - start.setDate(start.getDate() - 1); - return { start, end, billDate: end }; -} - -function shanghaiYmd(d: Date): string { - return d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }); -} - -function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) { - const billDate = startOfDay(anchor); - billDate.setDate(billDate.getDate() - lagDays); - const start = billDate; - const end = new Date(start); - end.setDate(end.getDate() + 1); - return { start, end, billDate: start }; +function withShanghaiPeriod(row: T) { + return { ...row, ...shanghaiPeriodYmds(row.periodStart) }; } function toPartnerBillItemDto(row: { @@ -180,17 +169,11 @@ function getStoreWithdrawDailyLimit(): number { /** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean { - const day = appliedAt.getDay(); // 0 Sun … 6 Sat + const ymd = shanghaiYmd(appliedAt); + const noon = new Date(`${ymd}T12:00:00+08:00`); + const day = noon.getUTCDay(); // 与北京日历日相同 if (day === 0 || day === 6) return false; - const deadline = new Date( - appliedAt.getFullYear(), - appliedAt.getMonth(), - appliedAt.getDate(), - 18, - 0, - 0, - 0, - ); + const deadline = new Date(`${ymd}T18:00:00+08:00`); return now.getTime() > deadline.getTime(); } @@ -210,15 +193,21 @@ export class SettlementService implements OnModuleInit { async onModuleInit() { try { const shifted = await this.realignStoreBillIssueDates(); - if (shifted > 0) this.logger.log(`Store bill dates aligned to issue day: ${shifted}`); + if (shifted > 0) this.logger.log(`Store bill dates aligned to Beijing issue day: ${shifted}`); } catch (e) { this.logger.warn(`Store bill date align skipped: ${e instanceof Error ? e.message : e}`); } + try { + const shifted = await this.realignWineryBillIssueDates(); + if (shifted > 0) this.logger.log(`Winery bill dates aligned to Beijing issue day: ${shifted}`); + } catch (e) { + this.logger.warn(`Winery bill date align skipped: ${e instanceof Error ? e.message : e}`); + } } /** - * 旧数据 billDate=核销自然日(窗口左端);现改为出账日(窗口右端=今天)。 - * 按 created_at 日历日对比,只把仍早一天的行 +1;从晚到早更新避免 (storeId,billDate) 冲突。 + * 账单日 = 出账当天(created_at 的北京日历日 00:00+08)。 + * 纠正:旧窗口左端、UTC DATE() +1 误伤、以及 toISOString 跨日。 */ private async realignStoreBillIssueDates(): Promise { const locked = await this.prisma.$queryRaw>` @@ -226,18 +215,53 @@ export class SettlementService implements OnModuleInit { `; if (!Number(locked[0]?.acquired)) return 0; try { - const shifted = await this.prisma.$executeRaw` - UPDATE store_bill - SET bill_date = DATE_ADD(bill_date, INTERVAL 1 DAY) - WHERE DATE(bill_date) < DATE(created_at) - ORDER BY bill_date DESC - `; - return Number(shifted); + const bills = await this.prisma.storeBill.findMany({ + select: { id: true, storeId: true, billDate: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }); + let shifted = 0; + for (const b of bills) { + const issue = startOfShanghaiDay(b.createdAt); + if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue; + const clash = await this.prisma.storeBill.findUnique({ + where: { storeId_billDate: { storeId: b.storeId, billDate: issue } }, + }); + if (clash && clash.id !== b.id) continue; + await this.prisma.storeBill.update({ where: { id: b.id }, data: { billDate: issue } }); + shifted += 1; + } + return shifted; } finally { await this.prisma.$queryRaw`SELECT RELEASE_LOCK('store_bill_issue_date_align')`; } } + /** 酒厂账单日改为出账当天(不再存 T+3 完成日) */ + 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 bills = await this.prisma.wineryBill.findMany({ + select: { id: true, billDate: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }); + let shifted = 0; + for (const b of bills) { + const issue = startOfShanghaiDay(b.createdAt); + if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue; + const clash = await this.prisma.wineryBill.findUnique({ where: { billDate: issue } }); + if (clash && clash.id !== b.id) continue; + await this.prisma.wineryBill.update({ where: { id: b.id }, data: { billDate: issue } }); + shifted += 1; + } + return shifted; + } finally { + await this.prisma.$queryRaw`SELECT RELEASE_LOCK('winery_bill_issue_date_align')`; + } + } + private notifyPartnerBillDigest( period: string, rows: Array<{ @@ -424,9 +448,8 @@ export class SettlementService implements OnModuleInit { } private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) { - const start = startOfDay(now); - const end = new Date(start); - end.setDate(end.getDate() + 1); + const start = startOfShanghaiDay(now); + const end = addShanghaiDays(start, 1); const agg = await this.prisma.storeWithdrawRequest.aggregate({ where: { storeId, @@ -897,7 +920,7 @@ export class SettlementService implements OnModuleInit { ...lines, '请尽快在 HQ「财务 → 门店账单(手动提现)」处理。', ].join('\n'), - dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`, + dedupeKey: `store_withdraw_overdue|${shanghaiYmd(new Date())}`, dedupeTtlSec: 6 * 3600, }); } @@ -1075,9 +1098,9 @@ export class SettlementService implements OnModuleInit { // ─── Store bills (daily header) ────────────────────── - /** 生成昨日核销窗口的门店对账单;billDate = 出账日(今日) */ + /** 生成昨日核销窗口的门店对账单;billDate = 出账日(北京时间今天) */ async generateStoreBillsForDay(anchor = new Date()) { - const { start, end, billDate } = dayWindow(anchor); + const { start, end, billDate } = shanghaiT1DayWindow(anchor); const payouts = await this.prisma.storePayout.findMany({ where: { storeBillId: null, @@ -1275,12 +1298,8 @@ export class SettlementService implements OnModuleInit { if (query.storeId) where.storeId = BigInt(query.storeId); if (query.dateFrom || query.dateTo) { where.appliedAt = {}; - if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom); - if (query.dateTo) { - const end = new Date(query.dateTo); - end.setHours(23, 59, 59, 999); - where.appliedAt.lte = end; - } + if (query.dateFrom) where.appliedAt.gte = parseShanghaiYmd(query.dateFrom); + if (query.dateTo) where.appliedAt.lt = addShanghaiDays(parseShanghaiYmd(query.dateTo), 1); } return where; })() @@ -1564,8 +1583,8 @@ export class SettlementService implements OnModuleInit { if (query.storeId) where.storeId = BigInt(query.storeId); if (query.dateFrom || query.dateTo) { where.billDate = {}; - if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom)); - if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo)); + if (query.dateFrom) where.billDate.gte = parseShanghaiYmd(query.dateFrom); + if (query.dateTo) where.billDate.lte = parseShanghaiYmd(query.dateTo); } return where; } @@ -1598,7 +1617,7 @@ export class SettlementService implements OnModuleInit { eventName: 'partner_bill_view', extraJson: { count: bills.length }, }); - return serializeBigInt(bills); + return serializeBigInt(bills.map(withShanghaiPeriod)); } async getPartnerBill(partnerAccountId: bigint, billId: bigint) { @@ -1617,7 +1636,7 @@ export class SettlementService implements OnModuleInit { }); const { items, ...header } = bill; return serializeBigInt({ - ...header, + ...withShanghaiPeriod(header), partnerId: header.partnerAccountId.toString(), ...splitPartnerBillItems(items), }); @@ -1664,7 +1683,7 @@ export class SettlementService implements OnModuleInit { ]); const items = rawItems.map((b) => ({ - ...b, + ...withShanghaiPeriod(b), partner: b.partnerAccount, })); @@ -1688,9 +1707,8 @@ export class SettlementService implements OnModuleInit { if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.year && query.month) { - const periodStart = new Date(query.year, query.month - 1, 1); - const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999); - where.periodStart = { gte: periodStart, lte: periodEnd }; + const { start, endExclusive } = shanghaiMonthRange(query.year, query.month); + where.periodStart = { gte: start, lt: endExclusive }; } return where; } @@ -1767,8 +1785,8 @@ export class SettlementService implements OnModuleInit { /** 每月 1 日任务:生成上一自然月账单 */ async generatePreviousMonthPartnerBills(anchor = new Date()) { - const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1); - return this.generateAllPartnerBills({ year: prev.getFullYear(), month: prev.getMonth() + 1 }); + const prev = previousShanghaiMonth(anchor); + return this.generateAllPartnerBills({ year: prev.year, month: prev.month }); } async getAdminPartnerBill(id: bigint) { @@ -1779,7 +1797,7 @@ export class SettlementService implements OnModuleInit { if (!bill) throw new NotFoundException('账单不存在'); const { items, ...header } = bill; return serializeBigInt({ - ...header, + ...withShanghaiPeriod(header), partnerId: header.partnerAccountId.toString(), ...splitPartnerBillItems(items), }); @@ -1791,12 +1809,13 @@ export class SettlementService implements OnModuleInit { ) { const partnerAccountId = BigInt(body.partnerId); const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); - const periodStart = new Date(body.year, body.month - 1, 1); - const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999); + const { start: periodStart, endExclusive } = shanghaiMonthRange(body.year, body.month); + const periodEnd = shanghaiMonthLastInstant(body.year, body.month); - const existing = await this.prisma.partnerBill.findUnique({ + const existing = await this.prisma.partnerBill.findFirst({ where: { - partnerAccountId_periodStart: { partnerAccountId: primary.id, periodStart }, + partnerAccountId: primary.id, + periodStart: { gte: periodStart, lt: endExclusive }, }, }); if (existing && existing.status !== 'PENDING_REVIEW') { @@ -1876,6 +1895,7 @@ export class SettlementService implements OnModuleInit { orderCommission: round2(orderCommission), redeemCommission: round2(redeemCommission), totalAmount, + periodStart, periodEnd, status: 'PENDING_REVIEW', }, @@ -2142,15 +2162,15 @@ export class SettlementService implements OnModuleInit { csvEscape(b.billNo), csvEscape(b.partnerAccount.companyName ?? ''), csvEscape(b.partnerAccount.phone ?? ''), - b.periodStart.toISOString().slice(0, 10), - b.periodEnd.toISOString().slice(0, 10), + shanghaiPeriodYmds(b.periodStart).periodStart, + shanghaiPeriodYmds(b.periodStart).periodEnd, Number(b.orderCommission), Number(b.redeemCommission), Number(b.totalAmount), b.status, - b.sentAt ? b.sentAt.toISOString().slice(0, 10) : '', - b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '', - b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '', + b.sentAt ? shanghaiYmd(b.sentAt) : '', + b.confirmedAt ? shanghaiYmd(b.confirmedAt) : '', + b.paidAt ? shanghaiYmd(b.paidAt) : '', csvEscape(b.paymentRef ?? ''), csvEscape(b.partnerAccount.bankAccountName ?? ''), csvEscape(b.partnerAccount.bankAccountNo ?? ''), @@ -2182,15 +2202,20 @@ export class SettlementService implements OnModuleInit { // ─── Winery bills ──────────────────────────────────── async generateWineryBillForDay(anchor = new Date()) { - const { start, end, billDate } = wineryDayWindow(anchor); + const { start, end, billDate } = shanghaiLaggedIssueWindow(anchor, WINERY_SETTLEMENT_LAG_DAYS); const rate = WINERY_SETTLEMENT_RATE; + const issueYmd = shanghaiYmd(billDate); - const existing = await this.prisma.wineryBill.findUnique({ where: { billDate } }); + const existing = + (await this.prisma.wineryBill.findUnique({ where: { billDate } })) ?? + (await this.prisma.wineryBill.findFirst({ + where: { createdAt: { gte: billDate, lt: addShanghaiDays(billDate, 1) } }, + })); if (existing?.status === 'PAID') { - return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' }; + return { billDate: issueYmd, skipped: true, reason: '已打款' }; } - // T+3:纳入「账单日」当天完成的同城/跨城订单(完成日 = 今天 − lagDays) + // T+3:纳入「今天 − lagDays」当天完成的同城/跨城订单;billDate = 出账日(今天) const orders = await this.prisma.order.findMany({ where: { status: 'COMPLETED', @@ -2203,7 +2228,7 @@ export class SettlementService implements OnModuleInit { }); if (orders.length === 0 && !existing) { - return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '无订单' }; + return { billDate: issueYmd, skipped: true, reason: '无订单' }; } const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0)); @@ -2214,6 +2239,7 @@ export class SettlementService implements OnModuleInit { ? await tx.wineryBill.update({ where: { id: existing.id }, data: { + billDate, orderCount: orders.length, orderAmount, wineryRate: rate, @@ -2254,7 +2280,7 @@ export class SettlementService implements OnModuleInit { return header; }); - const period = billDate.toISOString().slice(0, 10); + const period = issueYmd; const wineryBank = await loadWineryBankConfig(this.prisma); const bankParts = wecomBankParts(wineryBank); const cityNames = joinLimited( @@ -2324,7 +2350,13 @@ export class SettlementService implements OnModuleInit { totalAmount: Number(aggregates._sum.wineryAmount ?? 0), }; - return serializeBigInt({ items, total, page, pageSize, summary }); + return serializeBigInt({ + items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })), + total, + page, + pageSize, + summary, + }); } async getAdminWineryBill(id: bigint) { @@ -2334,7 +2366,7 @@ export class SettlementService implements OnModuleInit { }); if (!bill) throw new NotFoundException('酒厂对账单不存在'); const wineryBank = await loadWineryBankConfig(this.prisma); - return serializeBigInt({ ...bill, wineryBank }); + return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), wineryBank }); } async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) { @@ -2418,7 +2450,7 @@ export class SettlementService implements OnModuleInit { rows.push( [ csvEscape(b.billNo), - b.billDate.toISOString().slice(0, 10), + shanghaiYmd(b.billDate), '', '', Number(b.orderAmount), @@ -2435,7 +2467,7 @@ export class SettlementService implements OnModuleInit { rows.push( [ csvEscape(b.billNo), - b.billDate.toISOString().slice(0, 10), + shanghaiYmd(b.billDate), csvEscape(item.orderNo), item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType, Number(item.payAmount), @@ -2469,13 +2501,12 @@ export class SettlementService implements OnModuleInit { where.wineryAmount = { gt: 0 }; } if (query.year && query.month) { - const start = new Date(query.year, query.month - 1, 1); - const end = new Date(query.year, query.month, 0, 23, 59, 59, 999); - where.billDate = { gte: start, lte: end }; + const { start, endExclusive } = shanghaiMonthRange(query.year, query.month); + where.billDate = { gte: start, lt: endExclusive }; } else if (query.dateFrom || query.dateTo) { where.billDate = {}; - if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom)); - if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo)); + if (query.dateFrom) where.billDate.gte = parseShanghaiYmd(query.dateFrom); + if (query.dateTo) where.billDate.lte = parseShanghaiYmd(query.dateTo); } return where; } @@ -2483,10 +2514,10 @@ export class SettlementService implements OnModuleInit { // ─── Logistics bills (按承运商月结) ─────────────────── async generatePreviousMonthLogisticsBills(anchor = new Date()) { - const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1); + const prev = previousShanghaiMonth(anchor); return this.generateAllLogisticsBills({ - year: prev.getFullYear(), - month: prev.getMonth() + 1, + year: prev.year, + month: prev.month, }); } @@ -2590,12 +2621,13 @@ export class SettlementService implements OnModuleInit { }); if (!provider) throw new NotFoundException('仓配承运商不存在'); - const periodStart = new Date(body.year, body.month - 1, 1); - const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999); + const { start: periodStart, endExclusive } = shanghaiMonthRange(body.year, body.month); + const periodEnd = shanghaiMonthLastInstant(body.year, body.month); - const existing = await this.prisma.logisticsBill.findUnique({ + const existing = await this.prisma.logisticsBill.findFirst({ where: { - fulfillmentProviderId_periodStart: { fulfillmentProviderId, periodStart }, + fulfillmentProviderId, + periodStart: { gte: periodStart, lt: endExclusive }, }, }); if (existing?.status === 'PAID') { @@ -2676,6 +2708,7 @@ export class SettlementService implements OnModuleInit { ? await tx.logisticsBill.update({ where: { id: existing.id }, data: { + periodStart, periodEnd, orderCount, bottleCount, @@ -2814,7 +2847,7 @@ export class SettlementService implements OnModuleInit { ]); const items = rawItems.map((b) => ({ - ...b, + ...withShanghaiPeriod(b), providerCode: b.fulfillmentProvider.code, providerName: b.fulfillmentProvider.name, pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(b.pricingSnapshotJson), @@ -2831,10 +2864,11 @@ export class SettlementService implements OnModuleInit { } async listLogisticsProviderSummary(query: { year?: number; month?: number }) { - const year = query.year ?? new Date().getFullYear(); - const month = query.month ?? new Date().getMonth() + 1; - const periodStart = new Date(year, month - 1, 1); - const periodEnd = new Date(year, month, 0, 23, 59, 59, 999); + const fallback = shanghaiYearMonth(); + const year = query.year ?? fallback.year; + const month = query.month ?? fallback.month; + const { start: periodStart, endExclusive } = shanghaiMonthRange(year, month); + const periodEnd = new Date(endExclusive.getTime() - 1); const providers = await this.prisma.fulfillmentProvider.findMany({ orderBy: { code: 'asc' }, @@ -2897,12 +2931,10 @@ export class SettlementService implements OnModuleInit { ); } - const bill = await this.prisma.logisticsBill.findUnique({ + const bill = await this.prisma.logisticsBill.findFirst({ where: { - fulfillmentProviderId_periodStart: { - fulfillmentProviderId: p.id, - periodStart, - }, + fulfillmentProviderId: p.id, + periodStart: { gte: periodStart, lt: endExclusive }, }, }); @@ -2938,7 +2970,7 @@ export class SettlementService implements OnModuleInit { }); if (!bill) throw new NotFoundException('物流对账单不存在'); return serializeBigInt({ - ...bill, + ...withShanghaiPeriod(bill), providerCode: bill.fulfillmentProvider.code, providerName: bill.fulfillmentProvider.name, pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson), @@ -3056,8 +3088,8 @@ export class SettlementService implements OnModuleInit { csvEscape(b.billNo), csvEscape(p.code), csvEscape(p.name), - b.periodStart.toISOString().slice(0, 10), - b.periodEnd.toISOString().slice(0, 10), + shanghaiPeriodYmds(b.periodStart).periodStart, + shanghaiPeriodYmds(b.periodStart).periodEnd, b.settlementMethod, '', b.bottleCount, @@ -3075,8 +3107,8 @@ export class SettlementService implements OnModuleInit { csvEscape(b.billNo), csvEscape(p.code), csvEscape(p.name), - b.periodStart.toISOString().slice(0, 10), - b.periodEnd.toISOString().slice(0, 10), + shanghaiPeriodYmds(b.periodStart).periodStart, + shanghaiPeriodYmds(b.periodStart).periodEnd, b.settlementMethod, csvEscape(item.orderNo), item.quantity, @@ -3101,9 +3133,8 @@ export class SettlementService implements OnModuleInit { if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status; if (query.providerId) where.fulfillmentProviderId = BigInt(query.providerId); if (query.year && query.month) { - const periodStart = new Date(query.year, query.month - 1, 1); - const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999); - where.periodStart = { gte: periodStart, lte: periodEnd }; + const { start, endExclusive } = shanghaiMonthRange(query.year, query.month); + where.periodStart = { gte: start, lt: endExclusive }; } return where; } -- 2.53.0