From 1000b489a00137cd034eebb14aaaf77c2643d983 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 2 Sep 2026 11:29:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?v4.0.10=E7=89=88=E6=9C=ACpackage.json?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index 20db33b..2c99b68 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -1,6 +1,6 @@ { "name": "@dukang/mini-user", - "version": "4.0.4", + "version": "4.0.10", "private": true, "description": "杜康好客 · C 端用户微信小程序(Taro)", "scripts": { From 6880b5daf48a7d384b996597ca54533cb57df59a Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 2 Sep 2026 15:45:12 +0800 Subject: [PATCH 2/2] =?UTF-8?q?v4.0.11HQ=E5=90=8E=E7=AB=AF=E4=BC=98?= =?UTF-8?q?=E5=8C=96=EF=BC=88=E5=BF=AB=E9=93=BE=E3=80=81=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E6=97=A5=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/HqAccountSettingsModal.tsx | 88 +++ apps/admin-web/src/layouts/AdminLayout.tsx | 11 + apps/admin-web/src/lib/api.ts | 1 + apps/admin-web/src/lib/export-columns.ts | 8 + .../admin-web/src/lib/useAdminListColumns.tsx | 13 +- apps/admin-web/src/pages/DevPlanTasksPage.tsx | 102 ++- .../src/pages/DevPlanVersionsPage.tsx | 4 + .../src/pages/LogisticsBillsPage.tsx | 33 +- apps/admin-web/src/pages/OrdersPage.tsx | 25 +- apps/admin-web/src/pages/PartnerBillsPage.tsx | 45 +- apps/admin-web/src/pages/StoreBillsPage.tsx | 38 +- apps/admin-web/src/pages/WineryBillsPage.tsx | 52 +- packages/shared-types/src/hq-permissions.ts | 10 + packages/shared-types/src/ops.ts | 8 + packages/shared-types/src/settlement.ts | 10 + .../migrate-hq-role-developer-v4011.sql | 23 + server/dukang-api/prisma/schema.prisma | 1 + .../src/common/export/column-export.util.ts | 48 ++ .../dev-plan/admin-dev-plan-export.util.ts | 48 +- .../dev-plan/admin-dev-plan.controller.ts | 2 + .../src/modules/dev-plan/dev-plan.service.ts | 25 +- .../src/modules/dev-plan/dto/dev-plan.dto.ts | 5 + .../src/modules/iam/admin-me.controller.ts | 7 +- .../src/modules/iam/auth.service.ts | 46 +- .../src/modules/iam/dto/auth.dto.ts | 16 + .../modules/ops/admin-order-export.util.ts | 142 ++--- .../src/modules/ops/admin-orders.service.ts | 4 +- .../src/modules/ops/dto/admin-mutate.dto.ts | 4 +- .../src/modules/ops/dto/admin-query.dto.ts | 6 + .../settlement/settlement.controller.ts | 4 + .../modules/settlement/settlement.service.ts | 587 ++++++++++-------- 31 files changed, 1014 insertions(+), 402 deletions(-) create mode 100644 apps/admin-web/src/components/HqAccountSettingsModal.tsx create mode 100644 apps/admin-web/src/lib/export-columns.ts create mode 100644 server/dukang-api/prisma/migrate-hq-role-developer-v4011.sql create mode 100644 server/dukang-api/src/common/export/column-export.util.ts diff --git a/apps/admin-web/src/components/HqAccountSettingsModal.tsx b/apps/admin-web/src/components/HqAccountSettingsModal.tsx new file mode 100644 index 0000000..be58851 --- /dev/null +++ b/apps/admin-web/src/components/HqAccountSettingsModal.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; +import { Form, Input, Modal, message } from 'antd'; +import type { UpdateMyHqCredentialsRequest } from '@dukang/shared-types'; +import { request, type HqProfile } from '../lib/api'; + +type Props = { + open: boolean; + profile: HqProfile | null; + onClose: () => void; + onUpdated: (profile: HqProfile) => void; +}; + +export function HqAccountSettingsModal({ open, profile, onClose, onUpdated }: Props) { + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + form.setFieldsValue({ + loginName: profile?.loginName ?? '', + oldPassword: '', + newPassword: '', + confirmPassword: '', + }); + }, [open, profile, form]); + + async function submit() { + const values = await form.validateFields(); + if (values.newPassword && values.newPassword !== values.confirmPassword) { + message.error('两次输入的新密码不一致'); + return; + } + const body: UpdateMyHqCredentialsRequest = {}; + const nextLogin = values.loginName?.trim(); + if (nextLogin && nextLogin !== (profile?.loginName ?? '')) { + body.loginName = nextLogin; + } + if (values.newPassword?.trim()) { + body.newPassword = values.newPassword.trim(); + if (values.oldPassword?.trim()) body.oldPassword = values.oldPassword.trim(); + } + if (!body.loginName && !body.newPassword) { + message.warning('请填写要修改的内容'); + return; + } + setSaving(true); + try { + await request('/admin/me/credentials', { + method: 'PUT', + body: JSON.stringify(body), + }); + const updated = await request('/admin/auth/me'); + message.success('账号信息已更新'); + onUpdated(updated); + onClose(); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setSaving(false); + } + } + + return ( + void submit()} + confirmLoading={saving} + destroyOnClose + > +
+ + + + + + + + + + + + +
+
+ ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 36a4d30..87b123c 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -30,6 +30,7 @@ import { clearAuth, request, type HqProfile } from '../lib/api'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events'; import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext'; +import { HqAccountSettingsModal } from '../components/HqAccountSettingsModal'; const { Header, Sider, Content } = Layout; @@ -292,6 +293,7 @@ export default function AdminLayout() { const contentRef = useRef(null); const [profile, setProfile] = useState(null); const [auditPendingCount, setAuditPendingCount] = useState(0); + const [accountSettingsOpen, setAccountSettingsOpen] = useState(false); function refreshAuditPendingCount() { Promise.all([ @@ -419,11 +421,20 @@ export default function AdminLayout() { {HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''} + + setAccountSettingsOpen(false)} + onUpdated={setProfile} + />
( ); itemsRef.current = items; + const exportColumnKeys = useMemo(() => { + const keyed = (allColumns as ColumnType[]).map((col, i) => ({ + col, + key: columnKey(col, i), + actions: col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions', + })); + const defaultKeys = keyed.filter((c) => !c.actions).map((c) => c.key); + return visibleColumnKeys(defaultKeys, pref); + }, [allColumns, pref]); + const serialCol: ColumnType = useMemo( () => ({ key: SERIAL_COLUMN_KEY, @@ -171,5 +182,5 @@ export function useAdminListColumns( /> ); - return { columns, settingsButton, settingsModal }; + return { columns, settingsButton, settingsModal, exportColumnKeys }; } diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx index dfeac61..b46f9fb 100644 --- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx +++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx @@ -29,6 +29,7 @@ import { import { request } from '../lib/api'; import { fmtTime } from '../lib/constants'; import { downloadBase64File } from '../lib/exportExcel'; +import { exportColumnsBody } from '../lib/export-columns'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; import { useAdminListColumns } from '../lib/useAdminListColumns'; @@ -62,6 +63,8 @@ export default function DevPlanTasksPage() { const [dispatchOpen, setDispatchOpen] = useState(false); const [batchEditOpen, setBatchEditOpen] = useState(false); const [batchSaving, setBatchSaving] = useState(false); + const [creatingVersion, setCreatingVersion] = useState(false); + const [newVersionNo, setNewVersionNo] = useState(''); const [versions, setVersions] = useState([]); const [supportTickets, setSupportTickets] = useState([]); const [editing, setEditing] = useState(null); @@ -172,9 +175,34 @@ export default function DevPlanTasksPage() { function openBatchEdit() { batchForm.setFieldsValue({ status: undefined, versionId: undefined }); + setNewVersionNo(''); setBatchEditOpen(true); } + async function createVersionInBatch() { + const versionNo = newVersionNo.trim(); + if (!versionNo) { + message.warning('请输入版本号'); + return; + } + setCreatingVersion(true); + try { + const created = await request('/admin/dev-plan/versions', { + method: 'POST', + body: JSON.stringify({ versionNo }), + }); + const res = await request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100'); + setVersions(res.items ?? []); + batchForm.setFieldsValue({ versionId: created.id }); + setNewVersionNo(''); + message.success(`已创建版本 ${versionNo}`); + } catch (e) { + message.error(e instanceof Error ? e.message : '创建失败'); + } finally { + setCreatingVersion(false); + } + } + async function submitBatchEdit() { const values = await batchForm.validateFields(); if (!values.status && !values.versionId) { @@ -224,34 +252,6 @@ export default function DevPlanTasksPage() { } } - async function exportTasks(scope: 'filter' | 'selected') { - setExporting(true); - try { - const result = await request<{ - filename: string; - mimeType: string; - contentBase64: string; - count: number; - }>('/admin/dev-plan/tasks/export', { - method: 'POST', - body: JSON.stringify({ - scope, - format: exportFormat, - ids: scope === 'selected' ? selectedRowKeys : undefined, - status: filters.status || undefined, - type: filters.type || undefined, - keyword: filters.keyword || undefined, - }), - }); - downloadBase64File(result.contentBase64, result.filename, result.mimeType); - message.success(`已导出 ${result.count} 条任务`); - } catch (e) { - message.error(e instanceof Error ? e.message : '导出失败'); - } finally { - setExporting(false); - } - } - const baseColumns: ColumnsType = [ { title: '任务号', @@ -285,6 +285,7 @@ export default function DevPlanTasksPage() { }, { title: '附件', + key: 'attachmentUrls', width: 100, render: (_, row) => row.attachmentUrls?.length ? ( @@ -314,6 +315,7 @@ export default function DevPlanTasksPage() { }, { title: '关联版本', + key: 'versions', dataIndex: 'versions', width: 140, render: (_, row) => @@ -347,7 +349,36 @@ export default function DevPlanTasksPage() { }, ]; - const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize }); + const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize }); + + async function exportTasks(scope: 'filter' | 'selected') { + setExporting(true); + try { + const result = await request<{ + filename: string; + mimeType: string; + contentBase64: string; + count: number; + }>('/admin/dev-plan/tasks/export', { + method: 'POST', + body: JSON.stringify({ + scope, + format: exportFormat, + ids: scope === 'selected' ? selectedRowKeys : undefined, + status: filters.status || undefined, + type: filters.type || undefined, + keyword: filters.keyword || undefined, + ...exportColumnsBody(exportColumnKeys), + }), + }); + downloadBase64File(result.contentBase64, result.filename, result.mimeType); + message.success(`已导出 ${result.count} 条任务`); + } catch (e) { + message.error(e instanceof Error ? e.message : '导出失败'); + } finally { + setExporting(false); + } + } return (
@@ -541,6 +572,19 @@ export default function DevPlanTasksPage() { options={versions.map((v) => ({ value: v.id, label: v.versionNo }))} /> + + + setNewVersionNo(e.target.value)} + onPressEnter={() => void createVersionInBatch()} + /> + + +
diff --git a/apps/admin-web/src/pages/DevPlanVersionsPage.tsx b/apps/admin-web/src/pages/DevPlanVersionsPage.tsx index 90127a2..e3a2128 100644 --- a/apps/admin-web/src/pages/DevPlanVersionsPage.tsx +++ b/apps/admin-web/src/pages/DevPlanVersionsPage.tsx @@ -57,6 +57,7 @@ export default function DevPlanVersionsPage() { useAdminList('/admin/dev-plan/versions', () => { const qs = new URLSearchParams(); if (filters.status) qs.set('status', filters.status); + if (filters.keyword) qs.set('keyword', filters.keyword); return qs; }, [filters]); @@ -190,6 +191,9 @@ export default function DevPlanVersionsPage() { + diff --git a/apps/admin-web/src/pages/LogisticsBillsPage.tsx b/apps/admin-web/src/pages/LogisticsBillsPage.tsx index f2582f6..4209475 100644 --- a/apps/admin-web/src/pages/LogisticsBillsPage.tsx +++ b/apps/admin-web/src/pages/LogisticsBillsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button, Card, @@ -27,6 +28,7 @@ import { } from '@dukang/shared-types'; import { fmtTime } from '../lib/constants'; import { downloadExcelCsv } from '../lib/exportExcel'; +import { appendExportColumns } from '../lib/export-columns'; import { request } from '../lib/api'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; @@ -98,6 +100,7 @@ const STATUS_COLORS: Record = { }; export default function LogisticsBillsPage() { + const navigate = useNavigate(); const [form] = Form.useForm(); const [summaryForm] = Form.useForm(); const [filters, setFilters] = useState>({}); @@ -202,6 +205,7 @@ export default function LogisticsBillsPage() { if (filters.providerId) qs.set('providerId', filters.providerId); if (filters.year) qs.set('year', filters.year); if (filters.month) qs.set('month', filters.month); + appendExportColumns(qs, exportColumnKeys); const result = await request<{ csv: string; count: number }>( `/admin/logistics-bills/export?${qs}`, ); @@ -250,6 +254,7 @@ export default function LogisticsBillsPage() { const billColumns: ColumnsType = [ { title: '账单号', + key: '账单号', dataIndex: 'billNo', width: 170, render: (v, row) => ( @@ -258,42 +263,49 @@ export default function LogisticsBillsPage() { }, { title: '承运商', + key: '承运商', width: 140, render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`, }, { title: '账期', + key: '账期', width: 200, render: (_, r) => `${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`, }, { title: '结算方式', + key: '结算方式', dataIndex: 'settlementMethod', width: 100, render: (v) => LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v, }, - { title: '订单数', dataIndex: 'orderCount', width: 80 }, - { title: '瓶数', dataIndex: 'bottleCount', width: 80 }, + { title: '订单数', key: '订单数', dataIndex: 'orderCount', width: 80 }, + { title: '瓶数', key: '瓶数', dataIndex: 'bottleCount', width: 80 }, { title: '物流费', + key: '物流费', dataIndex: 'logisticsAmount', width: 110, render: (v) => `¥${Number(v).toFixed(2)}`, }, { title: '收款户名', + key: '收款户名', width: 100, render: (_, r) => providerBank(r)?.bankAccountName || '—', }, { title: '收款账号', + key: '收款账号', width: 140, render: (_, r) => providerBank(r)?.bankAccountNo || '—', }, { title: '状态', + key: '状态', dataIndex: 'status', width: 90, render: (s) => {STATUS_LABELS[s] || s}, @@ -398,7 +410,7 @@ export default function LogisticsBillsPage() { }, ]; - const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, { + const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-logistics-bills', billColumns, { page, pageSize, }); @@ -627,7 +639,20 @@ export default function LogisticsBillsPage() { pagination={false} dataSource={detail.items ?? []} columns={[ - { title: '订单号', dataIndex: 'orderNo' }, + { + title: '订单号', + dataIndex: 'orderNo', + render: (v: string) => + v ? ( + navigate(`/orders?orderNo=${encodeURIComponent(v)}`)} + > + {v} + + ) : ( + '—' + ), + }, { title: '瓶数', dataIndex: 'quantity', width: 70 }, { title: '物流费', diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 61009a9..fbf7de5 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -29,6 +29,7 @@ import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { downloadBase64File } from '../lib/exportExcel'; +import { exportColumnsBody } from '../lib/export-columns'; import { ADMIN_OPTIONS_PAGE_SIZE, DELIVERY_PROVIDER_LABELS, @@ -265,8 +266,9 @@ function buildExportPayload( format: OrderExportFormat, filters: OrderExportFilters, selectedIds: string[], + columns?: string[], ) { - const payload: Record = { scope, format }; + const payload: Record = { scope, format, ...exportColumnsBody(columns ?? []) }; if (scope === 'selected') { payload.ids = selectedIds; return payload; @@ -671,6 +673,7 @@ export default function OrdersPage() { status: selectedStatuses(values.status).length ? values.status : initialStatuses, }, selectedRowKeys, + exportColumnKeys, ); const result = await request('/admin/orders/export', { method: 'POST', @@ -688,6 +691,7 @@ export default function OrdersPage() { const baseColumns: ColumnsType = [ { title: '订单号', + key: '订单号', dataIndex: 'orderNo', width: 180, render: (v, row) => ( @@ -699,7 +703,7 @@ export default function OrdersPage() { }, { title: '用户', - key: 'user', + key: '用户', width: 200, render: (_, row) => row.user?.id ? ( @@ -712,7 +716,7 @@ export default function OrdersPage() { }, { title: '商品', - key: 'productName', + key: '商品', width: 180, render: (_, row) => ( @@ -726,12 +730,14 @@ export default function OrdersPage() { }, { title: '规格', + key: '规格', dataIndex: 'productSpec', width: 140, render: (v: string | undefined) => v || '—', }, { title: '数量', + key: '数量', dataIndex: 'quantity', width: 80, render: (v: number | undefined, row) => @@ -739,12 +745,14 @@ export default function OrdersPage() { }, { title: '配送方式', + key: '配送方式', dataIndex: 'deliveryType', width: 100, render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—', }, { title: '状态', + key: '状态', dataIndex: 'status', width: 100, render: (s: string) => ( @@ -753,42 +761,47 @@ export default function OrdersPage() { }, { title: '实付', + key: '实付', dataIndex: 'payAmount', width: 90, render: (v: number) => `¥${v}`, }, { title: '运费', - key: 'logisticsFee', + key: '运费', width: 90, render: (_, row) => row.delivery?.logisticsFee == null ? '—' : `¥${Number(row.delivery.logisticsFee).toFixed(2)}`, }, { title: '好客权益', + key: '好客权益', width: 200, render: (_, row) => formatBenefitBrief(row), }, { title: '收货人', + key: '收货人', dataIndex: 'receiverName', width: 90, render: (v: string | undefined) => v || '—', }, { title: '电话', + key: '电话', dataIndex: 'receiverPhone', width: 120, render: (v: string | undefined) => v || '—', }, { title: '地址', - key: 'receiverAddress', + key: '地址', width: 260, render: (_, row) => formatReceiverAddress(row) || '—', }, { title: '下单时间', + key: '下单时间', dataIndex: 'createdAt', width: 170, render: fmtTime, @@ -823,7 +836,7 @@ export default function OrdersPage() { }, ]; - const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, { + const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('orders', baseColumns, { page, pageSize, }); diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx index b65d6f1..515b5e2 100644 --- a/apps/admin-web/src/pages/PartnerBillsPage.tsx +++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button, Card, @@ -14,6 +15,7 @@ import { Table, Tabs, Tag, + Tooltip, Typography, message, } from 'antd'; @@ -22,6 +24,7 @@ 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 { appendExportColumns } from '../lib/export-columns'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; @@ -48,6 +51,7 @@ type Row = { redeemCommission: number; totalAmount: number; status: string; + billDate: string; periodStart: string; periodEnd: string; rejectReason?: string | null; @@ -109,6 +113,7 @@ function weekMonday(d: Dayjs) { } export default function PartnerBillsPage() { + const navigate = useNavigate(); const [form] = Form.useForm(); const [filters, setFilters] = useState>({}); const [partners, setPartners] = useState([]); @@ -263,6 +268,7 @@ export default function PartnerBillsPage() { if (filters.status) qs.set('status', filters.status); if (filters.partnerId) qs.set('partnerId', filters.partnerId); if (filters.weekStartYmd) qs.set('weekStartYmd', filters.weekStartYmd); + appendExportColumns(qs, exportColumnKeys); const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`); const suffix = filters.weekStartYmd || 'all'; downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`); @@ -285,6 +291,7 @@ export default function PartnerBillsPage() { const baseColumns: ColumnsType = [ { title: '账单号', + key: '账单号', dataIndex: 'billNo', width: 180, render: (v, row) => ( @@ -293,29 +300,45 @@ export default function PartnerBillsPage() { }, { title: '合伙人', + key: '合伙人', width: 200, render: (_, r) => partnerName(r), }, + { + title: ( + + 出账日 + + ), + key: '出账日', + dataIndex: 'billDate', + width: 110, + render: (v) => String(v || '').slice(0, 10), + }, { title: '账期', + key: '账期', width: 200, render: (_, r) => `${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`, }, { title: '酒单佣金', + key: '酒单佣金', dataIndex: 'orderCommission', width: 110, render: (v) => `¥${Number(v).toFixed(2)}`, }, { title: '核销佣金', + key: '核销佣金', dataIndex: 'redeemCommission', width: 110, render: (v) => `¥${Number(v).toFixed(2)}`, }, { title: '合计应付', + key: '合计应付', dataIndex: 'totalAmount', width: 130, render: (v) => @@ -323,16 +346,19 @@ export default function PartnerBillsPage() { }, { title: '收款户名', + key: '收款户名', width: 100, render: (_, r) => partnerBank(r)?.bankAccountName || '—', }, { title: '收款账号', + key: '收款账号', width: 140, render: (_, r) => partnerBank(r)?.bankAccountNo || '—', }, { title: '状态', + key: '状态', dataIndex: 'status', width: 130, render: (s, row) => ( @@ -380,7 +406,7 @@ export default function PartnerBillsPage() { }, ]; - const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize }); + const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize }); return (
@@ -572,6 +598,7 @@ export default function PartnerBillsPage() { {detail.billNo} {partnerName(detail)} + {String(detail.billDate || '').slice(0, 10)} {String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)} @@ -610,7 +637,21 @@ export default function PartnerBillsPage() { pagination={false} dataSource={detail.orderItems ?? []} columns={[ - { title: '订单号', dataIndex: 'refNo', width: 160 }, + { + title: '订单号', + dataIndex: 'refNo', + width: 160, + render: (v: string) => + v ? ( + navigate(`/orders?orderNo=${encodeURIComponent(v)}`)} + > + {v} + + ) : ( + '—' + ), + }, { title: '商品', dataIndex: 'title' }, { title: '数量', dataIndex: 'extra', width: 70 }, { diff --git a/apps/admin-web/src/pages/StoreBillsPage.tsx b/apps/admin-web/src/pages/StoreBillsPage.tsx index e27b449..8885ded 100644 --- a/apps/admin-web/src/pages/StoreBillsPage.tsx +++ b/apps/admin-web/src/pages/StoreBillsPage.tsx @@ -31,6 +31,7 @@ import { import { request, type Paginated } from '../lib/api'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { downloadExcelCsv } from '../lib/exportExcel'; +import { appendExportColumns } from '../lib/export-columns'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; import { AdminListHeader } from '../components/AdminListHeader'; @@ -44,6 +45,8 @@ type Row = { amount: number; status: StoreSettlementStatus; date: string; + periodStart?: string | null; + periodEnd?: string | null; overdue?: boolean; redeemCount?: number | null; redeemAmount?: number | null; @@ -198,6 +201,7 @@ export default function StoreBillsPage() { if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.dateTo) qs.set('dateTo', filters.dateTo); + appendExportColumns(qs, exportColumnKeys); const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`); downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); message.success(`已导出 ${result.count} 条 T+1 账单`); @@ -218,6 +222,7 @@ export default function StoreBillsPage() { const baseColumns: ColumnsType = [ { title: '类型', + key: '类型', dataIndex: 'kind', width: 100, render: (k: StoreSettlementKind) => ( @@ -228,6 +233,7 @@ export default function StoreBillsPage() { }, { title: '单号', + key: '账单号', dataIndex: 'billNo', width: 180, render: (v, row) => ( @@ -243,39 +249,58 @@ export default function StoreBillsPage() { 出账日 ), + key: '出账日', dataIndex: 'date', width: 160, render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), }, - { title: '门店', dataIndex: ['store', 'name'], width: 140 }, - { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, - { title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, + { + title: ( + + 账期 + + ), + key: '账期', + width: 200, + render: (_, row) => + row.kind === 'T1_BILL' && row.periodStart + ? `${row.periodStart} ~ ${row.periodEnd}` + : '—', + }, + { title: '门店', key: '门店', dataIndex: ['store', 'name'], width: 140 }, + { title: '登录手机', key: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, + { title: '城市', key: '城市', dataIndex: ['store', 'cityName'], width: 90 }, { title: '收款户名', + key: '收款户名', width: 100, render: (_, row) => row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—', }, { title: '收款账号', + key: '收款账号', width: 140, render: (_, row) => row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—', }, { title: '笔数', + key: '笔数', width: 80, render: (_, row) => row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'), }, { title: '应付金额', + key: '应付金额', dataIndex: 'amount', width: 110, render: (v) => `¥${Number(v).toFixed(2)}`, }, { title: '状态', + key: '状态', dataIndex: 'status', width: 100, render: (s: string, row) => ( @@ -325,7 +350,7 @@ export default function StoreBillsPage() { } | undefined; - const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize }); + const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize }); return (
@@ -495,6 +520,11 @@ export default function StoreBillsPage() { {String(detail.billDate || '').slice(0, 10)} + + {detail.periodStart + ? `${String(detail.periodStart).slice(0, 10)} ~ ${String(detail.periodEnd || '').slice(0, 10)}` + : '—'} + ¥{Number(detail.payoutAmount ?? 0).toFixed(2)} diff --git a/apps/admin-web/src/pages/WineryBillsPage.tsx b/apps/admin-web/src/pages/WineryBillsPage.tsx index a956dba..9e27e34 100644 --- a/apps/admin-web/src/pages/WineryBillsPage.tsx +++ b/apps/admin-web/src/pages/WineryBillsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button, Card, @@ -26,6 +27,7 @@ import { } from '@dukang/shared-types'; import { fmtTime } from '../lib/constants'; import { downloadExcelCsv } from '../lib/exportExcel'; +import { appendExportColumns } from '../lib/export-columns'; import { request, type HqProfile } from '../lib/api'; import { useAdminList } from '../lib/useAdminList'; import { useAdminListColumns } from '../lib/useAdminListColumns'; @@ -37,6 +39,8 @@ type Row = { id: string; billNo: string; billDate: string; + periodStart: string; + periodEnd: string; orderCount: number; orderAmount: number; wineryRate: number; @@ -107,6 +111,7 @@ const WINERY_BANK_KEYS = [ ] as const; export default function WineryBillsPage() { + const navigate = useNavigate(); const [form] = Form.useForm(); const [bankForm] = Form.useForm>(); const [reconcileForm] = Form.useForm<{ range?: [Dayjs, Dayjs] }>(); @@ -196,6 +201,7 @@ export default function WineryBillsPage() { if (filters.month) qs.set('month', filters.month); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.dateTo) qs.set('dateTo', filters.dateTo); + appendExportColumns(qs, exportColumnKeys); 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`); @@ -275,6 +281,7 @@ export default function WineryBillsPage() { const baseColumns: ColumnsType = [ { title: '账单号', + key: '账单号', dataIndex: 'billNo', width: 170, render: (v, row) => ( @@ -284,34 +291,49 @@ export default function WineryBillsPage() { { title: ( - 账单日 + 出账日 ), + key: '出账日', dataIndex: 'billDate', width: 110, render: (v) => String(v || '').slice(0, 10), }, - { title: '订单数', dataIndex: 'orderCount', width: 80 }, { - title: '酒单实付合计', + title: ( + + 账期 + + ), + key: '账期', + width: 200, + render: (_, row) => `${row.periodStart} ~ ${row.periodEnd}`, + }, + { title: '订单数', key: '订单数', dataIndex: 'orderCount', width: 80 }, + { + title: '订单总额', + key: '订单总额', dataIndex: 'orderAmount', width: 120, render: (v) => `¥${Number(v).toFixed(2)}`, }, { title: '酒厂比例', + key: '酒厂比例', dataIndex: 'wineryRate', width: 90, render: (v) => `${Math.round(Number(v) * 100)}%`, }, { title: '应付', + key: '应付', dataIndex: 'wineryAmount', width: 110, render: (v) => `¥${Number(v).toFixed(2)}`, }, { title: '状态', + key: '状态', dataIndex: 'status', width: 100, render: (s, row) => { @@ -338,7 +360,7 @@ export default function WineryBillsPage() { }, ]; - const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize }); + const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize }); return (
@@ -365,7 +387,7 @@ export default function WineryBillsPage() { - + @@ -460,7 +482,10 @@ export default function WineryBillsPage() { <> {detail.billNo} - {String(detail.billDate).slice(0, 10)} + {String(detail.billDate).slice(0, 10)} + + {detail.periodStart} ~ {detail.periodEnd} + ¥{Number(detail.wineryAmount).toFixed(2)} {displayWineryStatus(detail.status, detail.wineryAmount).label} @@ -500,7 +525,20 @@ export default function WineryBillsPage() { pagination={false} dataSource={detail.items ?? []} columns={[ - { title: '订单号', dataIndex: 'orderNo' }, + { + title: '订单号', + dataIndex: 'orderNo', + render: (v: string) => + v ? ( + navigate(`/orders?orderNo=${encodeURIComponent(v)}`)} + > + {v} + + ) : ( + '—' + ), + }, { title: '配送', dataIndex: 'deliveryType', diff --git a/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts index 1473abf..c8bca97 100644 --- a/packages/shared-types/src/hq-permissions.ts +++ b/packages/shared-types/src/hq-permissions.ts @@ -149,6 +149,7 @@ export const HQ_ADMIN_ROLE_VALUES = [ 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE', + 'DEVELOPER', ] as const; export type HqAdminRoleValue = (typeof HQ_ADMIN_ROLE_VALUES)[number]; @@ -159,6 +160,7 @@ export const HQ_ADMIN_ROLES: { value: HqAdminRoleValue; label: string }[] = [ { value: 'FINANCE', label: '财务' }, { value: 'CUSTOMER_SERVICE', label: '客服' }, { value: 'CITY_STORE_SERVICE', label: '城市门店服务' }, + { value: 'DEVELOPER', label: '开发者' }, ]; const OPS_STORE_KEYS: HqPermissionKey[] = [ @@ -170,6 +172,13 @@ const OPS_STORE_KEYS: HqPermissionKey[] = [ 'store_media', ]; +/** 开发者默认:除权限分配、HQ 账户外全部权限 */ +function developerDefaultPermissions(): HqPermissionKey[] { + return HQ_PERMISSION_CATALOG.map((p) => p.key).filter( + (k) => k !== 'hq_permissions' && k !== 'hq_accounts', + ); +} + export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { SUPER_ADMIN: [...hqBasePermissionKeys(), ...HQ_DEBUG_PERMISSION_KEYS], OPS: [ @@ -228,4 +237,5 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record { items: T[]; total: number; diff --git a/packages/shared-types/src/settlement.ts b/packages/shared-types/src/settlement.ts index c3284ed..95f009e 100644 --- a/packages/shared-types/src/settlement.ts +++ b/packages/shared-types/src/settlement.ts @@ -139,6 +139,8 @@ export interface PartnerBillDto { redeemCommission: number; totalAmount: number; status: PartnerBillStatus; + /** 出账日 YYYY-MM-DD(周账=账期结束后下周一;历史月账=次月 1 日) */ + billDate: string; periodStart: string; periodEnd: string; confirmedAt?: string | null; @@ -187,6 +189,10 @@ export interface StoreBillDto { storeId: string; /** 出账日 YYYY-MM-DD(T+1 窗口「昨日 00:00–今日 00:00」中的今天) */ billDate: string; + /** 账期起 YYYY-MM-DD(含;T+1 = 出账日前一日) */ + periodStart: string; + /** 账期止 YYYY-MM-DD(含;T+1 = 出账日前一日) */ + periodEnd: string; redeemCount: number; redeemAmount: number; settlementRate: number; @@ -202,6 +208,10 @@ export interface WineryBillDto { billNo: string; /** 出账日 YYYY-MM-DD(北京时间;T+3 = 每 3 天一期,纳入上期 3 天完成订单) */ billDate: string; + /** 账期起 YYYY-MM-DD(含) */ + periodStart: string; + /** 账期止 YYYY-MM-DD(含) */ + periodEnd: string; orderCount: number; orderAmount: number; wineryRate: number; diff --git a/server/dukang-api/prisma/migrate-hq-role-developer-v4011.sql b/server/dukang-api/prisma/migrate-hq-role-developer-v4011.sql new file mode 100644 index 0000000..c6f4b69 --- /dev/null +++ b/server/dukang-api/prisma/migrate-hq-role-developer-v4011.sql @@ -0,0 +1,23 @@ +-- v4.0.11: HQ 角色新增「开发者」(DEVELOPER) +-- 默认权限见 packages/shared-types HQ_ROLE_DEFAULT_PERMISSIONS.DEVELOPER +-- (除 hq_permissions / hq_accounts 外全部;未写入 hq_role_permission 时走代码默认) + +ALTER TABLE `hq_account` + MODIFY COLUMN `admin_role` ENUM( + 'SUPER_ADMIN', + 'OPS', + 'FINANCE', + 'CUSTOMER_SERVICE', + 'CITY_STORE_SERVICE', + 'DEVELOPER' + ) NOT NULL DEFAULT 'OPS'; + +ALTER TABLE `hq_role_permission` + MODIFY COLUMN `admin_role` ENUM( + 'SUPER_ADMIN', + 'OPS', + 'FINANCE', + 'CUSTOMER_SERVICE', + 'CITY_STORE_SERVICE', + 'DEVELOPER' + ) NOT NULL; diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 77e7162..10dec36 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -268,6 +268,7 @@ enum HqAdminRole { FINANCE CUSTOMER_SERVICE CITY_STORE_SERVICE + DEVELOPER } enum HqPermissionEffect { diff --git a/server/dukang-api/src/common/export/column-export.util.ts b/server/dukang-api/src/common/export/column-export.util.ts new file mode 100644 index 0000000..d3dcba3 --- /dev/null +++ b/server/dukang-api/src/common/export/column-export.util.ts @@ -0,0 +1,48 @@ +export type ExportColumnDef = { + key: string; + header: string; + value: (row: T) => string | number; +}; + +/** 按请求的列 key 过滤导出列;未传则导出全部 */ +export function pickExportColumns( + defs: ExportColumnDef[], + requestedKeys?: string[], +): ExportColumnDef[] { + if (!requestedKeys?.length) return defs; + const byKey = new Map(defs.map((d) => [d.key, d])); + return requestedKeys.map((k) => byKey.get(k)).filter((d): d is ExportColumnDef => !!d); +} + +export function parseExportColumnKeys(raw?: string): string[] | undefined { + if (!raw?.trim()) return undefined; + const keys = raw + .split(',') + .map((k) => k.trim()) + .filter(Boolean); + return keys.length ? keys : undefined; +} + +export function buildCsvFromColumns( + defs: ExportColumnDef[], + rows: T[], + requestedKeys?: string[], +): { csv: string; count: number } { + const active = pickExportColumns(defs, requestedKeys); + if (!active.length) { + return { csv: '\uFEFF', count: 0 }; + } + const header = active.map((d) => d.header).join(','); + const body = rows.map((row) => + active + .map((d) => { + const v = d.value(row); + const s = v == null ? '' : String(v); + return s.includes(',') || s.includes('"') || s.includes('\n') + ? `"${s.replace(/"/g, '""')}"` + : s; + }) + .join(','), + ); + return { csv: `\uFEFF${[header, ...body].join('\n')}`, count: rows.length }; +} diff --git a/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts b/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts index 609806b..b8b06ee 100644 --- a/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts +++ b/server/dukang-api/src/modules/dev-plan/admin-dev-plan-export.util.ts @@ -1,3 +1,7 @@ +import { + pickExportColumns, + type ExportColumnDef, +} from '../../common/export/column-export.util'; import { DEV_PLAN_TASK_STATUS_LABELS, DEV_PLAN_TASK_TYPE_LABELS, @@ -27,6 +31,7 @@ const EXPORT_HEADERS = [ '状态', '内容', '关联工单', + '关联版本', '创建人', '创建时间', '完成时间', @@ -39,6 +44,7 @@ export type DevPlanTaskExportRow = { status: string; content: string; supportTicketNo: string; + versionNos: string; creatorName: string; createdAt: string; completedAt: string; @@ -53,6 +59,7 @@ export function mapTaskToExportRow(task: { status: DevPlanTaskStatusDto; content: string; supportTicketNo?: string | null; + versionNos?: string | null; creatorName?: string | null; createdAt: string; completedAt?: string | null; @@ -64,6 +71,7 @@ export function mapTaskToExportRow(task: { status: DEV_PLAN_TASK_STATUS_LABELS[task.status] ?? task.status, content: task.content, supportTicketNo: task.supportTicketNo ?? '', + versionNos: task.versionNos ?? '', creatorName: task.creatorName ?? '', createdAt: task.createdAt.slice(0, 19).replace('T', ' '), completedAt: task.completedAt ? task.completedAt.slice(0, 19).replace('T', ' ') : '', @@ -71,20 +79,30 @@ export function mapTaskToExportRow(task: { }; } -function rowToCells(row: DevPlanTaskExportRow): string[] { +function devPlanExportColumnDefs(): ExportColumnDef[] { return [ - row.taskNo, - row.type, - row.status, - row.content, - row.supportTicketNo, - row.creatorName, - row.createdAt, - row.completedAt, - row.attachmentUrls.join(' '), + { key: 'taskNo', header: '任务编号', value: (r) => r.taskNo }, + { key: 'type', header: '类型', value: (r) => r.type }, + { key: 'status', header: '状态', value: (r) => r.status }, + { key: 'content', header: '内容', value: (r) => r.content }, + { key: 'supportTicketNo', header: '关联工单', value: (r) => r.supportTicketNo }, + { key: 'versions', header: '关联版本', value: (r) => r.versionNos }, + { key: 'creatorName', header: '创建人', value: (r) => r.creatorName }, + { key: 'createdAt', header: '创建时间', value: (r) => r.createdAt }, + { key: 'completedAt', header: '完成时间', value: (r) => r.completedAt }, + { + key: 'attachmentUrls', + header: '附件', + value: (r) => r.attachmentUrls.join(' '), + }, ]; } +function rowToCells(row: DevPlanTaskExportRow, columnKeys?: string[]): string[] { + const cols = pickExportColumns(devPlanExportColumnDefs(), columnKeys); + return cols.map((c) => String(c.value(row))); +} + function resolvePdfFontPath(): string { const candidates = [ process.env.EXPORT_PDF_FONT_PATH, @@ -187,15 +205,17 @@ export async function buildDevPlanDocx(rows: DevPlanTaskExportRow[]): Promise { +export async function buildDevPlanXlsx(rows: DevPlanTaskExportRow[], columnKeys?: string[]): Promise { + const cols = pickExportColumns(devPlanExportColumnDefs(), columnKeys); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('开发任务'); - sheet.addRow([...EXPORT_HEADERS]); + sheet.addRow(cols.map((c) => c.header)); for (const row of rows) { - sheet.addRow(rowToCells(row)); + sheet.addRow(cols.map((c) => c.value(row))); } sheet.columns.forEach((col, i) => { - col.width = i === 3 || i === 8 ? 40 : 16; + const header = cols[i]?.header ?? ''; + col.width = header === '内容' || header === '附件' ? 40 : 16; }); const buffer = await workbook.xlsx.writeBuffer(); return Buffer.from(buffer); diff --git a/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts b/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts index a692ef3..047404f 100644 --- a/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts +++ b/server/dukang-api/src/modules/dev-plan/admin-dev-plan.controller.ts @@ -107,11 +107,13 @@ export class AdminDevPlanController { @Get('versions') listVersions( @Query('status') status?: string, + @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.service.listVersions({ status, + keyword, page: page ? Number(page) : undefined, pageSize: pageSize ? Number(pageSize) : undefined, }); 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 f17fba7..d215431 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 @@ -590,7 +590,7 @@ export class DevPlanService { - async listVersions(query: { status?: string; page?: number; pageSize?: number }) { + async listVersions(query: { status?: string; keyword?: string; page?: number; pageSize?: number }) { const page = query.page ?? 1; @@ -600,7 +600,13 @@ export class DevPlanService { if (query.status) where.status = query.status as DevPlanVersionStatus; - + if (query.keyword?.trim()) { + const kw = query.keyword.trim(); + where.OR = [ + { versionNo: { contains: kw } }, + { content: { contains: kw } }, + ]; + } const [rows, total] = await Promise.all([ @@ -1329,6 +1335,7 @@ export class DevPlanService { status?: string; type?: string; keyword?: string; + columns?: string[]; }) { let rows: Array<{ id: bigint; @@ -1372,20 +1379,24 @@ export class DevPlanService { : Promise.resolve([]), ]); const ticketMap = new Map(tickets.map((t) => [String(t.id), t.ticketNo] as [string, string])); + const taskIds = rows.map((r) => r.id); + const versionMap = await this.loadTaskVersionMap(taskIds); - const exportRows = rows.map((r) => - mapTaskToExportRow({ + const exportRows = rows.map((r) => { + const versions = versionMap.get(String(r.id)) ?? []; + return mapTaskToExportRow({ taskNo: r.taskNo, type: r.type, status: r.status, content: r.content, supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null, + versionNos: versions.map((v) => v.versionNo).join('、') || null, creatorName: names.get(String(r.creatorHqAccountId)) ?? null, createdAt: r.createdAt.toISOString(), completedAt: r.completedAt?.toISOString() ?? null, attachmentUrls: parseAttachmentUrls(r.attachmentUrls), - }), - ); + }); + }); let buffer: Buffer; switch (dto.format) { @@ -1396,7 +1407,7 @@ export class DevPlanService { buffer = await buildDevPlanDocx(exportRows); break; case 'xlsx': - buffer = await buildDevPlanXlsx(exportRows); + buffer = await buildDevPlanXlsx(exportRows, dto.columns); break; case 'pdf': buffer = await buildDevPlanPdf(exportRows); diff --git a/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts b/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts index 72ac2c4..386e62a 100644 --- a/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts +++ b/server/dukang-api/src/modules/dev-plan/dto/dev-plan.dto.ts @@ -221,6 +221,11 @@ export class DevPlanTaskExportDto { @IsOptional() @IsString() keyword?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + columns?: string[]; } export class BatchReviewPreviewDto { diff --git a/server/dukang-api/src/modules/iam/admin-me.controller.ts b/server/dukang-api/src/modules/iam/admin-me.controller.ts index 0c27926..2c9c9ff 100644 --- a/server/dukang-api/src/modules/iam/admin-me.controller.ts +++ b/server/dukang-api/src/modules/iam/admin-me.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Param, Put, UseGuards } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { SaveHqListColumnPrefsDto } from './dto/auth.dto'; +import { SaveHqListColumnPrefsDto, UpdateMyHqCredentialsDto } from './dto/auth.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { AuthUser } from '../../common/guards/jwt-auth.guard'; @@ -18,4 +18,9 @@ export class AdminMeController { ) { return this.authService.updateMyListColumnPrefs(user.actorType, user.actorId, listKey, dto); } + + @Put('credentials') + updateCredentials(@CurrentUser() user: AuthUser, @Body() dto: UpdateMyHqCredentialsDto) { + return this.authService.updateMyHqCredentials(user.actorType, user.actorId, dto); + } } diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 082cd59..8f5bd73 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -20,7 +20,7 @@ import type { SmsActorRef } from '../../integrations/sms/sms.interface'; import { SmsCodeStore } from '../../integrations/sms/sms-code.store'; import type { IWechatProvider } from '../../integrations/wechat/wechat.interface'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; -import { verifyPassword } from '../../common/crypto/password.util'; +import { verifyPassword, hashPassword } from '../../common/crypto/password.util'; import { AnalyticsService } from '../analytics/analytics.service'; import { UserAddressService } from './user-address.service'; import { ResourceService } from '../common/resource.service'; @@ -1287,6 +1287,50 @@ export class AuthService { return { listColumnPrefs: parseListColumnPrefs(updated.listColumnPrefs) }; } + async updateMyHqCredentials( + actorType: string, + actorId: bigint, + dto: { loginName?: string; oldPassword?: string; newPassword?: string }, + ) { + if (actorType !== 'HQ') throw new ForbiddenException('仅总部账号可修改'); + const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } }); + if (!account) throw new NotFoundException('账号不存在'); + + const data: { loginName?: string; passwordHash?: string } = {}; + const nextLogin = dto.loginName?.trim(); + if (nextLogin !== undefined) { + if (!nextLogin) throw new BadRequestException('用户名不能为空'); + if (nextLogin !== account.loginName) { + const dup = await this.prisma.hqAccount.findUnique({ where: { loginName: nextLogin } }); + if (dup && dup.id !== account.id) throw new BadRequestException('用户名已被占用'); + data.loginName = nextLogin; + } + } + + if (dto.newPassword !== undefined) { + const pwd = dto.newPassword.trim(); + if (pwd.length < 6) throw new BadRequestException('新密码至少 6 位'); + if (account.passwordHash) { + if (!dto.oldPassword?.trim()) throw new BadRequestException('请输入当前密码'); + if (!verifyPassword(dto.oldPassword.trim(), account.passwordHash)) { + throw new BadRequestException('当前密码不正确'); + } + } + data.passwordHash = hashPassword(pwd); + } + + if (!Object.keys(data).length) { + throw new BadRequestException('请填写要修改的内容'); + } + + const updated = await this.prisma.hqAccount.update({ + where: { id: actorId }, + data, + }); + const { passwordHash: _ph, ...safe } = updated; + return serializeBigInt(safe); + } + wechatDisabled() { throw new NotImplementedException('FEATURE_DISABLED'); } diff --git a/server/dukang-api/src/modules/iam/dto/auth.dto.ts b/server/dukang-api/src/modules/iam/dto/auth.dto.ts index 1d2bf9a..2441684 100644 --- a/server/dukang-api/src/modules/iam/dto/auth.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/auth.dto.ts @@ -130,6 +130,22 @@ export class CheckPartnerPhoneDto { phone: string; } +export class UpdateMyHqCredentialsDto { + @IsOptional() + @IsString() + @IsNotEmpty() + loginName?: string; + + @IsOptional() + @IsString() + oldPassword?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + newPassword?: string; +} + export class SaveHqListColumnPrefsDto { @IsOptional() @IsBoolean() diff --git a/server/dukang-api/src/modules/ops/admin-order-export.util.ts b/server/dukang-api/src/modules/ops/admin-order-export.util.ts index 094ab2e..f70d767 100644 --- a/server/dukang-api/src/modules/ops/admin-order-export.util.ts +++ b/server/dukang-api/src/modules/ops/admin-order-export.util.ts @@ -3,20 +3,16 @@ import ExcelJS from 'exceljs'; import * as fs from 'fs'; import * as path from 'path'; import PDFDocument from 'pdfkit'; +import { + pickExportColumns, + type ExportColumnDef, +} from '../../common/export/column-export.util'; -const EXPORT_HEADERS = [ - '订单号', - '下单时间', - '状态', - '商品', - '规格', - '数量', - '实付', - '好客权益', - '收货人', - '手机', - '收货地址', -] as const; +const DELIVERY_TYPE_LABELS: Record = { + LOCAL: '同城', + CROSS_CITY: '跨城', + ON_SITE_PICKUP: '现场提货', +}; export type OrderExportRow = { orderNo: string; @@ -25,7 +21,9 @@ export type OrderExportRow = { productName: string; productSpec: string; quantity: number; + deliveryType: string; payAmount: number; + logisticsFee: string; benefitBrief: string; receiverName: string; receiverPhone: string; @@ -95,7 +93,9 @@ export function mapOrderToExportRow(order: { productName: order.productName, productSpec: order.productSpec, quantity: order.quantity, + deliveryType: DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '', payAmount: Number(order.payAmount), + logisticsFee: '', benefitBrief: formatBenefitBrief(order), receiverName: order.receiverName, receiverPhone: order.receiverPhone, @@ -103,22 +103,49 @@ export function mapOrderToExportRow(order: { }; } -export function rowToCells(row: OrderExportRow): string[] { +function orderExportColumnDefs(): ExportColumnDef[] { return [ - row.orderNo, - row.createdAt, - row.status, - row.productName, - row.productSpec, - String(row.quantity), - row.payAmount.toFixed(2), - row.benefitBrief, - row.receiverName, - row.receiverPhone, - row.address, + { key: '订单号', header: '订单号', value: (r) => r.orderNo }, + { key: '下单时间', header: '下单时间', value: (r) => r.createdAt }, + { key: '状态', header: '状态', value: (r) => r.status }, + { key: '商品', header: '商品', value: (r) => r.productName }, + { key: '规格', header: '规格', value: (r) => r.productSpec }, + { key: '数量', header: '数量', value: (r) => r.quantity }, + { key: '配送方式', header: '配送方式', value: (r) => r.deliveryType }, + { key: '实付', header: '实付', value: (r) => r.payAmount.toFixed(2) }, + { key: '运费', header: '运费', value: (r) => r.logisticsFee }, + { key: '好客权益', header: '好客权益', value: (r) => r.benefitBrief }, + { key: '收货人', header: '收货人', value: (r) => r.receiverName }, + { key: '电话', header: '电话', value: (r) => r.receiverPhone }, + { key: '地址', header: '地址', value: (r) => r.address }, ]; } +const ORDER_COLUMN_ALIASES: Record = { + orderNo: '订单号', + createdAt: '下单时间', + status: '状态', + productName: '商品', + productSpec: '规格', + quantity: '数量', + deliveryType: '配送方式', + payAmount: '实付', + logisticsFee: '运费', + benefitBrief: '好客权益', + receiverName: '收货人', + receiverPhone: '电话', + phone: '电话', + receiverAddress: '地址', + address: '地址', + 手机: '电话', + 收货地址: '地址', +}; + +function normalizeOrderColumns(columnKeys?: string[]): string[] | undefined { + if (!columnKeys?.length) return undefined; + return columnKeys.map((k) => ORDER_COLUMN_ALIASES[k] ?? k); +} + function resolvePdfFontPath(): string { const candidates = [ process.env.EXPORT_PDF_FONT_PATH, @@ -138,79 +165,42 @@ function resolvePdfFontPath(): string { throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/'); } -export async function buildOrdersXlsx(rows: OrderExportRow[]): Promise { +export async function buildOrdersXlsx(rows: OrderExportRow[], columnKeys?: string[]): Promise { + const cols = pickExportColumns(orderExportColumnDefs(), normalizeOrderColumns(columnKeys)); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('订单'); - sheet.addRow([...EXPORT_HEADERS]); + sheet.addRow(cols.map((c) => c.header)); for (const row of rows) { - sheet.addRow(rowToCells(row)); + sheet.addRow(cols.map((c) => c.value(row))); } sheet.columns.forEach((col) => { col.width = 16; }); - sheet.getColumn(4).width = 22; - sheet.getColumn(8).width = 36; - sheet.getColumn(11).width = 36; const buffer = await workbook.xlsx.writeBuffer(); return Buffer.from(buffer); } -export async function buildOrdersPdf(rows: OrderExportRow[]): Promise { +export async function buildOrdersPdf(rows: OrderExportRow[], columnKeys?: string[]): Promise { + const cols = pickExportColumns(orderExportColumnDefs(), normalizeOrderColumns(columnKeys)); const fontPath = resolvePdfFontPath(); const chunks: Buffer[] = []; return new Promise((resolve, reject) => { - const doc = new PDFDocument({ - size: 'A4', - layout: 'landscape', - margin: 24, - bufferPages: true, - }); - doc.on('data', (chunk) => chunks.push(chunk as Buffer)); + const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 24, bufferPages: true }); + doc.on('data', (c) => chunks.push(c as Buffer)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', reject); - - doc.registerFont('zh', fontPath); - doc.font('zh'); - - const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right; - const colWidths = [72, 78, 48, 88, 64, 32, 48, 110, 48, 72, 120]; - const scale = pageWidth / colWidths.reduce((sum, w) => sum + w, 0); - const widths = colWidths.map((w) => w * scale); - const rowHeight = 28; - const fontSize = 7; - let y = doc.page.margins.top; - - const drawRow = (cells: string[], isHeader = false) => { - let x = doc.page.margins.left; - const height = isHeader ? 24 : rowHeight; - if (y + height > doc.page.height - doc.page.margins.bottom) { - doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 }); - y = doc.page.margins.top; - } - doc.fontSize(isHeader ? 8 : fontSize); - cells.forEach((cell, index) => { - doc.rect(x, y, widths[index], height).stroke('#dddddd'); - doc.text(cell || '', x + 2, y + 4, { - width: widths[index] - 4, - height: height - 6, - lineBreak: true, - }); - x += widths[index]; - }); - y += height; - }; - - drawRow([...EXPORT_HEADERS], true); + doc.font(fontPath); + doc.fontSize(10).text(cols.map((c) => c.header).join(' | ')); + doc.moveDown(0.5); for (const row of rows) { - drawRow(rowToCells(row)); + doc.text(cols.map((c) => String(c.value(row))).join(' | ')); } - doc.end(); }); } export function buildExportFilename(format: 'xlsx' | 'pdf', count: number): string { - const stamp = formatShanghaiDateTime(new Date()).slice(0, 10); - return `订单导出_${stamp}_${count}条.${format}`; + const stamp = new Date().toISOString().slice(0, 10); + return `订单导出_${stamp}_${count}.${format}`; } diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index 5ad8f06..9016762 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -156,7 +156,9 @@ export class AdminOrdersService { const rows = orders.map((order) => mapOrderToExportRow(order)); const buffer = - dto.format === 'pdf' ? await buildOrdersPdf(rows) : await buildOrdersXlsx(rows); + dto.format === 'pdf' + ? await buildOrdersPdf(rows, dto.columns) + : await buildOrdersXlsx(rows, dto.columns); const filename = buildExportFilename(dto.format, rows.length); const mimeType = dto.format === 'pdf' diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 3004e8c..2d2b4e5 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1164,7 +1164,7 @@ export class CreateHqAccountDto { name: string; @IsOptional() - @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE']) + @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE', 'DEVELOPER']) adminRole?: string; @IsOptional() @@ -1191,7 +1191,7 @@ export class UpdateHqAccountDto { name?: string; @IsOptional() - @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE']) + @IsIn(['SUPER_ADMIN', 'OPS', 'FINANCE', 'CUSTOMER_SERVICE', 'CITY_STORE_SERVICE', 'DEVELOPER']) adminRole?: string; @IsOptional() diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index 6c1e733..b6cd365 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -225,6 +225,12 @@ export class AdminOrdersExportDto { @IsOptional() @IsString() assocPartnerAccountId?: string; + + /** 按列表可见列导出(中文列名或英文 key) */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + columns?: string[]; } /** 概览页用户/订单 ECharts 聚合筛选 */ diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 526abc2..7add4f4 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -300,6 +300,7 @@ export class AdminStoreBillController { storeId: query.storeId, dateFrom: query.dateFrom, dateTo: query.dateTo, + columns: query.columns, }); } @@ -391,6 +392,7 @@ export class AdminPartnerBillController { year: query.year ? Number(query.year) : undefined, month: query.month ? Number(query.month) : undefined, weekStartYmd: query.weekStartYmd, + columns: query.columns, }); } @@ -493,6 +495,7 @@ export class AdminWineryBillController { dateTo: query.dateTo, year: query.year ? Number(query.year) : undefined, month: query.month ? Number(query.month) : undefined, + columns: query.columns, }); } @@ -575,6 +578,7 @@ export class AdminLogisticsBillController { providerId: query.providerId, year: query.year ? Number(query.year) : undefined, month: query.month ? Number(query.month) : undefined, + columns: query.columns, }); } diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index eafe31c..3dc66a9 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -21,6 +21,7 @@ import { previousShanghaiWeek, resolveSettlementRate, shanghaiBillPeriodYmds, + shanghaiCalendarDaysBetween, shanghaiMonthLastInstant, shanghaiMonthRange, shanghaiPeriodYmds, @@ -40,6 +41,7 @@ import { import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AlertService } from '../../common/alert/alert.service'; +import { buildCsvFromColumns, parseExportColumnKeys, type ExportColumnDef } from '../../common/export/column-export.util'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; @@ -88,6 +90,34 @@ function withShanghaiPeriod(ro return { ...row, ...shanghaiBillPeriodYmds(row.periodStart, row.periodEnd) }; } +/** 门店 T+1:账期 = 出账日前一日 */ +function mapStoreBillDates(billDate: Date) { + const billDateYmd = shanghaiYmd(billDate); + const periodDay = shanghaiYmd(addShanghaiDays(parseShanghaiYmd(billDateYmd), -1)); + return { billDate: billDateYmd, periodStart: periodDay, periodEnd: periodDay }; +} + +/** + * 合伙人出账日:周账 = 账期周一 + 7 天(下周一); + * 历史月账 = 账期所在月次月 1 日。 + */ +function partnerBillIssueYmd(periodStart: Date, periodEnd: Date): string { + const days = shanghaiCalendarDaysBetween(periodStart, periodEnd); + if (days >= 0 && days <= 8) { + return shanghaiYmd(addShanghaiDays(startOfShanghaiDay(periodStart), 7)); + } + const [y, m] = shanghaiYmd(periodStart).split('-').map(Number); + return shanghaiYmd(shanghaiMonthRange(y, m).endExclusive); +} + +function withPartnerBillDates(row: T) { + return { + ...row, + ...shanghaiBillPeriodYmds(row.periodStart, row.periodEnd), + billDate: partnerBillIssueYmd(row.periodStart, row.periodEnd), + }; +} + function resolvePartnerWeekPeriod(weekStartYmd: string): { periodStart: Date; periodEnd: Date; @@ -1429,14 +1459,20 @@ export class SettlementService implements OnModuleInit { const payoutAmount = rows.reduce((s, r) => s + r.amount, 0); return serializeBigInt({ - items: slice.map((r) => ({ - ...r, - date: r.kind === 'T1_BILL' ? shanghaiYmd(r.date) : r.date, - bankAccount: - r.kind === 'T1_BILL' - ? bankMap.get(String(r.storeId)) ?? null - : null, - })), + items: slice.map((r) => { + const storeDates = + r.kind === 'T1_BILL' ? mapStoreBillDates(r.date) : null; + return { + ...r, + date: r.kind === 'T1_BILL' ? storeDates!.billDate : r.date, + periodStart: storeDates?.periodStart ?? null, + periodEnd: storeDates?.periodEnd ?? null, + bankAccount: + r.kind === 'T1_BILL' + ? bankMap.get(String(r.storeId)) ?? null + : null, + }; + }), total, page, pageSize, @@ -1487,7 +1523,7 @@ export class SettlementService implements OnModuleInit { }; return serializeBigInt({ - items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })), + items: items.map((b) => ({ ...b, ...mapStoreBillDates(b.billDate) })), total, page, pageSize, @@ -1510,7 +1546,7 @@ export class SettlementService implements OnModuleInit { const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId); return serializeBigInt({ ...bill, - billDate: shanghaiYmd(bill.billDate), + ...mapStoreBillDates(bill.billDate), storeAccount, paymentProofUrls: parsePaymentProofUrls(bill.paymentProofUrls), }); @@ -1568,6 +1604,7 @@ export class SettlementService implements OnModuleInit { storeId?: string; dateFrom?: string; dateTo?: string; + columns?: string; }) { const where = this.buildStoreBillWhere(query); const bills = await this.prisma.storeBill.findMany({ @@ -1579,44 +1616,70 @@ export class SettlementService implements OnModuleInit { this.prisma, bills.map((b) => b.storeId), ); - const header = [ - '账单号', - '出账日', - '门店', - '城市', - '核销笔数', - '核销金额', - '结算比例', - '应付金额', - '状态', - '打款时间', - '打款凭证', - '打款凭证照片', - '收款户名', - '收款账号', - '开户行', - ].join(','); - const rows = bills.map((b) => { - const bank = bankMap.get(String(b.storeId)); - return [ - csvEscape(b.billNo), - shanghaiYmd(b.billDate), - csvEscape(b.store.name), - csvEscape(b.store.cityName ?? ''), - b.redeemCount, - Number(b.redeemAmount), - Number(b.settlementRate), - Number(b.payoutAmount), - b.status, - b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', - csvEscape(b.paymentRef ?? ''), - csvEscape(parsePaymentProofUrls(b.paymentProofUrls).join(' ')), - csvEscape(bank?.bankAccountName ?? ''), - csvEscape(bank?.bankAccountNo ?? ''), - csvEscape(bank?.bankBranch ?? ''), - ].join(','); - }); - return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; + const columnKeys = parseExportColumnKeys(query.columns); + + type Row = (typeof bills)[number] & { bank?: ReturnType extends infer T ? T : never }; + const rows = bills.map((b) => ({ ...b, bank: bankMap.get(String(b.storeId)) })); + + const defs: ExportColumnDef[] = [ + { key: '账单号', header: '账单号', value: (r) => r.billNo }, + { key: '出账日', header: '出账日', value: (r) => shanghaiYmd(r.billDate) }, + { + key: '账期', + header: '账期', + value: (r) => { + const d = mapStoreBillDates(r.billDate); + return `${d.periodStart} ~ ${d.periodEnd}`; + }, + }, + { key: '门店', header: '门店', value: (r) => r.store.name }, + { key: '登录手机', header: '登录手机', value: (r) => r.store.phone ?? '' }, + { key: '城市', header: '城市', value: (r) => r.store.cityName ?? '' }, + { key: '收款户名', header: '收款户名', value: (r) => r.bank?.bankAccountName ?? '' }, + { key: '收款账号', header: '收款账号', value: (r) => r.bank?.bankAccountNo ?? '' }, + { key: '笔数', header: '笔数', value: (r) => r.redeemCount }, + { key: '应付金额', header: '应付金额', value: (r) => Number(r.payoutAmount) }, + { key: '核销金额', header: '核销金额', value: (r) => Number(r.redeemAmount) }, + { key: '结算比例', header: '结算比例', value: (r) => Number(r.settlementRate) }, + { key: '状态', header: '状态', value: (r) => r.status }, + { + key: '打款时间', + header: '打款时间', + value: (r) => (r.paidAt ? r.paidAt.toISOString().slice(0, 19).replace('T', ' ') : ''), + }, + { key: '打款凭证', header: '打款凭证', value: (r) => r.paymentRef ?? '' }, + { + key: '打款凭证照片', + header: '打款凭证照片', + value: (r) => parsePaymentProofUrls(r.paymentProofUrls).join(' '), + }, + { key: '开户行', header: '开户行', value: (r) => r.bank?.bankBranch ?? '' }, + ]; + + // 兼容旧英文 key + const aliases: Record = { + billNo: '账单号', + date: '出账日', + billDate: '出账日', + period: '账期', + 'store.name': '门店', + 'store.phone': '登录手机', + 'store.cityName': '城市', + bankAccountName: '收款户名', + bankAccountNo: '收款账号', + redeemCount: '笔数', + amount: '应付金额', + redeemAmount: '核销金额', + settlementRate: '结算比例', + status: '状态', + paidAt: '打款时间', + paymentRef: '打款凭证', + paymentProofUrls: '打款凭证照片', + bankBranch: '开户行', + }; + const normalized = columnKeys?.map((k) => aliases[k] ?? k); + + return buildCsvFromColumns(defs, rows, normalized); } private buildStoreBillWhere(query: { @@ -1665,7 +1728,7 @@ export class SettlementService implements OnModuleInit { eventName: 'partner_bill_view', extraJson: { count: bills.length }, }); - return serializeBigInt(bills.map(withShanghaiPeriod)); + return serializeBigInt(bills.map(withPartnerBillDates)); } getPartnerSettlementCycle(anchor = new Date()) { @@ -1737,7 +1800,7 @@ export class SettlementService implements OnModuleInit { }); const { items, ...header } = bill; return serializeBigInt({ - ...withShanghaiPeriod(header), + ...withPartnerBillDates(header), partnerId: header.partnerAccountId.toString(), ...splitPartnerBillItems(items), }); @@ -1785,7 +1848,7 @@ export class SettlementService implements OnModuleInit { ]); const items = rawItems.map((b) => ({ - ...withShanghaiPeriod(b), + ...withPartnerBillDates(b), partner: b.partnerAccount, })); @@ -1904,7 +1967,7 @@ export class SettlementService implements OnModuleInit { if (!bill) throw new NotFoundException('账单不存在'); const { items, ...header } = bill; return serializeBigInt({ - ...withShanghaiPeriod(header), + ...withPartnerBillDates(header), partnerId: header.partnerAccountId.toString(), ...splitPartnerBillItems(items), }); @@ -2229,6 +2292,7 @@ export class SettlementService implements OnModuleInit { year?: number; month?: number; weekStartYmd?: string; + columns?: string; }) { const where = this.buildPartnerBillWhere(query); const bills = await this.prisma.partnerBill.findMany({ @@ -2237,76 +2301,82 @@ export class SettlementService implements OnModuleInit { partnerAccount: { select: { companyName: true, + name: true, phone: true, bankAccountName: true, bankAccountNo: true, bankBranch: true, }, }, - items: { orderBy: { occurredAt: 'asc' } }, }, orderBy: { periodStart: 'desc' }, }); + const columnKeys = parseExportColumnKeys(query.columns); - const header = [ - '账单号', - '合伙人', - '登录手机', - '账期起', - '账期止', - '酒单佣金', - '核销佣金', - '合计应付', - '状态', - '发送时间', - '确认时间', - '打款时间', - '打款凭证', - '收款户名', - '收款账号', - '开户行', - '驳回理由', - ].join(','); - const rows = bills.map((b) => - [ - csvEscape(b.billNo), - csvEscape(b.partnerAccount.companyName ?? ''), - csvEscape(b.partnerAccount.phone ?? ''), - shanghaiBillPeriodYmds(b.periodStart, b.periodEnd).periodStart, - shanghaiBillPeriodYmds(b.periodStart, b.periodEnd).periodEnd, - Number(b.orderCommission), - Number(b.redeemCommission), - Number(b.totalAmount), - b.status, - 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 ?? ''), - csvEscape(b.partnerAccount.bankBranch ?? ''), - csvEscape(b.rejectReason ?? ''), - ].join(','), - ); + type Row = (typeof bills)[number]; + const defs: ExportColumnDef[] = [ + { key: '账单号', header: '账单号', value: (r) => r.billNo }, + { + key: '合伙人', + header: '合伙人', + value: (r) => { + const c = (r.partnerAccount.companyName || '').trim(); + const n = (r.partnerAccount.name || '').trim(); + if (c && n) return `${c}-${n}`; + return c || n || r.partnerAccount.phone || ''; + }, + }, + { + key: '出账日', + header: '出账日', + value: (r) => partnerBillIssueYmd(r.periodStart, r.periodEnd), + }, + { + key: '账期', + header: '账期', + value: (r) => { + const p = shanghaiBillPeriodYmds(r.periodStart, r.periodEnd); + return `${p.periodStart} ~ ${p.periodEnd}`; + }, + }, + { key: '酒单佣金', header: '酒单佣金', value: (r) => Number(r.orderCommission) }, + { key: '核销佣金', header: '核销佣金', value: (r) => Number(r.redeemCommission) }, + { key: '合计应付', header: '合计应付', value: (r) => Number(r.totalAmount) }, + { key: '收款户名', header: '收款户名', value: (r) => r.partnerAccount.bankAccountName ?? '' }, + { key: '收款账号', header: '收款账号', value: (r) => r.partnerAccount.bankAccountNo ?? '' }, + { key: '状态', header: '状态', value: (r) => r.status }, + { key: '登录手机', header: '登录手机', value: (r) => r.partnerAccount.phone ?? '' }, + { key: '开户行', header: '开户行', value: (r) => r.partnerAccount.bankBranch ?? '' }, + { + key: '发送时间', + header: '发送时间', + value: (r) => (r.sentAt ? shanghaiYmd(r.sentAt) : ''), + }, + { + key: '确认时间', + header: '确认时间', + value: (r) => (r.confirmedAt ? shanghaiYmd(r.confirmedAt) : ''), + }, + { + key: '打款时间', + header: '打款时间', + value: (r) => (r.paidAt ? shanghaiYmd(r.paidAt) : ''), + }, + { key: '打款凭证', header: '打款凭证', value: (r) => r.paymentRef ?? '' }, + { key: '驳回理由', header: '驳回理由', value: (r) => r.rejectReason ?? '' }, + ]; - const itemHeader = ['账单号', '类型', '单号', '标题', '备注', '基数', '费率', '佣金', '发生时间'].join(','); - const itemRows = bills.flatMap((b) => - b.items.map((it) => - [ - csvEscape(b.billNo), - it.kind === 'ORDER' ? '酒订单' : '核销', - csvEscape(it.refNo), - csvEscape(it.title ?? ''), - csvEscape(it.extra ?? ''), - Number(it.baseAmount), - Number(it.rate), - Number(it.commission), - it.occurredAt.toISOString().slice(0, 19).replace('T', ' '), - ].join(','), - ), - ); - const csv = ['账单汇总', header, ...rows, '', '酒订单/核销明细', itemHeader, ...itemRows].join('\n'); - return { csv: `\uFEFF${csv}`, count: bills.length }; + const aliases: Record = { + billNo: '账单号', + billDate: '出账日', + period: '账期', + orderCommission: '酒单佣金', + redeemCommission: '核销佣金', + totalAmount: '合计应付', + status: '状态', + }; + const normalized = columnKeys?.map((k) => aliases[k] ?? k); + return buildCsvFromColumns(defs, bills, normalized); } // ─── Winery bills ──────────────────────────────────── @@ -2673,7 +2743,7 @@ export class SettlementService implements OnModuleInit { }; return serializeBigInt({ - items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })), + items: items.map((b) => this.mapWineryBillForAdmin(b)), total, page, pageSize, @@ -2681,6 +2751,33 @@ export class SettlementService implements OnModuleInit { }); } + private mapWineryBillForAdmin(b: { + id: bigint; + billNo: string; + billDate: Date; + orderCount: number; + orderAmount: Prisma.Decimal | number; + wineryRate: Prisma.Decimal | number; + wineryAmount: Prisma.Decimal | number; + status: string; + paidAt?: Date | null; + paymentRef?: string | null; + }) { + const billDateYmd = shanghaiYmd(b.billDate); + const { start, end } = shanghaiWineryPeriodWindow( + parseShanghaiYmd(billDateYmd), + WINERY_SETTLEMENT_PERIOD_DAYS, + ); + const periodStart = shanghaiYmd(start); + const periodEnd = shanghaiYmd(addShanghaiDays(end, -1)); + return { + ...b, + billDate: billDateYmd, + periodStart, + periodEnd, + }; + } + async getAdminWineryBill(id: bigint) { const bill = await this.prisma.wineryBill.findUnique({ where: { id }, @@ -2688,7 +2785,7 @@ export class SettlementService implements OnModuleInit { }); if (!bill) throw new NotFoundException('酒厂对账单不存在'); const wineryBank = await loadWineryBankConfig(this.prisma); - return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), wineryBank }); + return serializeBigInt({ ...this.mapWineryBillForAdmin(bill), items: bill.items, wineryBank }); } async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) { @@ -2728,87 +2825,87 @@ export class SettlementService implements OnModuleInit { dateTo?: string; year?: number; month?: number; + columns?: string; }) { const where = this.buildWineryBillWhere(query); const bills = await this.prisma.wineryBill.findMany({ where, - include: { items: true }, orderBy: { billDate: 'desc' }, }); const wineryBank = await loadWineryBankConfig(this.prisma); + const columnKeys = parseExportColumnKeys(query.columns); - const header = [ - '账单号', - '账单日', - '订单号', - '配送类型', - '实付金额', - '酒厂比例', - '应付', - '支付时间', - '账单状态', - '打款凭证', - '收款户名', - '开户银行', - '开户支行', - '收款账号', - ].join(','); - const rows: string[] = []; const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => { if (Number(b.wineryAmount) === 0) return '无需打款'; if (b.status === 'PAID') return '已打款'; if (b.status === 'UNPAID') return '未打款'; return b.status; }; - const bankCols = (b: { paymentRef?: string | null }) => [ - csvEscape(b.paymentRef ?? ''), - csvEscape(wineryBank.bankAccountName ?? ''), - csvEscape(wineryBank.bankName ?? ''), - csvEscape(wineryBank.bankBranch ?? ''), - csvEscape(wineryBank.bankAccountNo ?? ''), + + type ExportRow = { + billNo: string; + billDate: string; + periodStart: string; + periodEnd: string; + orderCount: number; + orderAmount: Prisma.Decimal | number; + wineryRate: Prisma.Decimal | number; + wineryAmount: Prisma.Decimal | number; + status: string; + paymentRef?: string | null; + paidAt?: Date | null; + }; + + const rows: ExportRow[] = bills.map((b) => ({ + ...this.mapWineryBillForAdmin(b), + paymentRef: b.paymentRef, + paidAt: b.paidAt, + })); + + const defs: ExportColumnDef[] = [ + { key: '账单号', header: '账单号', value: (r) => r.billNo }, + { key: '出账日', header: '出账日', value: (r) => r.billDate }, + { + key: '账期', + header: '账期', + value: (r) => `${r.periodStart} ~ ${r.periodEnd}`, + }, + { key: '订单数', header: '订单数', value: (r) => r.orderCount }, + { key: '订单总额', header: '订单总额', value: (r) => Number(r.orderAmount) }, + { key: '酒厂比例', header: '酒厂比例', value: (r) => Number(r.wineryRate) }, + { key: '应付', header: '应付', value: (r) => Number(r.wineryAmount) }, + { key: '状态', header: '状态', value: (r) => statusLabel(r) }, + { + key: '打款时间', + header: '打款时间', + value: (r) => (r.paidAt ? r.paidAt.toISOString().slice(0, 19).replace('T', ' ') : ''), + }, + { key: '打款凭证', header: '打款凭证', value: (r) => r.paymentRef ?? '' }, + { key: '收款户名', header: '收款户名', value: () => wineryBank.bankAccountName ?? '' }, + { key: '开户银行', header: '开户银行', value: () => wineryBank.bankName ?? '' }, + { key: '开户支行', header: '开户支行', value: () => wineryBank.bankBranch ?? '' }, + { key: '收款账号', header: '收款账号', value: () => wineryBank.bankAccountNo ?? '' }, ]; - for (const b of bills) { - if (b.items.length === 0) { - rows.push( - [ - csvEscape(b.billNo), - shanghaiYmd(b.billDate), - '', - '', - Number(b.orderAmount), - Number(b.wineryRate), - Number(b.wineryAmount), - '', - statusLabel(b), - ...bankCols(b), - ].join(','), - ); - continue; - } - for (const item of b.items) { - rows.push( - [ - csvEscape(b.billNo), - shanghaiYmd(b.billDate), - csvEscape(item.orderNo), - item.deliveryType === 'LOCAL' - ? '同城' - : item.deliveryType === 'CROSS_CITY' - ? '跨城' - : item.deliveryType === 'ON_SITE_PICKUP' - ? '现场提货' - : item.deliveryType, - Number(item.payAmount), - Number(b.wineryRate), - Number(item.wineryAmount), - item.paidAt.toISOString().slice(0, 19).replace('T', ' '), - statusLabel(b), - ...bankCols(b), - ].join(','), - ); - } - } - return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length }; + + const aliases: Record = { + billNo: '账单号', + billDate: '出账日', + period: '账期', + orderCount: '订单数', + orderAmount: '订单总额', + wineryRate: '酒厂比例', + wineryAmount: '应付', + status: '状态', + paidAt: '打款时间', + paymentRef: '打款凭证', + bankAccountName: '收款户名', + bankName: '开户银行', + bankBranch: '开户支行', + bankAccountNo: '收款账号', + }; + const normalized = columnKeys?.map((k) => aliases[k] ?? k); + + return buildCsvFromColumns(defs, rows, normalized); } private buildWineryBillWhere(query: { @@ -3362,6 +3459,7 @@ export class SettlementService implements OnModuleInit { providerId?: string; year?: number; month?: number; + columns?: string; }) { const where = this.buildLogisticsBillWhere(query); const bills = await this.prisma.logisticsBill.findMany({ @@ -3377,78 +3475,73 @@ export class SettlementService implements OnModuleInit { bankAccountNo: true, }, }, - items: true, }, orderBy: { periodStart: 'desc' }, }); + const columnKeys = parseExportColumnKeys(query.columns); - const header = [ - '账单号', - '承运商编码', - '承运商名称', - '账期起', - '账期止', - '结算方式', - '订单号', - '瓶数', - '物流费', - '发货时间', - '账单状态', - '打款凭证', - '收款户名', - '开户银行', - '开户支行', - '收款账号', - ].join(','); - const rows: string[] = []; - for (const b of bills) { - const p = b.fulfillmentProvider; - const bankCols = [ - csvEscape(b.paymentRef ?? ''), - csvEscape(p.bankAccountName ?? ''), - csvEscape(p.bankName ?? ''), - csvEscape(p.bankBranch ?? ''), - csvEscape(p.bankAccountNo ?? ''), - ]; - if (b.items.length === 0) { - rows.push( - [ - csvEscape(b.billNo), - csvEscape(p.code), - csvEscape(p.name), - shanghaiPeriodYmds(b.periodStart).periodStart, - shanghaiPeriodYmds(b.periodStart).periodEnd, - b.settlementMethod, - '', - b.bottleCount, - Number(b.logisticsAmount), - '', - b.status, - ...bankCols, - ].join(','), - ); - continue; - } - for (const item of b.items) { - rows.push( - [ - csvEscape(b.billNo), - csvEscape(p.code), - csvEscape(p.name), - shanghaiPeriodYmds(b.periodStart).periodStart, - shanghaiPeriodYmds(b.periodStart).periodEnd, - b.settlementMethod, - csvEscape(item.orderNo), - item.quantity, - Number(item.logisticsAmount), - item.shippedAt.toISOString().slice(0, 19).replace('T', ' '), - b.status, - ...bankCols, - ].join(','), - ); - } - } - return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length }; + type Row = (typeof bills)[number]; + const defs: ExportColumnDef[] = [ + { key: '账单号', header: '账单号', value: (r) => r.billNo }, + { + key: '承运商', + header: '承运商', + value: (r) => `${r.fulfillmentProvider.name} (${r.fulfillmentProvider.code})`, + }, + { + key: '账期', + header: '账期', + value: (r) => { + const p = shanghaiPeriodYmds(r.periodStart); + return `${p.periodStart} ~ ${p.periodEnd}`; + }, + }, + { + key: '结算方式', + header: '结算方式', + value: (r) => + LOGISTICS_SETTLEMENT_METHOD_LABELS[ + r.settlementMethod as keyof typeof LOGISTICS_SETTLEMENT_METHOD_LABELS + ] || r.settlementMethod, + }, + { key: '订单数', header: '订单数', value: (r) => r.orderCount }, + { key: '瓶数', header: '瓶数', value: (r) => r.bottleCount }, + { key: '物流费', header: '物流费', value: (r) => Number(r.logisticsAmount) }, + { + key: '收款户名', + header: '收款户名', + value: (r) => r.fulfillmentProvider.bankAccountName ?? '', + }, + { + key: '收款账号', + header: '收款账号', + value: (r) => r.fulfillmentProvider.bankAccountNo ?? '', + }, + { key: '状态', header: '状态', value: (r) => r.status }, + { + key: '开户银行', + header: '开户银行', + value: (r) => r.fulfillmentProvider.bankName ?? '', + }, + { + key: '开户支行', + header: '开户支行', + value: (r) => r.fulfillmentProvider.bankBranch ?? '', + }, + { key: '打款凭证', header: '打款凭证', value: (r) => r.paymentRef ?? '' }, + ]; + + const aliases: Record = { + billNo: '账单号', + period: '账期', + settlementMethod: '结算方式', + orderCount: '订单数', + bottleCount: '瓶数', + logisticsAmount: '物流费', + status: '状态', + }; + const normalized = columnKeys?.map((k) => aliases[k] ?? k); + return buildCsvFromColumns(defs, bills, normalized); } private buildLogisticsBillWhere(query: {