From d61a43cc8cf6ad2217a5eff8e44780ee6a4d2b4c Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Tue, 14 Jul 2026 15:16:41 +0800 Subject: [PATCH] =?UTF-8?q?webadmin=E5=A2=9E=E5=8A=A0=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/App.tsx | 10 +- apps/admin-web/src/layouts/AdminLayout.tsx | 15 +- apps/admin-web/src/lib/api.ts | 10 + apps/admin-web/src/lib/exportExcel.ts | 10 + apps/admin-web/src/pages/PartnerBillsPage.tsx | 317 ++++++++++--- apps/admin-web/src/pages/StoreBillsPage.tsx | 127 ++++- apps/admin-web/src/pages/WineryBillsPage.tsx | 205 ++++++++ apps/mini-user/config/index.ts | 20 +- apps/mini-user/package.json | 12 +- packages/shared-types/src/settlement.ts | 44 ++ pnpm-lock.yaml | 9 + scripts/dev-mini-user-h5.mjs | 167 +++++++ .../settlement/settlement.controller.ts | 97 +++- .../modules/settlement/settlement.module.ts | 2 + .../modules/settlement/settlement.service.ts | 439 ++++++++++++++++-- 15 files changed, 1352 insertions(+), 132 deletions(-) create mode 100644 apps/admin-web/src/lib/exportExcel.ts create mode 100644 apps/admin-web/src/pages/WineryBillsPage.tsx create mode 100644 scripts/dev-mini-user-h5.mjs diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 9b78d8b..86d1122 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -28,6 +28,7 @@ import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage'; import ResourcesPage from './pages/ResourcesPage'; import StoreBillsPage from './pages/StoreBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; +import WineryBillsPage from './pages/WineryBillsPage'; import TicketsPage from './pages/TicketsPage'; import UserLogsPage from './pages/UserLogsPage'; import HqLogsPage from './pages/HqLogsPage'; @@ -79,9 +80,12 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 2c72049..fc5b93b 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -16,6 +16,7 @@ import { FileTextOutlined, LockOutlined, SettingOutlined, + AccountBookOutlined, } from '@ant-design/icons'; import { clearAuth, request, type HqProfile } from '../lib/api'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; @@ -44,7 +45,6 @@ const MENU_ITEMS: MenuProps['items'] = [ children: [ { key: '/stores', label: '门店列表' }, { key: '/store-accounts', label: '门店账户' }, - { key: '/store-bills', label: '门店账单' }, { key: '/store-media', label: '门店资源' }, ], }, @@ -56,7 +56,16 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/cities', label: '城市' }, { key: '/city-partners', label: '城市合伙人' }, { key: '/city-warehouses', label: '仓库' }, - { key: '/partner-bills', label: '合伙人结算' }, + ], + }, + { + key: 'finance-group', + icon: , + label: '财务', + children: [ + { key: '/finance/store-bills', label: '门店账单' }, + { key: '/finance/partner-bills', label: '合伙人账单' }, + { key: '/finance/winery-bills', label: '酒厂账单' }, ], }, { @@ -144,7 +153,7 @@ export default function AdminLayout() { theme="dark" mode="inline" selectedKeys={[selectedKey]} - defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']} + defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']} items={MENU_ITEMS} onClick={({ key }) => { if (key.startsWith('/')) navigate(key); diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 0e6138a..909870f 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -57,6 +57,16 @@ export type Paginated = { total: number; page: number; pageSize: number; + summary?: { + count: number; + redeemAmount?: number; + payoutAmount?: number; + orderAmount?: number; + orderCommission?: number; + redeemCommission?: number; + wineryAmount?: number; + totalAmount: number; + }; }; export type DashboardStats = { diff --git a/apps/admin-web/src/lib/exportExcel.ts b/apps/admin-web/src/lib/exportExcel.ts new file mode 100644 index 0000000..244355e --- /dev/null +++ b/apps/admin-web/src/lib/exportExcel.ts @@ -0,0 +1,10 @@ +/** Download CSV (UTF-8 BOM) so Excel opens Chinese columns correctly. */ +export function downloadExcelCsv(csv: string, filename: string) { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index a1d90f9..d9cd155 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -1,9 +1,23 @@ -import { useState } from 'react'; -import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd'; +import { useEffect, useState } from 'react'; +import { + Button, + Card, + DatePicker, + Form, + Modal, + Select, + Space, + Statistic, + Table, + Tag, + Typography, + message, +} from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import dayjs from 'dayjs'; -import { request } from '../lib/api'; -import { fmtTime } from '../lib/constants'; +import dayjs, { type Dayjs } from 'dayjs'; +import { request, type Paginated } from '../lib/api'; +import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; +import { downloadExcelCsv } from '../lib/exportExcel'; import { useAdminList } from '../lib/useAdminList'; type Row = { @@ -15,34 +29,66 @@ type Row = { status: string; periodStart: string; periodEnd: string; - partner?: { companyName: string }; + partner?: { companyName?: string; phone?: string }; + partnerAccount?: { companyName?: string; phone?: string }; +}; + +type PartnerOption = { id: string; companyName: string; phone?: string }; + +const STATUS_LABELS: Record = { + DRAFT: '草稿', + CONFIRMED: '已确认', + PAID: '已打款', +}; + +const STATUS_COLORS: Record = { + DRAFT: 'default', + CONFIRMED: 'blue', + PAID: 'green', }; export default function PartnerBillsPage() { + const [form] = Form.useForm(); const [filters, setFilters] = useState>({}); + const [partners, setPartners] = useState([]); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/partner-bills', () => { const qs = new URLSearchParams(); if (filters.status) qs.set('status', filters.status); if (filters.partnerId) qs.set('partnerId', filters.partnerId); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); return qs; }, [filters], ); const [genOpen, setGenOpen] = useState(false); const [genForm] = Form.useForm(); + const [exporting, setExporting] = useState(false); - async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) { - await request('/admin/partner-bills/generate', { - method: 'POST', - body: JSON.stringify({ - partnerId: values.partnerId, - year: values.month.year(), - month: values.month.month() + 1, - }), - }); - message.success('账单已生成'); + useEffect(() => { + void request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) + .then((res) => setPartners(res.items)) + .catch(() => {}); + }, []); + + async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) { + const year = values.month.year(); + const month = values.month.month() + 1; + if (values.all || !values.partnerId) { + const result = await request<{ success: number; failed: number; total: number }>( + '/admin/partner-bills/generate-all', + { method: 'POST', body: JSON.stringify({ year, month }) }, + ); + message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`); + } else { + await request('/admin/partner-bills/generate', { + method: 'POST', + body: JSON.stringify({ partnerId: values.partnerId, year, month }), + }); + message.success('账单已生成'); + } setGenOpen(false); reload(); } @@ -62,61 +108,216 @@ export default function PartnerBillsPage() { reload(); } - async function exportCsv() { - const result = await request<{ csv: string }>('/admin/partner-bills/export'); - const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'partner-bills.csv'; - a.click(); - URL.revokeObjectURL(url); + async function exportExcel() { + setExporting(true); + try { + const qs = new URLSearchParams(); + if (filters.status) qs.set('status', filters.status); + if (filters.partnerId) qs.set('partnerId', filters.partnerId); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); + const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`); + const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; + downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`); + message.success(`已导出 ${result.count} 条`); + } finally { + setExporting(false); + } } + const summary = data?.summary; + const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; + const columns: ColumnsType = [ - { title: '账单号', dataIndex: 'billNo', width: 180 }, - { title: '合伙人', dataIndex: ['partner', 'companyName'] }, - { title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` }, - { title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` }, - { title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` }, - { title: '状态', dataIndex: 'status', width: 90 }, - { title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` }, + { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, { - title: '操作', width: 180, + title: '合伙人', + width: 160, + ellipsis: true, + render: (_, r) => partnerName(r), + }, + { + title: '账期', + width: 200, + render: (_, r) => + `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`, + }, + { + title: '酒单佣金', + dataIndex: 'orderCommission', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '核销佣金', + dataIndex: 'redeemCommission', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '合计应付', + dataIndex: 'totalAmount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s) => {STATUS_LABELS[s] || s}, + }, + { + title: '操作', + width: 160, + fixed: 'right', render: (_, row) => ( - <> - {row.status === 'DRAFT' && } - {row.status === 'CONFIRMED' && } - + + {row.status === 'DRAFT' && ( + + )} + {row.status === 'CONFIRMED' && ( + + )} + ), }, ]; return (
- 合伙人结算(T+30) -
- - -
-
{ setFilters(v); setPage(1); }}> - - ({ + value: p.id, + label: p.companyName || p.phone || p.id, + }))} + /> + + + + + + -
- { setPage(p); setPageSize(ps); } }} /> - setGenOpen(false)} footer={null}> -
- - - + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + setGenOpen(false)} footer={null}> + + + + + +
+ setDrawerOpen(false)}> {detail && ( - {String((detail.store as { name?: string })?.name ?? '—')} + + {String((detail.store as { name?: string })?.name ?? '—')} + {String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')} ¥{String(detail.redeemAmount)} - ¥{String(detail.payoutAmount)} + ¥{String(detail.payoutAmount)} {detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'} @@ -196,7 +290,6 @@ export default function StoreBillsPage() { {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} - {fmtTime(String(detail.createdAt))} )} diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx new file mode 100644 index 0000000..0e56960 --- /dev/null +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { + Button, + Card, + DatePicker, + Form, + Select, + Space, + Statistic, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { Dayjs } from 'dayjs'; +import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types'; +import { fmtTime } from '../lib/constants'; +import { downloadExcelCsv } from '../lib/exportExcel'; +import { request } from '../lib/api'; +import { useAdminList } from '../lib/useAdminList'; + +type Row = { + orderId: string; + orderNo: string; + deliveryType: string; + cityName?: string; + receiverCity?: string; + payAmount: number; + wineryRate: number; + wineryAmount: number; + paidAt: string; + productName?: string; + quantity?: number; +}; + +const DELIVERY_LABELS: Record = { + LOCAL: '同城', + CROSS_CITY: '跨城', +}; + +export default function WineryBillsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({}); + const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList( + '/admin/winery-bills', + () => { + const qs = new URLSearchParams(); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); + if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType); + return qs; + }, + [filters], + ); + const [exporting, setExporting] = useState(false); + + async function exportExcel() { + setExporting(true); + try { + const qs = new URLSearchParams(); + if (filters.year) qs.set('year', filters.year); + if (filters.month) qs.set('month', filters.month); + if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType); + const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`); + const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; + downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`); + message.success(`已导出 ${result.count} 条`); + } finally { + setExporting(false); + } + } + + const summary = data?.summary; + const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100); + + const columns: ColumnsType = [ + { title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true }, + { + title: '配送类型', + dataIndex: 'deliveryType', + width: 90, + render: (v) => {DELIVERY_LABELS[v] || v}, + }, + { title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' }, + { title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' }, + { title: '商品', dataIndex: 'productName', width: 140, ellipsis: true }, + { title: '数量', dataIndex: 'quantity', width: 70 }, + { + title: '酒单实付', + dataIndex: 'payAmount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '酒厂比例', + dataIndex: 'wineryRate', + width: 90, + render: (v) => `${Math.round(Number(v) * 100)}%`, + }, + { + title: '酒厂应付', + dataIndex: 'wineryAmount', + width: 110, + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '支付时间', + dataIndex: 'paidAt', + width: 170, + render: (v) => (v ? fmtTime(v) : '—'), + }, + ]; + + return ( +
+ + + 酒厂账单 + + + T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定) + + + + {summary && ( + + + + + + + + )} + + { + setFilters({ + year: v.month ? String(v.month.year()) : '', + month: v.month ? String(v.month.month() + 1) : '', + deliveryType: v.deliveryType || '', + }); + setPage(1); + }} + > + + + + +
{ + setPage(p); + setPageSize(ps); + }, + }} + /> + + ); +} diff --git a/apps/mini-user/config/index.ts b/apps/mini-user/config/index.ts index 571a2c6..9ab49b6 100644 --- a/apps/mini-user/config/index.ts +++ b/apps/mini-user/config/index.ts @@ -33,10 +33,26 @@ export default defineConfig(async () => ({ framework: 'react', compiler: { type: 'vite', - vitePlugins: [], + vitePlugins: [ + { + name: 'dukang-mini-user-vite-memory', + config() { + return { + // H5 Vite watch 预构建 Stencil 组件时峰值极易 >8GB;noDiscovery 降低冷启动成本 + optimizeDeps: { + noDiscovery: true, + include: ['react', 'react-dom', 'react/jsx-runtime'], + }, + build: { + sourcemap: false, + }, + }; + }, + }, + ], }, cache: { - enable: false, + enable: true, }, mini: { /** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */ diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index c3f8418..01633f3 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -4,10 +4,11 @@ "private": true, "description": "杜康好客 · C 端用户微信小程序(Taro)", "scripts": { - "dev": "taro build --type h5 --watch", - "dev:weapp": "taro build --type weapp --watch --mode development", - "build": "taro build --type h5", - "build:weapp": "taro build --type weapp", + "dev": "node ../../scripts/dev-mini-user-h5.mjs", + "dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch", + "dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development", + "build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5", + "build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp", "lint": "echo ok" }, "dependencies": { @@ -32,6 +33,9 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@babel/core": "^7.24.4", + "@babel/plugin-proposal-decorators": "^7.24.1", + "@babel/plugin-transform-class-properties": "^7.24.1", "@babel/preset-react": "^7.24.1", "@tarojs/cli": "4.2.0", "@tarojs/vite-runner": "4.2.0", diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index c766206..6af99c3 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -23,3 +23,47 @@ export interface PartnerBillDetailDto extends PartnerBillDto { confirmedAt?: string | null; paidAt?: string | null; } + +/** 门店结算默认比例(核销金额 × 比例) */ +export const STORE_SETTLEMENT_DEFAULT_RATE = 0.6; + +/** 酒厂账单结算比例(酒单实付 × 比例),暂定 30% */ +export const WINERY_SETTLEMENT_RATE = 0.3; + +export type FinanceBillSummary = { + count: number; + redeemAmount?: number; + payoutAmount?: number; + orderAmount?: number; + orderCommission?: number; + redeemCommission?: number; + wineryAmount?: number; + totalAmount: number; +}; + +export type StoreDailyBillRow = { + billDate: string; + storeId: string; + storeName: string; + storePhone?: string; + cityName?: string; + redeemCount: number; + redeemAmount: number; + settlementRate: number; + payoutAmount: number; + pendingCount: number; + paidCount: number; +}; + +export type WineryOrderBillRow = { + orderId: string; + orderNo: string; + deliveryType: string; + cityName?: string; + payAmount: number; + wineryRate: number; + wineryAmount: number; + paidAt: string; + receiverCity?: string; +}; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a817b48..11098d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -320,6 +320,15 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@babel/core': + specifier: ^7.24.4 + version: 7.29.7 + '@babel/plugin-proposal-decorators': + specifier: ^7.24.1 + version: 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': + specifier: ^7.24.1 + version: 7.29.7(@babel/core@7.29.7) '@babel/preset-react': specifier: ^7.24.1 version: 7.29.7(@babel/core@7.29.7) diff --git a/scripts/dev-mini-user-h5.mjs b/scripts/dev-mini-user-h5.mjs new file mode 100644 index 0000000..31b6b25 --- /dev/null +++ b/scripts/dev-mini-user-h5.mjs @@ -0,0 +1,167 @@ +/** + * mini-user H5 本地预览: + * Taro Vite `--watch` 在本机易 OOM(峰值 >8GB),改为: + * 1) 先完整 build(约 30s) + * 2) 静态托管 dist + /api 代理 + * 3) 监听 src 变更后防抖重建 + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = path.resolve(__dirname, '../apps/mini-user'); +const DIST = path.resolve(APP_ROOT, 'dist'); +const PORT = Number(process.env.PORT || 5177); +const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top').replace(/\/$/, ''); +const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro'); + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.map': 'application/json', +}; + +function runBuild() { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ['--max-old-space-size=8192', TARO_BIN, 'build', '--type', 'h5'], + { + cwd: APP_ROOT, + stdio: 'inherit', + env: process.env, + }, + ); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`taro build failed with code ${code}`)); + }); + }); +} + +function sendFile(res, filePath) { + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' }); + fs.createReadStream(filePath).pipe(res); +} + +function proxyApi(req, res) { + const target = new URL(req.url || '/', API_ORIGIN); + const lib = target.protocol === 'https:' ? https : http; + const headers = { ...req.headers, host: target.host }; + delete headers['accept-encoding']; + const upstream = lib.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || undefined, + path: target.pathname + target.search, + method: req.method, + headers, + }, + (up) => { + res.writeHead(up.statusCode || 502, up.headers); + up.pipe(res); + }, + ); + upstream.on('error', (err) => { + res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end(`API proxy error: ${err.message}`); + }); + req.pipe(upstream); +} + +function startServer() { + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent((req.url || '/').split('?')[0]); + if (urlPath.startsWith('/api')) { + proxyApi(req, res); + return; + } + + const candidates = [ + path.join(DIST, urlPath), + path.join(DIST, urlPath, 'index.html'), + path.join(DIST, 'index.html'), + ]; + const file = candidates.find((p) => fs.existsSync(p) && fs.statSync(p).isFile()); + if (!file) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + sendFile(res, file); + }); + + server.listen(PORT, '0.0.0.0', () => { + console.log(`\n mini-user H5 preview: http://localhost:${PORT}`); + console.log(` API proxy -> ${API_ORIGIN}`); + console.log(' Watching apps/mini-user/src for changes...\n'); + }); +} + +function watchSrc() { + const srcDir = path.join(APP_ROOT, 'src'); + let timer = null; + let building = false; + let queued = false; + + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(async () => { + if (building) { + queued = true; + return; + } + building = true; + try { + console.log('\n[dev] src changed, rebuilding...'); + await runBuild(); + console.log('[dev] rebuild done — refresh browser'); + } catch (e) { + console.error('[dev] rebuild failed:', e instanceof Error ? e.message : e); + } finally { + building = false; + if (queued) { + queued = false; + schedule(); + } + } + }, 800); + }; + + fs.watch(srcDir, { recursive: true }, (_event, filename) => { + if (!filename) return; + if (/\.(tsx?|jsx?|css|scss|json|png|jpg|svg)$/i.test(filename)) { + schedule(); + } + }); +} + +async function main() { + if (!fs.existsSync(TARO_BIN)) { + throw new Error(`Taro CLI not found: ${TARO_BIN}`); + } + console.log('[dev] initial H5 build...'); + await runBuild(); + startServer(); + watchSrc(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 9718510..f346dcb 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -59,9 +59,49 @@ export class AdminStorePayoutController { pageSize: query.pageSize ? Number(query.pageSize) : 20, status: query.status, storeId: query.storeId, + dateFrom: query.dateFrom, + dateTo: query.dateTo, }); } + @Get('daily') + daily(@Query() query: Record) { + return this.settlementService.listAdminStoreDailyBills({ + page: query.page ? Number(query.page) : 1, + pageSize: query.pageSize ? Number(query.pageSize) : 50, + status: query.status, + storeId: query.storeId, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + }); + } + + @Get('export') + export(@Query() query: Record) { + return this.settlementService.exportAdminStorePayouts({ + status: query.status, + storeId: query.storeId, + dateFrom: query.dateFrom, + dateTo: query.dateTo, + }); + } + + @Post('batch-confirm') + @HqOperation({ + action: HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM, + refType: 'STORE_PAYOUT', + batch: true, + includeBody: true, + }) + batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) { + return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body); + } + + @Post('scan-due') + scanDue() { + return this.settlementService.scanDueStorePayouts(); + } + @Get(':id') detail(@Param('id') id: string) { return this.settlementService.getAdminStorePayout(BigInt(id)); @@ -77,22 +117,6 @@ export class AdminStorePayoutController { confirm(@Param('id') id: string, @Body() body: { paymentRef?: string; batchNo?: string; remark?: string }) { return this.settlementService.confirmStorePayout(BigInt(id), body); } - - @Post('batch-confirm') - @HqOperation({ - action: HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM, - refType: 'STORE_PAYOUT', - batch: true, - includeBody: true, - }) - batchConfirm(@Body() body: { ids: string[]; batchNo?: string }) { - return this.settlementService.batchConfirmStorePayouts(body.ids ?? [], body); - } - - @Post('scan-due') - scanDue() { - return this.settlementService.scanDueStorePayouts(); - } } @Controller('admin/partner-bills') @@ -111,6 +135,17 @@ export class AdminPartnerBillController { return this.settlementService.generatePartnerBill(body); } + @Post('generate-all') + @HqOperation({ + action: HqOperationAction.PARTNER_BILL_GENERATE, + refType: 'PARTNER_BILL', + batch: true, + includeBody: true, + }) + generateAll(@Body() body: { year: number; month: number }) { + return this.settlementService.generateAllPartnerBills(body); + } + @Get() list(@Query() query: Record) { return this.settlementService.listAdminPartnerBills({ @@ -118,6 +153,8 @@ export class AdminPartnerBillController { pageSize: query.pageSize ? Number(query.pageSize) : 20, status: query.status, partnerId: query.partnerId, + year: query.year ? Number(query.year) : undefined, + month: query.month ? Number(query.month) : undefined, }); } @@ -126,6 +163,8 @@ export class AdminPartnerBillController { return this.settlementService.exportPartnerBills({ partnerId: query.partnerId, status: query.status, + year: query.year ? Number(query.year) : undefined, + month: query.month ? Number(query.month) : undefined, }); } @@ -156,6 +195,32 @@ export class AdminPartnerBillController { } } +@Controller('admin/winery-bills') +@UseGuards(HqAuthGuard) +export class AdminWineryBillController { + constructor(private readonly settlementService: SettlementService) {} + + @Get() + list(@Query() query: Record) { + return this.settlementService.listAdminWineryBills({ + page: query.page ? Number(query.page) : 1, + pageSize: query.pageSize ? Number(query.pageSize) : 20, + year: query.year ? Number(query.year) : undefined, + month: query.month ? Number(query.month) : undefined, + deliveryType: query.deliveryType, + }); + } + + @Get('export') + export(@Query() query: Record) { + return this.settlementService.exportAdminWineryBills({ + year: query.year ? Number(query.year) : undefined, + month: query.month ? Number(query.month) : undefined, + deliveryType: query.deliveryType, + }); + } +} + @Controller('partner/me') @UseGuards(JwtAuthGuard) export class PartnerMeController { diff --git a/server/dukang-api/src/modules/settlement/settlement.module.ts b/server/dukang-api/src/modules/settlement/settlement.module.ts index e1dc1cd..73e592e 100644 --- a/server/dukang-api/src/modules/settlement/settlement.module.ts +++ b/server/dukang-api/src/modules/settlement/settlement.module.ts @@ -6,6 +6,7 @@ import { SettlementService } from './settlement.service'; import { AdminPartnerBillController, AdminStorePayoutController, + AdminWineryBillController, PartnerMeController, SettlementController, ShopPayoutController, @@ -19,6 +20,7 @@ import { ShopPayoutController, AdminStorePayoutController, AdminPartnerBillController, + AdminWineryBillController, ], providers: [SettlementService], exports: [SettlementService], diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index a5433c4..ea8dae4 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AnalyticsService } from '../analytics/analytics.service'; @@ -9,6 +10,11 @@ function generateBillNo() { return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } +function csvEscape(value: string) { + if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`; + return value; +} + @Injectable() export class SettlementService { constructor( @@ -77,27 +83,192 @@ export class SettlementService { pageSize?: number; status?: string; storeId?: string; + dateFrom?: string; + dateTo?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; - const where: Prisma.StorePayoutWhereInput = {}; - if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals']; - if (query.storeId) where.storeId = BigInt(query.storeId); + const where = this.buildStorePayoutWhere(query); - const [items, total] = await Promise.all([ + const [items, total, aggregates] = await Promise.all([ this.prisma.storePayout.findMany({ where, - orderBy: { expectedPayAt: 'asc' }, + orderBy: [{ createdAt: 'desc' }, { expectedPayAt: 'asc' }], skip: (page - 1) * pageSize, take: pageSize, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, - redeemRecord: { select: { redeemNo: true, amount: true, userId: true } }, + redeemRecord: { select: { redeemNo: true, amount: true, userId: true, createdAt: true } }, }, }), this.prisma.storePayout.count({ where }), + this.prisma.storePayout.aggregate({ + where, + _sum: { redeemAmount: true, payoutAmount: true }, + _count: true, + }), ]); - return serializeBigInt({ items, total, page, pageSize }); + + const summary = { + count: aggregates._count, + redeemAmount: Number(aggregates._sum.redeemAmount ?? 0), + payoutAmount: Number(aggregates._sum.payoutAmount ?? 0), + totalAmount: Number(aggregates._sum.payoutAmount ?? 0), + }; + + return serializeBigInt({ items, total, page, pageSize, summary }); + } + + /** 门店账单按日汇总(T+1:按核销日归集) */ + async listAdminStoreDailyBills(query: { + page?: number; + pageSize?: number; + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 50; + const where = this.buildStorePayoutWhere(query); + + const payouts = await this.prisma.storePayout.findMany({ + where, + include: { + store: { select: { id: true, name: true, cityName: true, phone: true } }, + redeemRecord: { select: { createdAt: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + type DailyAgg = { + billDate: string; + storeId: string; + storeName: string; + storePhone?: string; + cityName?: string; + redeemCount: number; + redeemAmount: number; + settlementRate: number; + payoutAmount: number; + pendingCount: number; + paidCount: number; + }; + + const map = new Map(); + for (const p of payouts) { + const billDate = (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10); + const storeId = p.storeId.toString(); + const key = `${billDate}:${storeId}`; + const existing = map.get(key); + if (!existing) { + map.set(key, { + billDate, + storeId, + storeName: p.store?.name ?? '—', + storePhone: p.store?.phone ?? undefined, + cityName: p.store?.cityName ?? undefined, + redeemCount: 1, + redeemAmount: Number(p.redeemAmount), + settlementRate: Number(p.settlementRate), + payoutAmount: Number(p.payoutAmount), + pendingCount: p.status === 'PENDING' ? 1 : 0, + paidCount: p.status === 'PAID' ? 1 : 0, + }); + } else { + existing.redeemCount += 1; + existing.redeemAmount += Number(p.redeemAmount); + existing.payoutAmount += Number(p.payoutAmount); + if (p.status === 'PENDING') existing.pendingCount += 1; + if (p.status === 'PAID') existing.paidCount += 1; + } + } + + const all = Array.from(map.values()).sort((a, b) => { + if (a.billDate === b.billDate) return a.storeName.localeCompare(b.storeName, 'zh-CN'); + return b.billDate.localeCompare(a.billDate); + }); + + const summary = { + count: all.length, + redeemAmount: Math.round(all.reduce((s, r) => s + r.redeemAmount, 0) * 100) / 100, + payoutAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100, + totalAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100, + }; + + const start = (page - 1) * pageSize; + const items = all.slice(start, start + pageSize).map((row) => ({ + ...row, + redeemAmount: Math.round(row.redeemAmount * 100) / 100, + payoutAmount: Math.round(row.payoutAmount * 100) / 100, + })); + + return { items, total: all.length, page, pageSize, summary }; + } + + async exportAdminStorePayouts(query: { + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }) { + const where = this.buildStorePayoutWhere(query); + const payouts = await this.prisma.storePayout.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + store: { select: { name: true, phone: true, cityName: true } }, + redeemRecord: { select: { redeemNo: true, createdAt: true } }, + }, + }); + + const header = [ + '核销日期', + '门店', + '登录手机', + '城市', + '核销单号', + '核销面额', + '结算比例', + '应付金额', + '状态', + '预计打款', + '实际打款', + ].join(','); + const rows = payouts.map((p) => + [ + (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10), + csvEscape(p.store?.name ?? ''), + csvEscape(p.store?.phone ?? ''), + csvEscape(p.store?.cityName ?? ''), + csvEscape(p.redeemRecord?.redeemNo ?? ''), + Number(p.redeemAmount), + `${Math.round(Number(p.settlementRate) * 100)}%`, + Number(p.payoutAmount), + p.status, + p.expectedPayAt.toISOString().slice(0, 10), + p.paidAt ? p.paidAt.toISOString().slice(0, 10) : '', + ].join(','), + ); + return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: payouts.length }; + } + + private buildStorePayoutWhere(query: { + status?: string; + storeId?: string; + dateFrom?: string; + dateTo?: string; + }): Prisma.StorePayoutWhereInput { + const where: Prisma.StorePayoutWhereInput = {}; + if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals']; + if (query.storeId) where.storeId = BigInt(query.storeId); + if (query.dateFrom || query.dateTo) { + const createdAt: Prisma.DateTimeFilter = {}; + if (query.dateFrom) createdAt.gte = new Date(`${query.dateFrom}T00:00:00.000`); + if (query.dateTo) createdAt.lte = new Date(`${query.dateTo}T23:59:59.999`); + where.createdAt = createdAt; + } + return where; } async getAdminStorePayout(id: bigint) { @@ -172,24 +343,95 @@ export class SettlementService { pageSize?: number; status?: string; partnerId?: string; + year?: number; + month?: number; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; + const where = this.buildPartnerBillWhere(query); + + const [rawItems, total, aggregates] = await Promise.all([ + this.prisma.partnerBill.findMany({ + where, + orderBy: { periodStart: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + partnerAccount: { + select: { id: true, companyName: true, name: true, phone: true }, + }, + }, + }), + this.prisma.partnerBill.count({ where }), + this.prisma.partnerBill.aggregate({ + where, + _sum: { orderCommission: true, redeemCommission: true, totalAmount: true }, + _count: true, + }), + ]); + + const items = rawItems.map((b) => ({ + ...b, + partner: b.partnerAccount, + })); + + const summary = { + count: aggregates._count, + orderCommission: Number(aggregates._sum.orderCommission ?? 0), + redeemCommission: Number(aggregates._sum.redeemCommission ?? 0), + totalAmount: Number(aggregates._sum.totalAmount ?? 0), + }; + + return serializeBigInt({ items, total, page, pageSize, summary }); + } + + private buildPartnerBillWhere(query: { + status?: string; + partnerId?: string; + year?: number; + month?: number; + }): Prisma.PartnerBillWhereInput { const where: Prisma.PartnerBillWhereInput = {}; 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 }; + } + return where; + } - const [items, total] = await Promise.all([ - this.prisma.partnerBill.findMany({ - where, - orderBy: { createdAt: 'desc' }, - skip: (page - 1) * pageSize, - take: pageSize, - include: { partnerAccount: { select: { companyName: true } } }, - }), - this.prisma.partnerBill.count({ where }), - ]); - return serializeBigInt({ items, total, page, pageSize }); + /** 为所有主合伙人生成指定自然月账单 */ + async generateAllPartnerBills(body: { year: number; month: number }) { + const partners = await this.prisma.partnerAccount.findMany({ + where: { isPrimary: 1, status: 'ACTIVE' }, + select: { id: true, companyName: true }, + }); + const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = []; + for (const p of partners) { + try { + await this.generatePartnerBill({ + partnerId: p.id.toString(), + year: body.year, + month: body.month, + }); + results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true }); + } catch (e) { + results.push({ + partnerId: p.id.toString(), + companyName: p.companyName ?? '', + ok: false, + message: e instanceof Error ? e.message : '失败', + }); + } + } + return { + total: partners.length, + success: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + results, + }; } async getAdminPartnerBill(id: bigint) { @@ -310,31 +552,170 @@ export class SettlementService { return serializeBigInt(updated); } - async exportPartnerBills(query: { partnerId?: string; status?: string }) { - const where: Prisma.PartnerBillWhereInput = {}; - if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); - if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; - + async exportPartnerBills(query: { + partnerId?: string; + status?: string; + year?: number; + month?: number; + }) { + const where = this.buildPartnerBillWhere(query); const bills = await this.prisma.partnerBill.findMany({ where, - include: { partnerAccount: { select: { companyName: true } } }, - orderBy: { createdAt: 'desc' }, + include: { partnerAccount: { select: { companyName: true, phone: true } } }, + orderBy: { periodStart: 'desc' }, }); - const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status'; + const header = [ + '账单号', + '合伙人', + '登录手机', + '账期起', + '账期止', + '酒单佣金', + '核销佣金', + '合计应付', + '状态', + '确认时间', + '打款时间', + ].join(','); const rows = bills.map((b) => [ - b.billNo, - b.partnerAccount.companyName, + csvEscape(b.billNo), + csvEscape(b.partnerAccount.companyName ?? ''), + csvEscape(b.partnerAccount.phone ?? ''), b.periodStart.toISOString().slice(0, 10), b.periodEnd.toISOString().slice(0, 10), Number(b.orderCommission), Number(b.redeemCommission), Number(b.totalAmount), b.status, + b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '', + b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '', ].join(','), ); - return { csv: [header, ...rows].join('\n'), count: bills.length }; + return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; + } + + /** 酒厂账单:T+30,按自然月列出已付酒单,应付 = 实付 × WINERY_SETTLEMENT_RATE */ + async listAdminWineryBills(query: { + page?: number; + pageSize?: number; + year?: number; + month?: number; + deliveryType?: string; + }) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const where = this.buildWineryOrderWhere(query); + + const [orders, total, aggregates] = await Promise.all([ + this.prisma.order.findMany({ + where, + orderBy: { paidAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { city: { select: { name: true } } }, + }), + this.prisma.order.count({ where }), + this.prisma.order.aggregate({ + where, + _sum: { payAmount: true }, + _count: true, + }), + ]); + + const rate = WINERY_SETTLEMENT_RATE; + const items = orders.map((o) => { + const payAmount = Number(o.payAmount); + const wineryAmount = Math.round(payAmount * rate * 100) / 100; + return { + orderId: o.id.toString(), + orderNo: o.orderNo, + deliveryType: o.deliveryType, + cityName: o.city?.name, + payAmount, + wineryRate: rate, + wineryAmount, + paidAt: o.paidAt?.toISOString() ?? '', + receiverCity: o.receiverCity, + productName: o.productName, + quantity: o.quantity, + }; + }); + + const orderAmount = Number(aggregates._sum.payAmount ?? 0); + const summary = { + count: aggregates._count, + orderAmount, + wineryAmount: Math.round(orderAmount * rate * 100) / 100, + totalAmount: Math.round(orderAmount * rate * 100) / 100, + }; + + return { items, total, page, pageSize, summary }; + } + + async exportAdminWineryBills(query: { + year?: number; + month?: number; + deliveryType?: string; + }) { + const where = this.buildWineryOrderWhere(query); + const orders = await this.prisma.order.findMany({ + where, + orderBy: { paidAt: 'desc' }, + include: { city: { select: { name: true } } }, + }); + const rate = WINERY_SETTLEMENT_RATE; + const header = [ + '订单号', + '配送类型', + '开城城市', + '收货城市', + '商品', + '数量', + '实付金额', + '酒厂比例', + '酒厂应付', + '支付时间', + ].join(','); + const rows = orders.map((o) => { + const payAmount = Number(o.payAmount); + return [ + csvEscape(o.orderNo), + o.deliveryType === 'LOCAL' ? '同城' : o.deliveryType === 'CROSS_CITY' ? '跨城' : o.deliveryType, + csvEscape(o.city?.name ?? ''), + csvEscape(o.receiverCity ?? ''), + csvEscape(o.productName ?? ''), + o.quantity, + payAmount, + `${Math.round(rate * 100)}%`, + Math.round(payAmount * rate * 100) / 100, + o.paidAt ? o.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', + ].join(','); + }); + return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: orders.length }; + } + + private buildWineryOrderWhere(query: { + year?: number; + month?: number; + deliveryType?: string; + }): Prisma.OrderWhereInput { + const where: Prisma.OrderWhereInput = { + payStatus: 'PAID', + deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, + }; + if (query.deliveryType === 'LOCAL' || query.deliveryType === 'CROSS_CITY') { + where.deliveryType = query.deliveryType; + } + 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.paidAt = { gte: start, lte: end }; + } else { + where.paidAt = { not: null }; + } + return where; } async scanDueStorePayouts() {