-
合伙人结算(T+30)
-
-
-
-
-
-
+
+
+ 合伙人账单
+
+
+ T+30 结算:按自然月汇总酒单佣金(酒单价 × 酒单佣金比例)与门店核销佣金(核销额 × 核销佣金比例)
+
+
+
+ {summary && (
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
- { setPage(p); setPageSize(ps); } }} />
- setGenOpen(false)} footer={null}>
-
-
-
+
+ {
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+ setGenOpen(false)} footer={null}>
+
+
+
+
+
+
diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx
index 15e96ea..13b5ab2 100644
--- a/apps/admin-web/src/pages/StoreBillsPage.tsx
+++ b/apps/admin-web/src/pages/StoreBillsPage.tsx
@@ -1,8 +1,24 @@
import { useEffect, useState } from 'react';
-import { Button, Descriptions, Drawer, Form, Select, Space, Table, Tag, Typography, message } from 'antd';
+import {
+ Button,
+ Card,
+ DatePicker,
+ Descriptions,
+ Drawer,
+ Form,
+ Select,
+ Space,
+ Statistic,
+ Table,
+ Tag,
+ Typography,
+ message,
+} from 'antd';
import type { ColumnsType } from 'antd/es/table';
+import 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,7 +31,7 @@ type Row = {
paidAt?: string;
createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string };
- redeemRecord?: { redeemNo: string; amount?: number };
+ redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
};
type StoreOption = { id: string; name: string; phone: string };
@@ -40,12 +56,15 @@ export default function StoreBillsPage() {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
+ if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
+ if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
+ const [exporting, setExporting] = useState(false);
useEffect(() => {
void request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -62,7 +81,33 @@ export default function StoreBillsPage() {
reload();
}
+ async function exportExcel() {
+ setExporting(true);
+ try {
+ const qs = new URLSearchParams();
+ if (filters.status) qs.set('status', filters.status);
+ if (filters.storeId) qs.set('storeId', filters.storeId);
+ if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
+ if (filters.dateTo) qs.set('dateTo', filters.dateTo);
+ const result = await request<{ csv: string; count: number }>(
+ `/admin/store-payouts/export?${qs}`,
+ );
+ downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
+ message.success(`已导出 ${result.count} 条`);
+ } finally {
+ setExporting(false);
+ }
+ }
+
+ const summary = data?.summary;
+
const columns: ColumnsType = [
+ {
+ title: '核销日期',
+ width: 110,
+ render: (_, r) =>
+ (r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
+ },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
@@ -75,10 +120,16 @@ export default function StoreBillsPage() {
},
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{
- title: '到账金额',
+ title: '核销比例',
+ dataIndex: 'settlementRate',
+ width: 90,
+ render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
+ },
+ {
+ title: '应付门店',
dataIndex: 'payoutAmount',
width: 100,
- render: (v, row) => `¥${v}${row.settlementRate ? ` (${Math.round(row.settlementRate * 100)}%)` : ''}`,
+ render: (v) => `¥${v}`,
},
{
title: '打款状态',
@@ -86,8 +137,7 @@ export default function StoreBillsPage() {
width: 100,
render: (s) => {PAYOUT_STATUS_LABELS[s] || s},
},
- { title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
- { title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
+ { title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{
title: '操作',
width: 140,
@@ -117,17 +167,39 @@ export default function StoreBillsPage() {
return (
- 门店核销账单
+
+ 门店账单
+
- 每笔核销对应一条 T+1 打款账单,可按门店筛选查看
+ T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例
+
+ {summary && (
+
+
+
+
+
+
+
+ )}
+
-
+
+
-
+
+
+
+
+
+
+
+
+
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 && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ 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() {