v3.5.5版本上传
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-23 12:26:57 +08:00
parent f9161368e1
commit 22cd00da42
18 changed files with 2036 additions and 294 deletions
+3
View File
@@ -21,4 +21,7 @@ coverage/
server/dukang-api/prisma/migrations/
debug_v3.xlsx
~$debug_v3.xlsx
server/dukang-api/assets/fonts/*.ttf
server/dukang-api/assets/fonts/*.ttc
server/dukang-api/assets/fonts/*.otf
deploy/auto-release.env
+12
View File
@@ -195,6 +195,18 @@ export type AdminOrderRow = {
quantity?: number;
receiverName: string;
receiverPhone: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
receiverAddress?: string;
benefitAmount?: number;
benefitCoupon?: {
couponNo?: string;
totalAmount?: number;
usedAmount?: number;
balance?: number;
status?: string;
} | null;
createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
+16
View File
@@ -8,3 +8,19 @@ export function downloadExcelCsv(csv: string, filename: string) {
a.click();
URL.revokeObjectURL(url);
}
/** Download binary file from base64 payload returned by HQ export APIs. */
export function downloadBase64File(contentBase64: string, filename: string, mimeType: string) {
const binary = atob(contentBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+284 -99
View File
@@ -4,7 +4,9 @@ import {
Alert,
Button,
Checkbox,
Col,
Collapse,
DatePicker,
Descriptions,
Drawer,
Form,
@@ -12,6 +14,7 @@ import {
InputNumber,
Modal,
Radio,
Row,
Select,
Space,
Table,
@@ -20,7 +23,10 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { downloadBase64File } from '../lib/exportExcel';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
@@ -130,6 +136,110 @@ type OrderDetail = AdminOrderRow & {
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
type OrderExportScope = 'filter' | 'selected';
type OrderExportFormat = 'xlsx' | 'pdf';
type OrderExportFilters = {
orderNo?: string;
status?: string;
orderType?: string;
cityId?: string;
receiverPhone?: string;
fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string;
dateRange?: [Dayjs, Dayjs];
};
type OrderExportResult = {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
};
const ORDER_TYPE_OPTIONS = Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => ({ value, label }));
const DELIVERY_TYPE_OPTIONS = [
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
];
function isOnSitePickupOrder(row: Pick<AdminOrderRow, 'deliveryType' | 'receiverProvince' | 'receiverCity' | 'receiverAddress'>) {
return (
row.deliveryType === 'ON_SITE_PICKUP' ||
row.receiverAddress === '现场取货' ||
row.receiverAddress === '现场提货' ||
(row.receiverProvince === '现场' && row.receiverCity === '现场')
);
}
function formatReceiverAddress(row: AdminOrderRow) {
if (isOnSitePickupOrder(row)) return '现场取货';
const region = [row.receiverProvince, row.receiverCity, row.receiverDistrict].filter(Boolean).join('');
const detail = row.receiverAddress || '';
if (!region) return detail;
if (!detail || detail.startsWith(region)) return detail || region;
return `${region}${detail}`;
}
function datePresetRange(kind: 'day' | 'week' | 'month' | 'quarter' | 'year'): [Dayjs, Dayjs] {
const now = dayjs();
if (kind === 'day') return [now.startOf('day'), now.endOf('day')];
if (kind === 'week') {
const monday = now.startOf('day').subtract((now.day() + 6) % 7, 'day');
return [monday, monday.add(6, 'day').endOf('day')];
}
if (kind === 'month') return [now.startOf('month'), now.endOf('month')];
if (kind === 'quarter') {
const start = now.month(Math.floor(now.month() / 3) * 3).startOf('month');
return [start, start.add(2, 'month').endOf('month')];
}
return [now.startOf('year'), now.endOf('year')];
}
const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year'; label: string }> = [
{ key: 'day', label: '当日' },
{ key: 'week', label: '当周' },
{ key: 'month', label: '当月' },
{ key: 'quarter', label: '当季' },
{ key: 'year', label: '当年' },
];
function formatBenefitBrief(row: AdminOrderRow) {
const coupon = row.benefitCoupon;
if (coupon) {
return `总额¥${Number(coupon.totalAmount ?? 0).toFixed(0)} / 已用¥${Number(coupon.usedAmount ?? 0).toFixed(0)} / 余¥${Number(coupon.balance ?? 0).toFixed(0)}`;
}
if (row.benefitAmount != null) return `¥${Number(row.benefitAmount).toFixed(0)}`;
return '—';
}
function buildExportPayload(
scope: OrderExportScope,
format: OrderExportFormat,
filters: OrderExportFilters,
selectedIds: string[],
) {
const payload: Record<string, unknown> = { scope, format };
if (scope === 'selected') {
payload.ids = selectedIds;
return payload;
}
if (filters.orderNo) payload.orderNo = filters.orderNo;
if (filters.status) payload.status = filters.status;
if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.excludeTest) payload.excludeTest = true;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
return payload;
}
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
if (detail.items?.length) return detail.items;
if (!detail.productName) return [];
@@ -192,8 +302,11 @@ export default function OrdersPage() {
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -238,6 +351,7 @@ export default function OrdersPage() {
try {
const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status);
if (values.orderType) qs.set('orderType', values.orderType);
@@ -245,12 +359,15 @@ export default function OrdersPage() {
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize]);
}, [form, page, pageSize, initialOrderNo]);
useEffect(() => {
void load();
@@ -451,79 +568,93 @@ export default function OrdersPage() {
}
}
async function submitExport(scope: OrderExportScope) {
const values = form.getFieldsValue() as OrderExportFilters;
if (scope === 'selected' && !selectedRowKeys.length) {
message.warning('请先勾选要导出的订单');
return;
}
setExporting(true);
try {
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
const result = await request<OrderExportResult>('/admin/orders/export', {
method: 'POST',
body: JSON.stringify(payload),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条订单`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const columns: ColumnsType<AdminOrderRow> = [
{
title: '订单号',
dataIndex: 'orderNo',
width: 200,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{
title: '城市',
width: 90,
render: (_, row) => row.city?.name || '—',
},
{
title: '商品',
width: 180,
ellipsis: true,
render: (_, row) => {
const name = row.productName || '—';
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
return (
<span title={row.productSpec ? `${name}${row.productSpec}` : name}>
{name}{qty}
</span>
);
},
},
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (s, row) => (
width: 240,
render: (_, row) => (
<div>
<Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
<span>{row.productName || '—'}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple" title={row.proxyPartnerName || undefined}>
</Tag>
<Tag color="purple"></Tag>
) : null}
</Space>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{[row.productSpec, row.quantity != null ? `×${row.quantity}` : null]
.filter(Boolean)
.join(' ')}
</Typography.Text>
</div>
</div>
),
},
{
title: '配送',
dataIndex: 'deliveryType',
width: 90,
render: (v) =>
v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城',
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
{ title: '手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 110,
render: (_, row) => row.user?.userNo || '—',
title: '状态 / 实付',
width: 130,
render: (_, row) => (
<div>
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}>
{ORDER_STATUS_LABELS[row.status] || row.status}
</Tag>
<div>¥{row.payAmount}</div>
</div>
),
},
{
title: '快递',
width: 100,
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
title: '好客权益',
width: 200,
render: (_, row) => formatBenefitBrief(row),
},
{
title: '收货信息',
width: 240,
render: (_, row) => {
const address = formatReceiverAddress(row);
return (
<div>
<div>
{row.receiverName || '—'} {row.receiverPhone || ''}
</div>
{address ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{address}
</Typography.Text>
) : null}
</div>
);
},
},
{
title: '下单时间',
dataIndex: 'createdAt',
width: 170,
render: (v) => new Date(v).toLocaleString('zh-CN'),
render: fmtTime,
},
{
title: '操作',
@@ -557,24 +688,15 @@ export default function OrdersPage() {
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
<Space size={12}>
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
) : null}
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
</Space>
@@ -592,54 +714,115 @@ export default function OrdersPage() {
/>
) : null}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号">
<Input placeholder="DK..." allowClear />
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<Row gutter={[16, 8]}>
<Col xs={24} md={14} lg={12}>
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
<Space size={8} wrap>
{DATE_PRESETS.map((preset) => (
<Button
key={preset.key}
size="small"
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
>
{preset.label}
</Button>
))}
</Space>
</Space>
</Form.Item>
<Form.Item name="orderType" label="类型">
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Select
allowClear
style={{ width: 120 }}
placeholder="全部"
options={[
{ value: 'NORMAL', label: '普通订单' },
{ value: 'PROXY', label: '代下单' },
]}
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="cityId" label="城市">
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="收货手机">
<Input allowClear />
</Col>
<Col xs={12} sm={8} md={6} lg={6}>
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
<Input allowClear placeholder="手机号" />
</Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
<Select
allowClear
style={{ width: 140 }}
placeholder="全部"
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Col>
<Col xs={12} sm={8} md={8} lg={8}>
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
<Space size={16} wrap>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
</Space>
</Form.Item>
</Col>
</Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
{canExportOrders ? (
<Space size={12} wrap>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<Radio.Button value="xlsx">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
<Button
disabled={!selectedRowKeys.length}
loading={exporting}
onClick={() => void submitExport('selected')}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
<Button loading={exporting} onClick={() => void submitExport('filter')}>
</Button>
</Space>
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</div>
</Form>
<Table
@@ -647,16 +830,18 @@ export default function OrdersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={canDeleteOrders ? {
scroll={{ x: 1100 }}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined}
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
+8
View File
@@ -23,6 +23,7 @@
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
| 3.5.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
| 3.5.4 | 08-21 | 商品 SPU+SKU 规格;DK 码/独立 69 码/规格主图;下单兼容回落;发票品类;权益文案与门店详情/收银台 UI;客户端版本号 | [`v3.5.4`](./杜康好客-v3.5.4-开发文档.md) |
---
@@ -68,6 +69,13 @@
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
**HQ 订单导出**`admin-web` 订单监控):
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
- 格式:Excel`.xlsx`/ PDF`.pdf`
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
- 权限:`orders`;操作审计 `ORDER_EXPORT`
### 3.3 佣金与结算
| 类型 | 默认 | 释放 |
+7 -3
View File
@@ -9,7 +9,7 @@
|------|------|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
| C 端 | `mini-user` 小程序 + h5-userv3.4.13 版本门控/门店/物流已上 |
| 近期版本 | … · v3.4.16 · **v3.5.3 门店照片/企微审核/分享 + 核销金额/提现补修 + 权益图标** |
| 近期版本 | … · v3.5.3 · **v3.5.4 商品规格 SPU+SKU + 发票品类 + 权益/门店详情 UI** |
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
@@ -32,7 +32,7 @@
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
| **admin-web** | 商品/开城/门店/订单/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
## 3. 场景 SC-01~09
@@ -57,10 +57,14 @@
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | 🔶 开发中 |
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | ✅ 生产 |
| 3.5.4 | [`商品规格 SPU+SKU + 发票/文案 UI`](./杜康好客-v3.5.4-开发文档.md) | ✅ 生产(weapp 版本号需单独上传) |
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
| 日期 | 说明 |
|------|------|
| 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF`POST /admin/orders/export` |
| 2026-08-21 | v3.5.4 发生产:规格表/回填 SKU/DK 码/主图列/发票品类;H5+API 200 |
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态 |
| 2026-08-07 | v3.4.14 增补统一测试白名单(限测可见 / Mock 旁路;测试流水计入结算) |
+119 -75
View File
@@ -1,101 +1,145 @@
# 杜康好客 · v3.5.4 商品规格(SPU + SKU
# 杜康好客 · v3.5.4 开发文档
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
> 目标:商品详情页内可选销售规格;价格、权益、69 码、履约、上下架按 SKU;箱装按瓶当量起购与物流。
> **生产 commit**`5fdddae`(规格主图与门店使用规则合并入 `main`)及后续客户端版本号 `3.5.4`
> **主题**:商品规格(SPU + SKU)上线 + 同批次发票 / 文案 / 门店详情体验
## 范围
---
| # | 项 | 交付 |
## 1. 版本目标
1. 商品由单规格升级为 **SPU + 多 SKU**:价格、权益、69 码、履约、上下架、主图按规格。
2. 箱装按 **瓶当量** 起购与物流,不改城市起购阈值口径。
3. **旧客户端兼容**:不传 `skuId` 仍可下单;无 SKU 回落 SPU,禁止「商品暂无可售规格」误伤。
4. 同批次补齐:发票品类、权益文案、收银台 logo、门店详情「使用规则」、C 端版本号。
**不做**:SKU 库存、把现有酒祖 10/15/20 合并为一个 SPU。
---
## 2. 功能清单(完整)
### 2.1 商品规格(核心)
| # | 模块 | 交付说明 |
|---|------|----------|
| 1 | 数据模型 | `common_product_item` 作 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
| 2 | 存量迁移 | 每商品默认 1 个瓶装 SKU;SQL `migrate-product-sku-v354.sql`;脚本 `backfill-product-skus.ts` |
| 3 | Catalog | 列表 `specEnabled`/`saleUnit`;详情 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
| 4 | Trade | preview/create 可选 `skuId`;有可售 SKU → 默认/唯一;**0 可售 → 回落 SPU**;订单 `sku_id` 可空 |
| 5 | Domain | 起购/大单/物流按 `quantity × bottlesPerUnit` 瓶当量 |
| 6 | Admin | `PUT .../specs``PUT .../skus`;抽屉加宽;「填写」弹窗;规格轴 + SKU 矩阵 |
| 7 | mini-user | 详情规格 chips;确认页带 `skuId`;箱装数量文案;选规格切主图 |
| 8 | 代下单 | HQ / 合伙人可选规格 SKU |
### 2.2 SKU 码与 69 码
| # | 项 | 说明 |
|---|----|------|
| 1 | 数据模型 | `common_product_item` 升级为 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
| 2 | 存量迁移 | 每商品 1 个默认瓶装 SKU;SQL:`prisma/migrate-product-sku-v354.sql`;脚本:`prisma/backfill-product-skus.ts` |
| 3 | Catalog | 列表附加 `specEnabled`/`saleUnit`;详情附加 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
| 4 | Trade | preview/create 可选 `skuId`;无 SKU 回落 SPU;有可售 SKU 用默认/唯一 |
| 5 | Admin | `PUT /admin/products/:id/specs``PUT .../skus`;商品抽屉加宽;规格弹窗填写;每规格主图 |
| 6 | mini-user | 详情规格 chips;选规格切主图;确认页带 `skuId`;箱装数量文案 |
| 7 | 代下单 | HQ / 合伙人可选规格 SKU |
| 8 | 权益文案 | 「好客权益券」→「好客权益」;首页角标「享{amount}好客权益」(去掉门店 icon) |
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可填;每规格独立 69 码 |
| 10 | 规格主图 | `common_product_sku.image_url` 可选;空则 C 端回落商品封面 |
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可手填;脚本 `rewrite-sku-codes-dk.ts` 改存量 |
| 10 | 69 码 | **每规格独立**;同商品内 + 跨商品唯一;不可与其它 SKU 冲突 |
**不做**:库存、把现有酒祖 10/15/20 合并为一个 SPU。
### 2.3 规格主图
## 兼容规则(线上)
| # | 项 | 说明 |
|---|----|------|
| 11 | DB | `common_product_sku.image_url``migrate-sku-image.sql` |
| 12 | Admin | 弹窗可为每 SKU 上传主图 |
| 13 | C 端 | 选规格优先 SKU 主图,空则回落商品封面 |
- 不改 `/api/v1` 前缀;**只增字段,不删旧字段语义**。
### 2.4 发票(同批次)
| # | 项 | 说明 |
|---|----|------|
| 14 | 品类 | `user_invoice.invoice_category``LIQUOR` 酒水类 / `CATERING` 餐饮类 |
| 15 | C 端申请 | 票种写死「增值税普通发票」;类型选酒水/餐饮;备注写入申请 |
| 16 | 抬头 | 必填项打 `*`;强调邮箱必填 |
| 17 | Admin | 发票列表展示品类;专票仍可走后台 |
### 2.5 文案与 UI(同批次)
| # | 位置 | 变更 |
|---|------|------|
| 18 | 全局权益文案 | 「好客权益券」→「好客权益」 |
| 19 | 首页角标 | 「享{amount}好客权益」,去掉门店 icon |
| 20 | 商品详情 | 规格区与「好客权益」说明加大间距 |
| 21 | 待支付/收银台 | 顶部 logo 用 `logo2.png`(杜康印章) |
| 22 | 门店详情 | 「使用规则」红色,置于「门店详情」上方;详情正文限高可滚动 |
| 23 | 客户端版本号 | `package.json` + `client-version.ts`**3.5.4**(「我的」页展示) |
---
## 3. 兼容规则(线上)
- 不改 `/api/v1`**只增字段,不删旧字段语义**。
- 旧客户端不传 `skuId`
- ≥1 个可售 SKU → 默认(或第一个)可售 SKU
- **没有任何可售 SKU(未回填)→ 回落 SPU 字段,照常下单**(禁止再报「商品暂无可售规格」)
- `skuId`(新客户端选规格)→ 按该 SKU 校验。
- 旧 admin `PUT /admin/products/:id` 不传规格:仅同步**唯一**默认 SKU(多规格时不同步,防误改);不同步覆盖已生成的 DK 码。
- SPU 上 `sku_code` / `barcode_69` 去掉唯一约束,唯一下沉到 `common_product_sku`SPU 列保留为默认 SKU 冗余。
- 订单 `sku_id` 可空;无规格快照走商品 69 码 / 价格。
- ≥1 个可售 SKU → 默认(或第一个)可售 SKU
- **0 个可售 SKU → 回落 SPU,照常下单**
- 新客户端`skuId` → 按该 SKU 校验。
- 旧 admin 不传规格:仅同步**唯一**默认 SKU;不覆盖已生成的 DK 码。
- SPU 上 `sku_code` / `barcode_69` 唯一约束下沉到 SKUSPU 列保留冗余。
## 发版一并执行
---
按顺序在**目标环境库**执行(先测试后生产)。本机 `npx ts-node …` 只改本地 `.env` 库,不会自动打线上。
## 4. 发版与生产执行记录
1. **规格表** `server/dukang-api/prisma/migrate-product-sku-v354.sql`
建 spec/sku 表,订单加 `sku_id` / `sale_unit` / `bottles_per_unit`
2. **回填默认 SKU**
`cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts`
尚无 SKU 的商品各建 1 条默认瓶装 SKU(码用 DK)。
3. **SKU 主图列** `server/dukang-api/prisma/migrate-sku-image.sql`
```sql
ALTER TABLE `common_product_sku`
ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`;
```
4. **SKU 码改 DK**(生产须在服务器用 `.env.production` 的 `DATABASE_URL`
`npx dotenv -e .env.production -- npx ts-node prisma/rewrite-sku-codes-dk.ts`
已是 `DK`+数字的不动;`JZ-10` / `QX-001` 等会改。**不改 69 码、订单、价格。**
5. **发票品类列**(同批次)`server/dukang-api/prisma/migrate-invoice-category.sql`
```sql
ALTER TABLE `user_invoice`
ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`;
```
6. 发布 API + mini-user + admin-web。
**此时不要给商品加第二规格**,等新小程序上线后再配「单瓶 / 整箱」。
按序在目标库执行(先测后产)。本机脚本只打本地 `.env`
## 起购与物流
| 步骤 | 动作 | 生产状态(2026-08-21 |
|------|------|------------------------|
| 1 | `migrate-product-sku-v354.sql` / Prisma sync | ✅ `db push` 已对齐 |
| 2 | `backfill-product-skus.ts` | ✅ 回填 9 个默认 SKU(扫 10 商品) |
| 3 | `migrate-sku-image.sql` / schema `image_url` | ✅ |
| 4 | `rewrite-sku-codes-dk.ts` | ✅ 13 条均已 DK,改写 0 |
| 5 | `migrate-invoice-category.sql` | ✅ schema 已含 |
| 6 | 发 API + mini-user H5 + admin | ✅ `mini-user 200` / `api 200` |
| 7 | 微信小程序 weapp 上传 | ⚠️ 需单独上传后「我的」页才见 v3.5.4 |
运营注意:新包上线前**不要**给商品加第二规格;上线后再配「单瓶 / 整箱」。
---
## 5. 起购与物流
- `bottleQty = quantity × bottlesPerUnit`
- 城市起购阈值仍是瓶;箱装 SKU`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6
- 大单拦截、承运商 `goodsNum` 使用瓶当量
- 城市起购仍按瓶;箱装(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6
- 大单拦截、承运商 `goodsNum` 用瓶当量
## 关键表摘要
---
## 6. 关键表 / 字段摘要
- `common_product_spec_attr` / `common_product_spec_value`
- `common_product_sku``sale_unit`=`BOTTLE|BOX``bottles_per_unit``image_url` 可选
- `common_product_sku``sale_unit``bottles_per_unit``image_url`
- `common_product_sku_spec`
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
- `user_invoice.invoice_category``LIQUOR` 酒水类 / `CATERING` 餐饮类;票种 C 端固定增值税普通发票
- `user_invoice.invoice_category`
## SKU 码与 69 码
---
- SKU 码:服务端生成 `DK000001` 起,后台只展示。
- 每个规格必须填写**互不相同**的 69 码(全局不可与其它商品 SKU 冲突)。
- 总部「规格与 SKU」点「填写」弹窗编辑 69 码 / 价 / 履约 / 主图。
## 7. 关键路径(便于排查)
## 权益与小程序 UI(同批次)
| 域 | 路径 |
|----|------|
| Prisma / 脚本 | `server/dukang-api/prisma/migrate-product-sku-v354.sql``backfill-product-skus.ts``rewrite-sku-codes-dk.ts``migrate-sku-image.sql``migrate-invoice-category.sql` |
| 规格工具 | `server/.../catalog/product-sku.util.ts` |
| Admin 规格 | `apps/admin-web/src/components/ProductSpecsEditor.tsx` |
| C 端详情 | `apps/mini-user/src/pages/product-detail/` |
| 版本号 | `apps/mini-user/src/lib/client-version.ts` |
| 门店详情 | `apps/mini-user/src/pages/store-detail/` |
| 发票 | `apps/mini-user/src/pages/invoice-apply/``invoice-titles/` |
| 位置 | 变更 |
|------|------|
| 门店详情等 | 「好客权益券」→「好客权益」 |
| 首页商品角标 `CouponBadge` | 纯文案 **「享{amount}好客权益」**,无门店 icon |
| 商品详情 | 规格区与「好客权益」说明之间加大间距 |
| 待支付 / 收银台 | 顶部 logo 使用 `public/images/logo2.png`(杜康印章) |
| 门店详情 | 「使用规则」四字红色,放在「门店详情」**上面**;门店详情正文限高,超出上下滚动 |
| 发票申请 | 票种写死增值税普通发票;类型选酒水类/餐饮类;申请备注;抬头邮箱必填打 `*` |
---
其它页 `BenefitFigure`(我的/权益/核销等)仍保留门店核销图标,仅首页角标去 icon。
## 8. 验收清单
## 验收
- [ ] 未配规格:旧小程序/H5/代下单路径与现网一致(无 SKU 也能下单)
- [ ] 多规格:详情选规格后价格/权益/履约/主图变化
- [ ] 整箱 SKU:数量 1 过起购;订单快照 `bottles_per_unit=6`
- [ ] `GET /catalog/products/:id` 仍含 `id/name/price/spec/skuCode/...`
- [ ] 小程序无「好客权益券」字样;首页角标为「享{金额}好客权益」且无门店 icon
- [ ] 后台 SKU 码为 DK 开头且不可手填;每规格独立 69 码与主图
- [ ] 门店详情「使用规则」红色且在门店详情上方;门店详情超长可滚动
- [ ] 待支付页顶部为 logo2 印章
- [ ] 未配规格:旧端下单与现网一致(无 SKU 也能下)
- [ ] 多规格:选规格后价格/权益/履约/主图变化;漏选 400
- [ ] 整箱:数量 1 过起购;订单 `bottles_per_unit=6`
- [ ] 详情 API 仍含 `id/name/price/spec/skuCode/...`
- [ ] 后台 SKU 码 DK 开头不可手填;每规格独立 69 码与主图
- [ ] 无「好客权益券」;首页角标「享{金额}好客权益」无门店 icon
- [ ] 门店详情「使用规则」红色且在详情上方;详情超长可滚
- [ ] 待支付页为 logo2 印章
- [ ] 发票:普票固定 + 酒水/餐饮品类 + 邮箱必填
- [ ] 「我的」页版本 **杜康好客 v3.5.4**(需 weapp 新包)
+170
View File
@@ -0,0 +1,170 @@
# 杜康好客 · v3.5.5 开发文档
> **2026-08-23** · admin-web / API
> **主题**HQ 订单监控导出(Excel / PDF)+ 订单列表页布局优化
---
## 1. 版本目标
1. 总部 **订单监控** 支持按筛选或勾选导出 **Excel.xlsx** / **PDF.pdf**
2. 导出内容聚焦运营履约:**商品明细、收货信息、好客权益摘要**;次要字段从列表与导出列中精简。
3. **订单列表页** 筛选区改为网格布局,支持日期快捷选择;导出、查询、重置、批量删除同一操作行。
4. 修复现场取货订单地址展示为「现场现场取货现场取货」的问题。
**不做**:C 端 / 合伙人 / 门店端改动;无 Prisma 迁移。
---
## 2. 功能清单
### 2.1 HQ 订单导出(核心)
| # | 模块 | 交付说明 |
|---|------|----------|
| 1 | API | `POST /api/v1/admin/orders/export` — 生成文件,响应 `{ filename, mimeType, contentBase64, count }` |
| 2 | 预览 | `POST /api/v1/admin/orders/export/preview` — 返回 `{ count, max, exceeds }`(上限 5000 |
| 3 | 导出范围 | `scope=filter` 按当前筛选;`scope=selected` 仅导出勾选 `ids` |
| 4 | 格式 | `format=xlsx`exceljs/ `format=pdf`pdfkit,横向 A4 |
| 5 | 权限 | `HqAuthGuard` + `orders`;操作审计 `ORDER_EXPORT` |
| 6 | 筛选复用 | `buildOrderWhere()` 与列表查询共用;新增 `deliveryType` 筛选 |
**导出列**11 列):
订单号、下单时间、状态、商品、规格、数量、实付、好客权益、收货人、手机、收货地址。
好客权益列:有权益券时 `总额¥X / 已用¥Y / 余¥Z`;否则回落订单 `benefitAmount`
### 2.2 订单列表页(admin-web
| # | 项 | 说明 |
|---|----|------|
| 7 | 筛选布局 | 网格表单:下单日期、状态、类型、配送、城市、收货手机;大单拦截 / 过滤测试 |
| 8 | 日期快捷 | 当日、当周(周一至周日)、当月、当季、当年 |
| 9 | 列表列 | 商品(含规格/数量/标签)、状态+实付、好客权益、收货信息、下单时间、操作 |
| 10 | 勾选导出 | 列表常驻勾选;左侧 Excel/PDF +「导出已勾选」「导出全部筛选」 |
| 11 | 操作行 | 右侧同一行:查询、重置、批量删除(有权限时) |
| 12 | 现场取货 | 收货地址识别 `ON_SITE_PICKUP` 或省市区占位后,统一显示「现场取货」 |
### 2.3 列表数据增强
| # | 项 | 说明 |
|---|----|------|
| 13 | 权益关联 | `GET /admin/orders` include `benefitCoupon`(券号、总额/已用/余额、状态) |
| 14 | 收货字段 | `AdminOrderRow` 增加省市区、地址、`benefitAmount``benefitCoupon` 类型 |
---
## 3. API 契约
### 3.1 导出请求体 `AdminOrdersExportDto`
```json
{
"scope": "filter",
"format": "xlsx",
"ids": ["123"],
"status": "PENDING_SHIP",
"orderType": "NORMAL",
"cityId": "1",
"receiverPhone": "138",
"deliveryType": "LOCAL",
"fulfillmentHold": true,
"excludeTest": true,
"createdFrom": "2026-08-01",
"createdTo": "2026-08-23"
}
```
| 字段 | 说明 |
|------|------|
| `scope` | `filter` \| `selected`(勾选时仅传 `ids`,忽略其它筛选) |
| `format` | `xlsx` \| `pdf` |
| `createdFrom` / `createdTo` | 按筛选导出且均未填时,服务端默认近 30 天 |
### 3.2 错误码
| 场景 | HTTP | message |
|------|------|---------|
| 超过 5000 条 | 400 | 超过 5000 条,请缩小日期或筛选条件 |
| 无数据 | 400 | 没有可导出的订单 |
| 勾选为空 | 400 | 请先勾选要导出的订单 |
| PDF 无字体 | 500 | 未找到可用于 PDF 的中文字体… |
### 3.3 列表查询增量
`GET /admin/orders` 查询参数新增(与导出筛选一致):
- `deliveryType``LOCAL` \| `CROSS_CITY` \| `ON_SITE_PICKUP`
- `createdFrom` / `createdTo`:下单日期(已有 DTO,本版接 UI)
---
## 4. 依赖与部署
### 4.1 新增 npm 依赖(仅 API
- `exceljs` — xlsx 生成
- `pdfkit` + `@types/pdfkit` — pdf 生成
### 4.2 PDF 中文字体
路径:`server/dukang-api/assets/fonts/`(见 `README.md`)。
解析顺序:
1. 环境变量 `EXPORT_PDF_FONT_PATH`
2. `assets/fonts/NotoSansSC-Regular.otf` / `simhei.ttf` / `msyh.ttc`
3. Linux 系统 Noto CJK
4. Windows 系统字体(开发机)
> 字体二进制已加入 `.gitignore`;生产部署需放置字体或安装 `fonts-noto-cjk`。
### 4.3 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | `pnpm install`(根目录,拉取 exceljs/pdfkit |
| 2 | 确认 `server/dukang-api/assets/fonts/` 有中文字体(PDF 必需) |
| 3 | `pnpm build`API + admin-web |
| 4 | 发 `dukang-api` + `admin-web`**无需** `prisma migrate` |
---
## 5. 关键路径(便于排查)
| 域 | 路径 |
|----|------|
| 导出工具 | `server/dukang-api/src/modules/ops/admin-order-export.util.ts` |
| 订单服务 | `server/dukang-api/src/modules/ops/admin-orders.service.ts` |
| 控制器 | `server/dukang-api/src/modules/ops/admin-orders.controller.ts` |
| DTO | `server/dukang-api/src/modules/ops/dto/admin-query.dto.ts` |
| 审计常量 | `server/dukang-api/src/common/hq-operation/hq-operation.constants.ts``ORDER_EXPORT` |
| 前端页面 | `apps/admin-web/src/pages/OrdersPage.tsx` |
| 下载工具 | `apps/admin-web/src/lib/exportExcel.ts``downloadBase64File` |
| 类型 | `apps/admin-web/src/lib/api.ts``AdminOrderRow` |
| 需求 | `docs/杜康好客-v3-PRD.md` §3.2 HQ 订单导出 |
| 现状 | `docs/杜康好客-v3-现状对照.md` |
---
## 6. 验收清单
- [ ] 订单列表:网格筛选 + 日期快捷(当日/当周/当月/当季/当年)可用
- [ ] 列表展示:商品(规格×数量)、状态/实付、好客权益、收货人+手机+地址
- [ ] 现场取货订单地址仅显示「现场取货」,不出现重复拼接
- [ ] 勾选若干订单 →「导出已勾选」→ xlsx / pdf 条数与勾选一致
- [ ] 设筛选条件 →「导出全部筛选」→ 文件含筛选范围内全部订单(≤5000)
- [ ] 超 5000 条时导出全部筛选返回明确错误
- [ ] Excel / PDF 中文正常;PDF 表头与分页可读
- [ ]`orders` 权限账号无法调用导出接口
- [ ] HQ 操作日志有 `ORDER_EXPORT` 记录
- [ ] 无删除权限账号仍可勾选并导出;有删除权限时批量删除与导出/查询同一行
---
## 7. 与 v3.5.4 关系
- **仅 HQ 内部工具**,不影响 C 端小程序版本号。
- 商品规格(SPU/SKU)、发票等 v3.5.4 能力保持不变。
- PRD §3.2 已补充 HQ 订单导出业务规则;本版为实现与 UI 落地说明。
+598 -77
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
# PDF 导出字体
订单 PDF 导出需要支持中文的 TTF/OTF/TTC 字体。按优先级尝试:
1. 环境变量 `EXPORT_PDF_FONT_PATH`
2. `assets/fonts/NotoSansSC-Regular.otf`(推荐,SIL OFL
3. `assets/fonts/simhei.ttf`
4. `assets/fonts/msyh.ttc`
5. Linux 系统字体(如 `fonts-noto-cjk`
6. Windows 系统字体(开发机)
本地开发可将 `C:\Windows\Fonts\simhei.ttf` 复制到本目录。
+2 -1
View File
@@ -3,6 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [{ "include": "../assets/**/*", "outDir": "dist", "watchAssets": true }]
}
}
+5 -1
View File
@@ -21,7 +21,8 @@
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts"
},
"dependencies": {
"@alicloud/dysmsapi20170525": "^4.6.0",
@@ -43,9 +44,11 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"ioredis": "^5.4.1",
"ip2region": "^2.3.0",
"pdfkit": "^0.19.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
@@ -57,6 +60,7 @@
"@types/express": "^4.17.21",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/pdfkit": "^0.17.6",
"@types/qrcode": "^1.5.6",
"prisma": "^5.18.0",
"ts-node": "^10.9.2",
@@ -0,0 +1,352 @@
/**
* 方案 A:把 DK000007 / DK000008 合并为同一 SPU,保留原 SKU id 与 DK 码,订单改绑到对应 SKU。
*
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts
* 预览:npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts --dry-run
*
* 幸存 SPU = DK000007 所在商品;默认规格 = 国标特级20。
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const CODES = ['DK000007', 'DK000008'] as const;
const SURVIVOR_CODE = 'DK000007';
const SPEC_ATTR_NAME = '规格';
const SPU_NAME = '酒祖杜康(国标特级)';
const SPEC_LABEL: Record<(typeof CODES)[number], string> = {
DK000007: '国标特级20',
DK000008: '国标特级30',
};
const dryRun = process.argv.includes('--dry-run');
function buildSpecKey(valueIds: bigint[]): string {
if (!valueIds.length) return '';
return [...valueIds]
.map((id) => id.toString())
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0))
.join('_');
}
async function ensureSkuImageColumn() {
const rows = await prisma.$queryRaw<Array<{ cnt: bigint | number }>>`
SELECT COUNT(*) AS cnt
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'common_product_sku'
AND COLUMN_NAME = 'image_url'
`;
const cnt = Number(rows[0]?.cnt ?? 0);
if (cnt > 0) return;
await prisma.$executeRawUnsafe(
'ALTER TABLE `common_product_sku` ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`',
);
console.log('added column common_product_sku.image_url');
}
async function resolveProductByCode(skuCode: string) {
const sku = await prisma.commonProductSku.findUnique({
where: { skuCode },
include: { product: true },
});
if (sku) return { product: sku.product, sku };
const product = await prisma.commonProductItem.findFirst({
where: { skuCode },
});
if (!product) {
throw new Error(`找不到商品/SKU${skuCode}`);
}
return { product, sku: null as null };
}
async function coverUrlOf(productId: bigint, coverResourceId: bigint | null) {
if (coverResourceId) {
const res = await prisma.commonResource.findUnique({
where: { id: coverResourceId },
select: { url: true },
});
if (res?.url) return res.url;
}
const fallback = await prisma.commonResource.findFirst({
where: { ownerType: 'PRODUCT', ownerId: productId, bizType: 'COVER', status: 'ACTIVE' },
orderBy: { id: 'asc' },
select: { url: true },
});
return fallback?.url ?? null;
}
async function ensureSkuFromProduct(
tx: PrismaClient,
product: {
id: bigint;
skuCode: string;
barcode69: string;
spec: string;
price: unknown;
benefitAmount: unknown;
status: 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
allowOnSitePickup: boolean;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
},
) {
const existing =
(await tx.commonProductSku.findUnique({ where: { skuCode: product.skuCode } })) ??
(await tx.commonProductSku.findFirst({
where: { productId: product.id, isDefault: true },
}));
if (existing) return existing;
return tx.commonProductSku.create({
data: {
productId: product.id,
skuCode: product.skuCode,
barcode69: product.barcode69,
specKey: '',
specText: product.spec,
price: product.price as never,
benefitAmount: product.benefitAmount as never,
status: product.status,
allowOnSitePickup: product.allowOnSitePickup,
allowOnlinePurchase: product.allowOnlinePurchase,
allowCrossCityDelivery: product.allowCrossCityDelivery,
saleUnit: 'BOTTLE',
bottlesPerUnit: 1,
isDefault: true,
sortOrder: 0,
},
});
}
async function main() {
await ensureSkuImageColumn();
const rows = [];
for (const code of CODES) {
const resolved = await resolveProductByCode(code);
rows.push({ code, ...resolved });
}
const survivorRow = rows.find((r) => r.code === SURVIVOR_CODE);
if (!survivorRow) throw new Error(`缺少幸存码 ${SURVIVOR_CODE}`);
const discardRows = rows.filter((r) => r.product.id !== survivorRow.product.id);
const survivorId = survivorRow.product.id;
console.log(
JSON.stringify(
{
dryRun,
survivor: {
productId: survivorId.toString(),
name: survivorRow.product.name,
skuCode: survivorRow.product.skuCode,
},
items: await Promise.all(
rows.map(async (r) => {
const orderCount = await prisma.order.count({ where: { productId: r.product.id } });
const nullSku = await prisma.order.count({
where: { productId: r.product.id, skuId: null },
});
return {
code: r.code,
productId: r.product.id.toString(),
name: r.product.name,
skuId: r.sku?.id.toString() ?? null,
orders: orderCount,
nullSkuOrders: nullSku,
};
}),
),
},
null,
2,
),
);
if (discardRows.length === 0 && survivorRow.sku) {
const sibling = await prisma.commonProductSku.findMany({
where: { productId: survivorId, skuCode: { in: [...CODES] } },
});
if (sibling.length === CODES.length) {
console.log('SKU 已在同一 SPU 上,继续核对订单 product_id / sku_id');
}
}
if (dryRun) {
console.log('[dry-run] skip writes');
return;
}
await prisma.$transaction(
async (tx) => {
const db = tx as unknown as PrismaClient;
const skuByCode = new Map<string, { id: bigint }>();
for (const row of rows) {
const sku = await ensureSkuFromProduct(db, row.product);
skuByCode.set(row.code, sku);
console.log(`ensured sku ${row.code} id=${sku.id} fromProduct=${row.product.id}`);
}
let attr = await db.commonProductSpecAttr.findFirst({
where: { productId: survivorId, name: SPEC_ATTR_NAME },
});
if (!attr) {
attr = await db.commonProductSpecAttr.create({
data: { productId: survivorId, name: SPEC_ATTR_NAME, sortOrder: 0 },
});
}
const valueIdByCode = new Map<string, bigint>();
for (let i = 0; i < CODES.length; i++) {
const code = CODES[i];
const label = SPEC_LABEL[code];
let value = await db.commonProductSpecValue.findFirst({
where: { attrId: attr.id, name: label },
});
if (!value) {
value = await db.commonProductSpecValue.create({
data: { attrId: attr.id, name: label, sortOrder: i },
});
}
valueIdByCode.set(code, value.id);
}
for (let i = 0; i < CODES.length; i++) {
const code = CODES[i];
const sku = skuByCode.get(code)!;
const valueId = valueIdByCode.get(code)!;
const specKey = buildSpecKey([valueId]);
const specText = SPEC_LABEL[code];
const sourceProduct = rows.find((r) => r.code === code)!.product;
const imageUrl = await coverUrlOf(sourceProduct.id, sourceProduct.coverResourceId);
const isDefault = code === SURVIVOR_CODE;
await db.commonProductSkuSpec.deleteMany({ where: { skuId: sku.id } });
await db.commonProductSku.update({
where: { id: sku.id },
data: {
product: { connect: { id: survivorId } },
specKey,
specText,
isDefault,
sortOrder: i,
imageUrl,
status: sourceProduct.status,
},
});
await db.commonProductSkuSpec.create({
data: { skuId: sku.id, valueId },
});
const orderRes = await db.order.updateMany({
where: {
OR: [{ skuId: sku.id }, { productId: sourceProduct.id, skuId: null }],
},
data: { productId: survivorId, skuId: sku.id },
});
console.log(
`bound ${code} → product ${survivorId} sku ${sku.id} spec=${specText} orders=${orderRes.count}`,
);
}
const leftover = await db.order.findMany({
where: { productId: { in: discardRows.map((r) => r.product.id) } },
select: { id: true, orderNo: true, productId: true, skuId: true },
});
if (leftover.length) {
throw new Error(
`仍有订单绑在被合并 SPU 上:${leftover.map((o) => o.orderNo).join(', ')}`,
);
}
for (const discard of discardRows) {
const phones = await db.commonProductVisibilityPhone.findMany({
where: { productId: discard.product.id },
});
for (const p of phones) {
await db.commonProductVisibilityPhone.upsert({
where: { productId_phone: { productId: survivorId, phone: p.phone } },
create: { productId: survivorId, phone: p.phone },
update: {},
});
}
await db.commonProductVisibilityPhone.deleteMany({
where: { productId: discard.product.id },
});
await db.commonProductSkuSpec.deleteMany({
where: { sku: { productId: discard.product.id } },
});
await db.commonProductSku.deleteMany({ where: { productId: discard.product.id } });
await db.commonProductSpecAttr.deleteMany({ where: { productId: discard.product.id } });
await db.commonResource.deleteMany({
where: { ownerType: 'PRODUCT', ownerId: discard.product.id },
});
await db.commonProductItem.update({
where: { id: discard.product.id },
data: { status: 'OFF_SALE', coverResourceId: null },
});
await db.commonProductItem.delete({ where: { id: discard.product.id } });
console.log(`deleted discard SPU ${discard.product.id} ${discard.code}`);
}
const defaultSku = await db.commonProductSku.findUniqueOrThrow({
where: { skuCode: SURVIVOR_CODE },
});
await db.commonProductItem.update({
where: { id: survivorId },
data: {
name: SPU_NAME,
skuCode: defaultSku.skuCode,
barcode69: defaultSku.barcode69,
spec: defaultSku.specText,
price: defaultSku.price,
benefitAmount: defaultSku.benefitAmount,
allowOnSitePickup: defaultSku.allowOnSitePickup,
allowOnlinePurchase: defaultSku.allowOnlinePurchase,
allowCrossCityDelivery: defaultSku.allowCrossCityDelivery,
status: 'ON_SALE',
},
});
},
{ timeout: 30000 },
);
const skus = await prisma.commonProductSku.findMany({
where: { skuCode: { in: [...CODES] } },
orderBy: { sortOrder: 'asc' },
});
const orders = await prisma.order.groupBy({
by: ['productId', 'skuId'],
where: { sku: { skuCode: { in: [...CODES] } } },
_count: true,
});
console.log(
JSON.stringify(
{
done: true,
skus: skus.map((s) => ({
id: s.id.toString(),
productId: s.productId.toString(),
skuCode: s.skuCode,
specText: s.specText,
isDefault: s.isDefault,
})),
orders: orders.map((o) => ({
productId: o.productId.toString(),
skuId: o.skuId?.toString() ?? null,
count: o._count,
})),
},
null,
2,
),
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -24,6 +24,7 @@ export const HqOperationAction = {
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
ORDER_DELETE: 'ORDER_DELETE',
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
ORDER_EXPORT: 'ORDER_EXPORT',
STORE_CREATE: 'STORE_CREATE',
STORE_UPDATE: 'STORE_UPDATE',
STORE_STATUS: 'STORE_STATUS',
@@ -147,6 +148,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
[HqOperationAction.ORDER_DELETE]: '删除订单',
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
[HqOperationAction.ORDER_EXPORT]: '导出订单',
[HqOperationAction.STORE_CREATE]: '新增门店',
[HqOperationAction.STORE_UPDATE]: '编辑门店',
[HqOperationAction.STORE_STATUS]: '变更门店状态',
@@ -0,0 +1,211 @@
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
import ExcelJS from 'exceljs';
import * as fs from 'fs';
import * as path from 'path';
import PDFDocument from 'pdfkit';
const EXPORT_HEADERS = [
'订单号',
'下单时间',
'状态',
'商品',
'规格',
'数量',
'实付',
'好客权益',
'收货人',
'手机',
'收货地址',
] as const;
export type OrderExportRow = {
orderNo: string;
createdAt: string;
status: string;
productName: string;
productSpec: string;
quantity: number;
payAmount: number;
benefitBrief: string;
receiverName: string;
receiverPhone: string;
address: string;
};
function formatBenefitBrief(order: {
benefitAmount?: { toString(): string } | number | null;
benefitCoupon?: {
totalAmount?: { toString(): string } | number;
usedAmount?: { toString(): string } | number;
balance?: { toString(): string } | number;
} | null;
}): string {
const coupon = order.benefitCoupon;
if (coupon) {
return `总额¥${Number(coupon.totalAmount).toFixed(2)} / 已用¥${Number(coupon.usedAmount).toFixed(2)} / 余¥${Number(coupon.balance).toFixed(2)}`;
}
if (order.benefitAmount != null) return `¥${Number(order.benefitAmount).toFixed(2)}`;
return '';
}
export function formatExportDateTime(value?: Date | null): string {
if (!value) return '';
return value.toISOString().slice(0, 19).replace('T', ' ');
}
export function mapOrderToExportRow(order: {
orderNo: string;
createdAt: Date;
status: string;
productName: string;
productSpec: string;
quantity: number;
payAmount: { toString(): string } | number;
benefitAmount?: { toString(): string } | number | null;
receiverName: string;
receiverPhone: string;
receiverProvince: string;
receiverCity: string;
receiverDistrict: string;
receiverAddress: string;
benefitCoupon?: {
totalAmount?: { toString(): string } | number;
usedAmount?: { toString(): string } | number;
balance?: { toString(): string } | number;
} | null;
deliveryType?: string;
}): OrderExportRow {
const isOnSite =
order.deliveryType === 'ON_SITE_PICKUP' ||
order.receiverAddress === '现场取货' ||
order.receiverAddress === '现场提货' ||
(order.receiverProvince === '现场' && order.receiverCity === '现场');
const address = isOnSite
? '现场取货'
: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}${order.receiverAddress}`;
return {
orderNo: order.orderNo,
createdAt: formatExportDateTime(order.createdAt),
status: ORDER_STATUS_LABELS[order.status] || order.status,
productName: order.productName,
productSpec: order.productSpec,
quantity: order.quantity,
payAmount: Number(order.payAmount),
benefitBrief: formatBenefitBrief(order),
receiverName: order.receiverName,
receiverPhone: order.receiverPhone,
address,
};
}
export function rowToCells(row: OrderExportRow): string[] {
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,
];
}
function resolvePdfFontPath(): string {
const candidates = [
process.env.EXPORT_PDF_FONT_PATH,
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
'C:\\Windows\\Fonts\\simhei.ttf',
'C:\\Windows\\Fonts\\msyh.ttc',
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
}
export async function buildOrdersXlsx(rows: OrderExportRow[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('订单');
sheet.addRow([...EXPORT_HEADERS]);
for (const row of rows) {
sheet.addRow(rowToCells(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<Buffer> {
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));
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);
for (const row of rows) {
drawRow(rowToCells(row));
}
doc.end();
});
}
export function buildExportFilename(format: 'xlsx' | 'pdf', count: number): string {
const stamp = new Date().toISOString().slice(0, 10);
return `订单导出_${stamp}_${count}条.${format}`;
}
@@ -8,7 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminOrdersService } from './admin-orders.service';
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
import { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
@Controller('admin/orders')
@UseGuards(HqAuthGuard)
@@ -26,6 +26,25 @@ export class AdminOrdersController {
return this.ordersService.listBigScreen(limit);
}
@Post('export/preview')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders')
previewExport(@Body() dto: AdminOrdersExportDto) {
return this.ordersService.previewExport(dto);
}
@Post('export')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders')
@HqOperation({
action: HqOperationAction.ORDER_EXPORT,
refType: 'ORDER',
includeBody: true,
})
exportOrders(@Body() dto: AdminOrdersExportDto) {
return this.ordersService.exportOrders(dto);
}
@Post('batch-delete')
@UseGuards(HqPermissionGuard)
@RequireHqPermissions('orders_delete')
@@ -7,12 +7,35 @@ import { orderStatusLogWhere } from '../../common/event/event.helpers';
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
import { TradeService } from '../trade/trade.service';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
import type { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import { AdminRedeemService } from './admin-redeem.service';
import {
buildExportFilename,
buildOrdersPdf,
buildOrdersXlsx,
mapOrderToExportRow,
} from './admin-order-export.util';
const ORDER_EXPORT_MAX = 5000;
type OrderFilterInput = Pick<
AdminOrdersQueryDto,
| 'orderNo'
| 'status'
| 'orderType'
| 'userId'
| 'cityId'
| 'receiverPhone'
| 'fulfillmentHold'
| 'createdFrom'
| 'createdTo'
| 'excludeTest'
| 'deliveryType'
>;
@Injectable()
export class AdminOrdersService {
@@ -60,23 +83,7 @@ export class AdminOrdersService {
async list(query: AdminOrdersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.OrderWhereInput = {};
if (query.orderNo) where.orderNo = { contains: query.orderNo };
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId);
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
where.fulfillmentHold = true;
}
if (query.createdFrom || query.createdTo) {
where.createdAt = {};
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
}
if (query.excludeTest) where.isTest = false;
const where = this.buildOrderWhere(query);
const [items, total] = await Promise.all([
this.prisma.order.findMany({
@@ -89,6 +96,9 @@ export class AdminOrdersService {
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
city: { select: { id: true, name: true, code: true } },
fulfillmentWarehouse: { select: { id: true, name: true } },
benefitCoupon: {
select: { couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true },
},
},
}),
this.prisma.order.count({ where }),
@@ -97,6 +107,114 @@ export class AdminOrdersService {
return serializeBigInt({ items, total, page, pageSize });
}
async previewExport(dto: AdminOrdersExportDto) {
const count = await this.countExportOrders(dto);
return { count, max: ORDER_EXPORT_MAX, exceeds: count > ORDER_EXPORT_MAX };
}
async exportOrders(dto: AdminOrdersExportDto) {
const count = await this.countExportOrders(dto);
if (count > ORDER_EXPORT_MAX) {
throw new BadRequestException(`超过 ${ORDER_EXPORT_MAX} 条,请缩小日期或筛选条件`);
}
if (count === 0) {
throw new BadRequestException('没有可导出的订单');
}
const orders = await this.prisma.order.findMany({
where: this.buildExportWhere(dto),
orderBy: { createdAt: 'desc' },
take: ORDER_EXPORT_MAX,
include: {
city: { select: { name: true } },
delivery: { select: { trackingNo: true } },
benefitCoupon: {
select: { totalAmount: true, usedAmount: true, balance: true },
},
},
});
const rows = orders.map((order) => mapOrderToExportRow(order));
const buffer =
dto.format === 'pdf' ? await buildOrdersPdf(rows) : await buildOrdersXlsx(rows);
const filename = buildExportFilename(dto.format, rows.length);
const mimeType =
dto.format === 'pdf'
? 'application/pdf'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
return {
filename,
mimeType,
contentBase64: buffer.toString('base64'),
count: rows.length,
};
}
private buildOrderWhere(query: OrderFilterInput): Prisma.OrderWhereInput {
const where: Prisma.OrderWhereInput = {};
if (query.orderNo) where.orderNo = { contains: query.orderNo };
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId);
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
if (query.deliveryType) {
where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals'];
}
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
where.fulfillmentHold = true;
}
if (query.createdFrom || query.createdTo) {
where.createdAt = {};
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo);
}
if (query.excludeTest) where.isTest = false;
return where;
}
private buildExportWhere(dto: AdminOrdersExportDto): Prisma.OrderWhereInput {
if (dto.scope === 'selected') {
const ids = (dto.ids ?? []).map((id) => BigInt(id));
if (!ids.length) {
throw new BadRequestException('请先勾选要导出的订单');
}
return { id: { in: ids } };
}
const normalized = this.normalizeExportFilter(dto);
return this.buildOrderWhere(normalized);
}
private async countExportOrders(dto: AdminOrdersExportDto): Promise<number> {
return this.prisma.order.count({ where: this.buildExportWhere(dto) });
}
private normalizeExportFilter(dto: AdminOrdersExportDto): OrderFilterInput {
const createdFrom = dto.createdFrom;
const createdTo = dto.createdTo;
if (!createdFrom && !createdTo) {
const from = new Date();
from.setDate(from.getDate() - 30);
from.setHours(0, 0, 0, 0);
return {
...dto,
createdFrom: from.toISOString().slice(0, 10),
createdTo: new Date().toISOString().slice(0, 10),
};
}
return { ...dto, createdFrom, createdTo };
}
private endOfDay(dateStr: string): Date {
const end = new Date(dateStr);
end.setHours(23, 59, 59, 999);
return end;
}
async getOrderTrack(id: bigint) {
const order = await this.prisma.order.findUnique({
where: { id },
@@ -1,5 +1,5 @@
import { Type, Transform } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
function toOptionalBoolean(value: unknown): boolean | undefined {
if (value === undefined || value === null || value === '') return undefined;
@@ -93,6 +93,66 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
@IsOptional()
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
deliveryType?: string;
}
/** HQ 订单导出(筛选 + 勾选范围) */
export class AdminOrdersExportDto {
@IsIn(['filter', 'selected'])
scope: 'filter' | 'selected';
@IsIn(['xlsx', 'pdf'])
format: 'xlsx' | 'pdf';
@IsOptional()
@IsArray()
@IsString({ each: true })
ids?: string[];
@IsOptional()
@IsString()
orderNo?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsIn(['NORMAL', 'PROXY', 'RESHIPMENT'])
orderType?: string;
@IsOptional()
@IsString()
cityId?: string;
@IsOptional()
@IsString()
receiverPhone?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
fulfillmentHold?: boolean;
@IsOptional()
@IsString()
createdFrom?: string;
@IsOptional()
@IsString()
createdTo?: string;
@IsOptional()
@Transform(({ value }) => toOptionalBoolean(value))
@IsBoolean()
excludeTest?: boolean;
@IsOptional()
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
deliveryType?: string;
}
/** 概览页用户/订单 ECharts 聚合筛选 */