Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22c0b03a47 | |||
| 73fd27bdf6 | |||
| f2d03e2595 | |||
| cd9696bc1c | |||
| 0c28c204b2 | |||
| b5d4f8b27c | |||
| 9a80f0f8d6 | |||
| e7f49a9639 | |||
| f941dcf072 | |||
| e8b585e623 | |||
| 5702266276 | |||
| 3382d36a6c | |||
| 9b8e3f1347 | |||
| a8a37fbc4a | |||
| 1440a59fc8 | |||
| e34416a9b3 | |||
| 11659484c6 | |||
| 6c44e9c56f | |||
| 0fb7ab7abb | |||
| 905e145af3 |
@@ -21,7 +21,4 @@ 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
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||
Button, Input, InputNumber, Radio, Select, Space, Switch, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { request } from '../lib/api';
|
||||
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
||||
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
||||
@@ -25,7 +24,6 @@ type SkuRow = {
|
||||
isDefault: boolean;
|
||||
sortOrder?: number;
|
||||
specText?: string;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
function cartesian(attrs: SpecAttr[]): string[][] {
|
||||
@@ -75,7 +73,6 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||
isDefault: !!s.isDefault,
|
||||
imageUrl: s.imageUrl ?? '',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
@@ -89,12 +86,10 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
imageUrl: '',
|
||||
},
|
||||
],
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||
|
||||
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||
|
||||
@@ -116,21 +111,12 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: i === 0,
|
||||
sortOrder: i,
|
||||
imageUrl: '',
|
||||
};
|
||||
});
|
||||
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
||||
setSkus(next.length ? next : skus);
|
||||
}
|
||||
|
||||
function patchSku(index: number, patch: Partial<SkuRow>) {
|
||||
setSkus((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], ...patch };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -182,7 +168,6 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
||||
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
||||
isDefault: !!s.isDefault,
|
||||
sortOrder: i,
|
||||
imageUrl: s.imageUrl?.trim() || null,
|
||||
}));
|
||||
|
||||
const barcodes = payloadSkus.map((row) => row.barcode69);
|
||||
@@ -204,13 +189,11 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
||||
}
|
||||
}
|
||||
|
||||
const editing = editIndex != null ? skus[editIndex] : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
||||
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
||||
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码。
|
||||
先配置销售规格轴(如「包装」),再生成 SKU 矩阵;未配置规格时仅保留默认一行。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Title level={5}>规格轴</Typography.Title>
|
||||
@@ -463,154 +446,12 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '主图',
|
||||
width: 72,
|
||||
render: (_, row) =>
|
||||
row.imageUrl ? (
|
||||
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
) : (
|
||||
<Typography.Text type="secondary">无</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '编辑',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row, index) => (
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
|
||||
填写
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
||||
保存规格与 SKU
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
|
||||
open={editIndex != null}
|
||||
onCancel={() => setEditIndex(null)}
|
||||
onOk={() => setEditIndex(null)}
|
||||
okText="完成"
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
{editing && editIndex != null ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">SKU</Typography.Text>
|
||||
<div>{editing.skuCode || '保存后自动生成'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>69 码 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
|
||||
<Input
|
||||
placeholder="本规格独立 69 码"
|
||||
value={editing.barcode69}
|
||||
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>主图</Typography.Text>
|
||||
<OssUpload
|
||||
bizType="COVER"
|
||||
value={editing.imageUrl || ''}
|
||||
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<div>
|
||||
<Typography.Text>售价</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: 140 }}
|
||||
value={editing.price}
|
||||
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>权益</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: 140 }}
|
||||
value={editing.benefitAmount}
|
||||
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Text>状态</Typography.Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={editing.status}
|
||||
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={(status) => patchSku(editIndex, { status })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 100 }}
|
||||
value={editing.saleUnit}
|
||||
options={[
|
||||
{ value: 'BOTTLE', label: '瓶' },
|
||||
{ value: 'BOX', label: '箱' },
|
||||
]}
|
||||
onChange={(saleUnit) =>
|
||||
patchSku(editIndex, {
|
||||
saleUnit,
|
||||
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{editing.saleUnit === 'BOX' ? (
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={editing.bottlesPerUnit}
|
||||
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
<Space>
|
||||
<Switch
|
||||
checkedChildren="线上"
|
||||
unCheckedChildren="线上"
|
||||
checked={editing.allowOnlinePurchase}
|
||||
onChange={(checked) =>
|
||||
patchSku(editIndex, {
|
||||
allowOnlinePurchase: checked,
|
||||
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="跨城"
|
||||
unCheckedChildren="跨城"
|
||||
disabled={!editing.allowOnlinePurchase}
|
||||
checked={editing.allowCrossCityDelivery}
|
||||
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="现场"
|
||||
unCheckedChildren="现场"
|
||||
checked={editing.allowOnSitePickup}
|
||||
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="默认"
|
||||
unCheckedChildren="默认"
|
||||
checked={editing.isDefault}
|
||||
onChange={(checked) => {
|
||||
if (!checked) return;
|
||||
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,18 +195,6 @@ 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 };
|
||||
|
||||
@@ -8,19 +8,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Collapse,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -23,10 +20,7 @@ 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,
|
||||
@@ -136,110 +130,6 @@ 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 [];
|
||||
@@ -302,11 +192,8 @@ 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)),
|
||||
@@ -351,7 +238,6 @@ 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);
|
||||
@@ -359,15 +245,12 @@ 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, initialOrderNo]);
|
||||
}, [form, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -568,93 +451,79 @@ 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: '商品',
|
||||
width: 240,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<Space size={4} wrap>
|
||||
<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">代下单</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: '状态 / 实付',
|
||||
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: '好客权益',
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
width: 200,
|
||||
render: (_, row) => formatBenefitBrief(row),
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '收货信息',
|
||||
width: 240,
|
||||
title: '城市',
|
||||
width: 90,
|
||||
render: (_, row) => row.city?.name || '—',
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, row) => {
|
||||
const address = formatReceiverAddress(row);
|
||||
const name = row.productName || '—';
|
||||
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{row.receiverName || '—'} {row.receiverPhone || ''}
|
||||
</div>
|
||||
{address ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{address}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
<span title={row.productSpec ? `${name}(${row.productSpec})` : name}>
|
||||
{name}{qty}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (s, row) => (
|
||||
<Space size={4} wrap>
|
||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||
{row.fulfillmentHold ? <Tag color="orange">大单待确认</Tag> : null}
|
||||
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||
<Tag color="purple" title={row.proxyPartnerName || undefined}>
|
||||
代下单
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: 100,
|
||||
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
|
||||
},
|
||||
{
|
||||
title: '下单时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: fmtTime,
|
||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -688,15 +557,24 @@ export default function OrdersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Space size={12}>
|
||||
<Space>
|
||||
<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>
|
||||
|
||||
@@ -714,115 +592,54 @@ export default function OrdersPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</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"
|
||||
placeholder="全部"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={6}>
|
||||
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
|
||||
<Input allowClear placeholder="手机号" />
|
||||
</Form.Item>
|
||||
</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" noStyle>
|
||||
<Checkbox>过滤测试</Checkbox>
|
||||
</Form.Item>
|
||||
</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>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="orderNo" label="订单号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="orderType" label="类型">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
placeholder="全部"
|
||||
options={[
|
||||
{ value: 'NORMAL', label: '普通订单' },
|
||||
{ value: 'PROXY', label: '代下单' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<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 />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<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.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -830,18 +647,16 @@ export default function OrdersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
rowSelection={{
|
||||
scroll={{ x: 1500 }}
|
||||
rowSelection={canDeleteOrders ? {
|
||||
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);
|
||||
|
||||
@@ -21,22 +21,6 @@ type ProductDetailContentDto = {
|
||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||
};
|
||||
|
||||
type SkuListRow = {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
specText: string;
|
||||
price: number;
|
||||
benefitAmount: number;
|
||||
status: string;
|
||||
isDefault?: boolean;
|
||||
allowOnSitePickup?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
soldBottles?: number;
|
||||
virtual?: boolean;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
@@ -46,15 +30,9 @@ type Row = {
|
||||
aromaType: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
soldBottles?: number;
|
||||
benefitAmount: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
skuCount?: number;
|
||||
specEnabled?: boolean;
|
||||
skus?: SkuListRow[];
|
||||
allowOnSitePickup?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
@@ -236,33 +214,7 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FulfillmentTags(row: {
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
allowOnSitePickup?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
|
||||
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
|
||||
<Tag color="cyan">跨城</Tag>
|
||||
) : null}
|
||||
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
|
||||
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag>无</Tag> : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseInfoFields({
|
||||
mode,
|
||||
form,
|
||||
hideFulfillment,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
form: FormInstance;
|
||||
/** 多规格商品:履约只在规格 SKU 上编辑 */
|
||||
hideFulfillment?: boolean;
|
||||
}) {
|
||||
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
|
||||
return (
|
||||
<>
|
||||
{mode === 'create' && (
|
||||
@@ -304,47 +256,39 @@ function BaseInfoFields({
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{hideFulfillment ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
该商品有多种规格,履约(线上 / 跨城 / 现场)请到「规格与 SKU」中按规格设置。
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="allowOnlinePurchase"
|
||||
label="允许线上购买"
|
||||
valuePropName="checked"
|
||||
extra="配送到址(同城)"
|
||||
>
|
||||
<Switch
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
onChange={(checked) => {
|
||||
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
||||
{() => (
|
||||
<Form.Item
|
||||
name="allowOnlinePurchase"
|
||||
label="允许线上购买"
|
||||
name="allowCrossCityDelivery"
|
||||
label="允许跨城配送"
|
||||
valuePropName="checked"
|
||||
extra="配送到址(同城)"
|
||||
extra="须先开启线上购买"
|
||||
>
|
||||
<Switch
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
onChange={(checked) => {
|
||||
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
||||
}}
|
||||
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
||||
{() => (
|
||||
<Form.Item
|
||||
name="allowCrossCityDelivery"
|
||||
label="允许跨城配送"
|
||||
valuePropName="checked"
|
||||
extra="须先开启线上购买"
|
||||
>
|
||||
<Switch
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||
</Form.Item>
|
||||
<VisibilityWhitelistFields form={form} />
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
@@ -388,35 +332,12 @@ export default function ProductsPage() {
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = useMemo(() => [
|
||||
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
|
||||
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||
{ title: '品名', dataIndex: 'name', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '累计销售',
|
||||
dataIndex: 'soldBottles',
|
||||
width: 110,
|
||||
render: (v: number | undefined) => `累计 ${v ?? 0} 瓶`,
|
||||
},
|
||||
{
|
||||
title: '售价',
|
||||
key: 'priceRange',
|
||||
width: 120,
|
||||
render: (_, row) => {
|
||||
const min = row.priceMin ?? row.price;
|
||||
const max = row.priceMax ?? row.price;
|
||||
if (min === max) return `¥${min}`;
|
||||
return `¥${min} ~ ¥${max}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '规格数',
|
||||
dataIndex: 'skuCount',
|
||||
width: 80,
|
||||
render: (v: number | undefined, row) => {
|
||||
const real = v ?? 0;
|
||||
if (real > 0) return real;
|
||||
return row.skus?.some((s) => s.virtual) ? 1 : 0;
|
||||
},
|
||||
},
|
||||
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
||||
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||
) },
|
||||
@@ -427,7 +348,23 @@ export default function ProductsPage() {
|
||||
render: (v: boolean) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
key: 'fulfillment',
|
||||
width: 160,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
|
||||
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
|
||||
<Tag color="cyan">跨城</Tag>
|
||||
) : null}
|
||||
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
|
||||
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag>无</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
render: (_, row) => (
|
||||
@@ -440,7 +377,7 @@ export default function ProductsPage() {
|
||||
}}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品?"
|
||||
description={`将永久删除「${row.name}」,此操作不可恢复。`}
|
||||
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
@@ -453,46 +390,6 @@ export default function ProductsPage() {
|
||||
},
|
||||
], [detail, editForm]);
|
||||
|
||||
const skuColumns: ColumnsType<SkuListRow> = useMemo(() => [
|
||||
{
|
||||
title: '规格',
|
||||
dataIndex: 'specText',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (v: string, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v || '默认'}</span>
|
||||
{row.isDefault ? <Tag color="blue">默认</Tag> : null}
|
||||
{row.virtual ? <Tag>未建SKU</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: 'SKU', dataIndex: 'skuCode', width: 110 },
|
||||
{ title: '69码', dataIndex: 'barcode69', width: 160, ellipsis: true },
|
||||
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` },
|
||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` },
|
||||
{
|
||||
title: '累计销售',
|
||||
dataIndex: 'soldBottles',
|
||||
width: 100,
|
||||
render: (v: number | undefined) => `${v ?? 0} 瓶`,
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
key: 'fulfillment',
|
||||
width: 160,
|
||||
render: (_, row) => <FulfillmentTags {...row} />,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
@@ -500,9 +397,7 @@ export default function ProductsPage() {
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
@@ -511,46 +406,17 @@ export default function ProductsPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1080 }}
|
||||
expandable={{
|
||||
rowExpandable: () => true,
|
||||
expandedRowRender: (row) => (
|
||||
<div style={{ margin: '-8px -8px -8px 24px', padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={skuColumns}
|
||||
dataSource={row.skus ?? []}
|
||||
scroll={{ x: 980 }}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }}
|
||||
/>
|
||||
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
|
||||
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||
return;
|
||||
}
|
||||
const payload = buildProductPayload(v);
|
||||
if (multiSku) {
|
||||
delete (payload as { allowOnlinePurchase?: boolean }).allowOnlinePurchase;
|
||||
delete (payload as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery;
|
||||
delete (payload as { allowOnSitePickup?: boolean }).allowOnSitePickup;
|
||||
}
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
@@ -560,24 +426,13 @@ export default function ProductsPage() {
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="默认 SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
|
||||
<Descriptions.Item label="SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
|
||||
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
|
||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs items={[
|
||||
{
|
||||
key: 'base',
|
||||
label: '基础信息',
|
||||
children: (
|
||||
<BaseInfoFields
|
||||
mode="edit"
|
||||
form={editForm}
|
||||
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情页',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/mini-user",
|
||||
"version": "3.5.4",
|
||||
"version": "3.4.15",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB |
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||
export const APP_VERSION = '3.5.4';
|
||||
export const APP_VERSION = '3.4.15';
|
||||
|
||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
saveWechatLoginResult,
|
||||
} from '../../lib/pay-wechat';
|
||||
import { applyWechatLoginResult } from '../../lib/wechat-auth';
|
||||
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
|
||||
import { isWechatEnv } from '../../lib/weixin';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import payLogo from '../../assets/logo2.png';
|
||||
|
||||
export default function PayPage() {
|
||||
const router = useRouter();
|
||||
@@ -31,6 +31,7 @@ export default function PayPage() {
|
||||
const [orderNo, setOrderNo] = useState('');
|
||||
const [payAmount, setPayAmount] = useState('—');
|
||||
const [deliveryType, setDeliveryType] = useState('');
|
||||
const [brandMarkUrl, setBrandMarkUrl] = useState(() => getBrandAssetsSync().brandLogoMarkUrl);
|
||||
|
||||
const returnPath = orderId
|
||||
? `/pages/pay/index?orderId=${orderId}`
|
||||
@@ -50,6 +51,10 @@ export default function PayPage() {
|
||||
void refreshPayReadiness();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void loadBrandAssets().then((brand) => setBrandMarkUrl(brand.brandLogoMarkUrl));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
if (process.env.TARO_ENV === 'weapp') {
|
||||
@@ -173,7 +178,7 @@ export default function PayPage() {
|
||||
<View className="sub-page-body">
|
||||
<View className="pay-status">
|
||||
<View className="pay-status-icon">
|
||||
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
|
||||
<Image className="pay-status-brand" src={brandMarkUrl} mode="aspectFit" />
|
||||
</View>
|
||||
<Text className="pay-status-title">
|
||||
{needsWechatAuth ? '需完成微信授权' : '待支付'}
|
||||
|
||||
@@ -165,12 +165,6 @@ export default function ProductDetailPage() {
|
||||
allowOnSitePickup: activeSku.allowOnSitePickup,
|
||||
}
|
||||
: product;
|
||||
const carouselImages = (() => {
|
||||
const base = getProductCarouselImages(product);
|
||||
const skuImg = activeSku?.imageUrl?.trim();
|
||||
if (!skuImg) return base;
|
||||
return [skuImg, ...base.filter((url) => url !== skuImg)];
|
||||
})();
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() =>
|
||||
@@ -178,9 +172,9 @@ export default function ProductDetailPage() {
|
||||
path: `/pages/product-detail/index?id=${productId}`,
|
||||
dynamicTitle: product?.name,
|
||||
dynamicDesc: product?.subtitle,
|
||||
dynamicImageUrl: (activeSku?.imageUrl?.trim() || (product ? getProductMainImage(product) : undefined)),
|
||||
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
|
||||
}),
|
||||
[product, productId, activeSku],
|
||||
[product, productId],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
@@ -255,6 +249,7 @@ export default function ProductDetailPage() {
|
||||
|
||||
const allowOnline = canBuyOnline(fulfillment ?? {});
|
||||
const allowOnSite = canPickupOnSite(fulfillment ?? {});
|
||||
const carouselImages = getProductCarouselImages(product);
|
||||
const detailImages = getProductDetailImages(product);
|
||||
const detail = product.detailContent ?? {};
|
||||
const features = detail.features ?? [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/store-detail.css';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
@@ -444,19 +444,17 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{benefitRule ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title store-detail-section-title--rule">使用规则</Text>
|
||||
<Text className="store-detail-intro">{benefitRule}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{intro ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店详情</Text>
|
||||
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
|
||||
<Text className="store-detail-intro">{intro}</Text>
|
||||
</ScrollView>
|
||||
<Text className="store-detail-intro">{intro}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{benefitRule ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">好客权益使用规则</Text>
|
||||
<Text className="store-detail-intro">{benefitRule}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -488,10 +488,12 @@
|
||||
}
|
||||
|
||||
.pay-status-icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: #f7f4ee;
|
||||
background: rgba(166, 29, 36, 0.08);
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -500,8 +502,8 @@
|
||||
}
|
||||
|
||||
.pay-status-brand {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.pay-status-title {
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
|
||||
.product-detail-promo {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
|
||||
@@ -378,7 +377,7 @@
|
||||
}
|
||||
|
||||
.product-detail-specs {
|
||||
margin: 16px 0 28px;
|
||||
margin: 12px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
@@ -211,17 +211,6 @@
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.store-detail-section-title--rule {
|
||||
color: var(--color-heritage-red, #a61d24);
|
||||
}
|
||||
|
||||
.store-detail-intro-scroll {
|
||||
max-height: 168px;
|
||||
height: 168px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.store-detail-env-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,13 +68,6 @@
|
||||
|
||||
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
||||
|
||||
**HQ 订单导出**(`admin-web` 订单监控):
|
||||
|
||||
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
|
||||
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
||||
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
||||
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
||||
|
||||
### 3.3 佣金与结算
|
||||
|
||||
| 类型 | 默认 | 释放 |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|------|------|
|
||||
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
||||
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
||||
| 近期版本 | … · v3.5.3 · **v3.5.4 商品规格 SPU+SKU + 发票品类 + 权益/门店详情 UI** |
|
||||
| 近期版本 | … · v3.4.16 · **v3.5.3 门店照片/企微审核/分享 + 核销金额/提现补修 + 权益图标** |
|
||||
| 冒烟 | `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** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
||||
| **admin-web** | 商品/开城/门店/订单/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
||||
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
||||
|
||||
## 3. 场景 SC-01~09
|
||||
@@ -57,14 +57,10 @@
|
||||
| 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.4 | [`商品规格 SPU+SKU + 发票/文案 UI`](./杜康好客-v3.5.4-开发文档.md) | ✅ 生产(weapp 版本号需单独上传) |
|
||||
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
|
||||
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.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 旁路;测试流水计入结算) |
|
||||
|
||||
+39
-122
@@ -1,145 +1,62 @@
|
||||
# 杜康好客 · v3.5.4 开发文档
|
||||
# 杜康好客 · v3.5.4 商品规格(SPU + SKU)
|
||||
|
||||
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
|
||||
> **生产 commit**:`5fdddae`(规格主图与门店使用规则合并入 `main`)及后续客户端版本号 `3.5.4`
|
||||
> **主题**:商品规格(SPU + SKU)上线 + 同批次发票 / 文案 / 门店详情体验
|
||||
> 目标:商品详情页内可选销售规格;价格、权益、69 码、履约、上下架按 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 码
|
||||
|
||||
| # | 项 | 说明 |
|
||||
| # | 项 | 交付 |
|
||||
|---|----|------|
|
||||
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可手填;脚本 `rewrite-sku-codes-dk.ts` 改存量 |
|
||||
| 10 | 69 码 | **每规格独立**;同商品内 + 跨商品唯一;不可与其它 SKU 冲突 |
|
||||
| 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 自动回落;起购按瓶当量 |
|
||||
| 5 | Admin | `PUT /admin/products/:id/specs`、`PUT .../skus`;商品抽屉「规格与 SKU」 |
|
||||
| 6 | mini-user | 详情规格 chips;确认页带 `skuId`;箱装数量文案 |
|
||||
| 7 | 代下单 | HQ / 合伙人可选规格 SKU |
|
||||
| 8 | 权益文案 | 「好客权益券」→「好客权益」;首页角标「享{amount}好客权益」(去掉门店 icon) |
|
||||
|
||||
### 2.3 规格主图
|
||||
**不做**:SKU 规格图、库存、把现有酒祖 10/15/20 合并为一个 SPU。
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 11 | DB | `common_product_sku.image_url`(`migrate-sku-image.sql`) |
|
||||
| 12 | Admin | 弹窗可为每 SKU 上传主图 |
|
||||
| 13 | C 端 | 选规格优先 SKU 主图,空则回落商品封面 |
|
||||
## 兼容规则(线上)
|
||||
|
||||
### 2.4 发票(同批次)
|
||||
- 不改 `/api/v1` 前缀;**只增字段,不删旧字段语义**。
|
||||
- 旧客户端不传 `skuId`:若该 SPU **恰好 1 个可售 SKU** → 自动使用(现网行为);多个可售 → `400 请选择规格`。
|
||||
- 旧 admin `PUT /admin/products/:id` 不传规格时:仅同步**唯一**默认 SKU(多规格时不同步,防误改)。
|
||||
- SPU 上 `sku_code` / `barcode_69` 去掉唯一约束,唯一下沉到 `common_product_sku`;SPU 列保留为默认 SKU 冗余。
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 14 | 品类 | `user_invoice.invoice_category`:`LIQUOR` 酒水类 / `CATERING` 餐饮类 |
|
||||
| 15 | C 端申请 | 票种写死「增值税普通发票」;类型选酒水/餐饮;备注写入申请 |
|
||||
| 16 | 抬头 | 必填项打 `*`;强调邮箱必填 |
|
||||
| 17 | Admin | 发票列表展示品类;专票仍可走后台 |
|
||||
## 发版顺序
|
||||
|
||||
### 2.5 文案与 UI(同批次)
|
||||
1. API + DB 迁移(每商品仅默认 SKU)。**此时不要给任何商品加第二 SKU。**
|
||||
2. 发布新 mini-user + admin 规格页 + 代下单 SKU 选择。
|
||||
3. 运营再配置「单瓶 / 整箱」等第二规格。
|
||||
|
||||
| # | 位置 | 变更 |
|
||||
|---|------|------|
|
||||
| 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
|
||||
- **0 个可售 SKU → 回落 SPU,照常下单**
|
||||
- 新客户端传 `skuId` → 按该 SKU 校验。
|
||||
- 旧 admin 不传规格:仅同步**唯一**默认 SKU;不覆盖已生成的 DK 码。
|
||||
- SPU 上 `sku_code` / `barcode_69` 唯一约束下沉到 SKU;SPU 列保留冗余。
|
||||
|
||||
---
|
||||
|
||||
## 4. 发版与生产执行记录
|
||||
|
||||
按序在目标库执行(先测后产)。本机脚本只打本地 `.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`
|
||||
- 城市起购仍按瓶;箱装(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6
|
||||
- 大单拦截、承运商 `goodsNum` 用瓶当量
|
||||
- 城市起购阈值仍是瓶;箱装 SKU(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6。
|
||||
- 大单拦截、承运商 `goodsNum` 使用瓶当量。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键表 / 字段摘要
|
||||
## 关键表摘要
|
||||
|
||||
- `common_product_spec_attr` / `common_product_spec_value`
|
||||
- `common_product_sku`(`sale_unit`、`bottles_per_unit`、`image_url`)
|
||||
- `common_product_sku`(`sale_unit`=`BOTTLE|BOX`,`bottles_per_unit`)
|
||||
- `common_product_sku_spec`
|
||||
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
|
||||
- `user_invoice.invoice_category`
|
||||
|
||||
---
|
||||
## 权益文案(mini-user)
|
||||
|
||||
## 7. 关键路径(便于排查)
|
||||
| 位置 | 变更 |
|
||||
|------|------|
|
||||
| 门店详情等 | 「好客权益券」→「好客权益」(如「好客权益使用规则」) |
|
||||
| 首页商品角标 `CouponBadge` | 由「享 + 门店 icon + 金额 + 好客权益」改为纯文案 **「享{amount}好客权益」** |
|
||||
|
||||
| 域 | 路径 |
|
||||
|----|------|
|
||||
| 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/` |
|
||||
其它页 `BenefitFigure`(我的/权益/核销等)仍保留门店核销图标,仅首页角标去 icon。
|
||||
|
||||
---
|
||||
## 验收
|
||||
|
||||
## 8. 验收清单
|
||||
|
||||
- [ ] 未配规格:旧端下单与现网一致(无 SKU 也能下)
|
||||
- [ ] 多规格:选规格后价格/权益/履约/主图变化;漏选 400
|
||||
- [ ] 整箱:数量 1 过起购;订单 `bottles_per_unit=6`
|
||||
- [ ] 详情 API 仍含 `id/name/price/spec/skuCode/...`
|
||||
- [ ] 后台 SKU 码 DK 开头不可手填;每规格独立 69 码与主图
|
||||
- [ ] 无「好客权益券」;首页角标「享{金额}好客权益」无门店 icon
|
||||
- [ ] 门店详情「使用规则」红色且在详情上方;详情超长可滚
|
||||
- [ ] 待支付页为 logo2 印章
|
||||
- [ ] 发票:普票固定 + 酒水/餐饮品类 + 邮箱必填
|
||||
- [ ] 「我的」页版本 **杜康好客 v3.5.4**(需 weapp 新包)
|
||||
- [ ] 未配规格:旧小程序/H5/代下单路径与现网一致
|
||||
- [ ] 多规格:详情选规格后价格/权益/履约变化;漏选下单 400
|
||||
- [ ] 整箱 SKU:数量 1 过起购;订单快照 `bottles_per_unit=6`
|
||||
- [ ] `GET /catalog/products/:id` 仍含 `id/name/price/spec/skuCode/...`
|
||||
- [ ] 小程序无「好客权益券」字样;首页角标为「享{金额}好客权益」且无门店 icon
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# 杜康好客 · 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 落地说明。
|
||||
@@ -35,8 +35,6 @@ export interface ProductSkuDto {
|
||||
saleUnit: ProductSaleUnit;
|
||||
bottlesPerUnit: number;
|
||||
isDefault: boolean;
|
||||
/** 规格主图;无则详情页回落商品封面/轮播 */
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
@@ -144,5 +142,4 @@ export interface AdminProductSkuInput {
|
||||
bottlesPerUnit?: number;
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
Generated
+77
-598
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 921 KiB |
@@ -1,12 +0,0 @@
|
||||
# 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` 复制到本目录。
|
||||
@@ -3,7 +3,6 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [{ "include": "../assets/**/*", "outDir": "dist", "watchAssets": true }]
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@
|
||||
"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:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts"
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/dysmsapi20170525": "^4.6.0",
|
||||
@@ -44,11 +43,9 @@
|
||||
"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"
|
||||
@@ -60,7 +57,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* 方案 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());
|
||||
@@ -1,3 +0,0 @@
|
||||
-- SKU 规格主图(可选;空则 C 端回落商品封面)
|
||||
ALTER TABLE `common_product_sku`
|
||||
ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`;
|
||||
@@ -894,8 +894,6 @@ model CommonProductSku {
|
||||
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
/// 规格主图;空则回落商品封面
|
||||
imageUrl String? @map("image_url") @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ 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',
|
||||
@@ -148,7 +147,6 @@ 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]: '变更门店状态',
|
||||
|
||||
@@ -188,7 +188,6 @@ export function mapSkuDto(sku: SkuWithSpecs) {
|
||||
isDefault: sku.isDefault,
|
||||
skuCode: sku.skuCode,
|
||||
barcode69: sku.barcode69,
|
||||
imageUrl: (sku as { imageUrl?: string | null }).imageUrl || undefined,
|
||||
sortOrder: sku.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
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 { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/orders')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -26,25 +26,6 @@ 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,35 +7,12 @@ 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 { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import type { 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 {
|
||||
@@ -83,7 +60,23 @@ export class AdminOrdersService {
|
||||
async list(query: AdminOrdersQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where = this.buildOrderWhere(query);
|
||||
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 [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
@@ -96,9 +89,6 @@ 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 }),
|
||||
@@ -107,114 +97,6 @@ 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 },
|
||||
|
||||
@@ -86,19 +86,7 @@ export class AdminProductsService {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CommonProductItemWhereInput = {};
|
||||
if (query.name?.trim()) {
|
||||
const kw = query.name.trim();
|
||||
where.OR = [
|
||||
{ name: { contains: kw } },
|
||||
{
|
||||
skus: {
|
||||
some: {
|
||||
OR: [{ skuCode: { contains: kw } }, { barcode69: { contains: kw } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals'];
|
||||
if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
|
||||
|
||||
@@ -111,25 +99,7 @@ export class AdminProductsService {
|
||||
include: {
|
||||
coverResource: true,
|
||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||
skus: {
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
skuCode: true,
|
||||
barcode69: true,
|
||||
specText: true,
|
||||
price: true,
|
||||
benefitAmount: true,
|
||||
status: true,
|
||||
isDefault: true,
|
||||
sortOrder: true,
|
||||
allowOnSitePickup: true,
|
||||
allowOnlinePurchase: true,
|
||||
allowCrossCityDelivery: true,
|
||||
saleUnit: true,
|
||||
bottlesPerUnit: true,
|
||||
},
|
||||
},
|
||||
skus: { select: { id: true }, take: 2 },
|
||||
specAttrs: { select: { id: true } },
|
||||
},
|
||||
}),
|
||||
@@ -150,101 +120,13 @@ export class AdminProductsService {
|
||||
: [];
|
||||
const resourceMap = groupResourcesByProductId(resources);
|
||||
|
||||
/** productId → skuIdKey → bottles;skuIdKey 用 '' 表示历史无 sku */
|
||||
const soldByProductSku = new Map<string, Map<string, number>>();
|
||||
if (productIds.length) {
|
||||
const saleOrders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
productId: { in: productIds },
|
||||
payStatus: 'PAID',
|
||||
isTest: false,
|
||||
status: { notIn: ['PENDING_PAY', 'CANCELLED', 'REFUNDED'] },
|
||||
},
|
||||
select: {
|
||||
productId: true,
|
||||
skuId: true,
|
||||
quantity: true,
|
||||
bottlesPerUnit: true,
|
||||
},
|
||||
});
|
||||
for (const o of saleOrders) {
|
||||
const pid = o.productId.toString();
|
||||
const sid = o.skuId?.toString() ?? '';
|
||||
const bottles = o.quantity * (o.bottlesPerUnit > 0 ? o.bottlesPerUnit : 1);
|
||||
let bySku = soldByProductSku.get(pid);
|
||||
if (!bySku) {
|
||||
bySku = new Map();
|
||||
soldByProductSku.set(pid, bySku);
|
||||
}
|
||||
bySku.set(sid, (bySku.get(sid) ?? 0) + bottles);
|
||||
}
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
items: items.map((p) => {
|
||||
const { skus, specAttrs, ...rest } = p;
|
||||
const formatted = this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []);
|
||||
const prices = skus.length
|
||||
? skus.map((s) => Number(s.price))
|
||||
: [Number(p.price)];
|
||||
const priceMin = Math.min(...prices);
|
||||
const priceMax = Math.max(...prices);
|
||||
const pid = p.id.toString();
|
||||
const bySku = soldByProductSku.get(pid);
|
||||
let soldBottles = 0;
|
||||
if (bySku) {
|
||||
for (const n of bySku.values()) soldBottles += n;
|
||||
}
|
||||
|
||||
const skuRows =
|
||||
skus.length > 0
|
||||
? skus.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
skuCode: s.skuCode,
|
||||
barcode69: s.barcode69,
|
||||
specText: s.specText,
|
||||
price: Number(s.price),
|
||||
benefitAmount: Number(s.benefitAmount ?? s.price),
|
||||
status: s.status,
|
||||
isDefault: s.isDefault,
|
||||
sortOrder: s.sortOrder,
|
||||
allowOnSitePickup: s.allowOnSitePickup,
|
||||
allowOnlinePurchase: s.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
||||
saleUnit: s.saleUnit,
|
||||
bottlesPerUnit: s.bottlesPerUnit,
|
||||
soldBottles: bySku?.get(s.id.toString()) ?? 0,
|
||||
virtual: false,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: `virtual-${pid}`,
|
||||
skuCode: p.skuCode,
|
||||
barcode69: p.barcode69,
|
||||
specText: p.spec,
|
||||
price: Number(p.price),
|
||||
benefitAmount: Number(p.benefitAmount ?? p.price),
|
||||
status: p.status,
|
||||
isDefault: true,
|
||||
sortOrder: 0,
|
||||
allowOnSitePickup: p.allowOnSitePickup,
|
||||
allowOnlinePurchase: p.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: p.allowCrossCityDelivery,
|
||||
saleUnit: 'BOTTLE' as const,
|
||||
bottlesPerUnit: 1,
|
||||
soldBottles,
|
||||
virtual: true,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
...formatted,
|
||||
...this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []),
|
||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||
skuCount: skus.length,
|
||||
priceMin,
|
||||
priceMax,
|
||||
soldBottles,
|
||||
skus: skuRows,
|
||||
};
|
||||
}),
|
||||
total,
|
||||
@@ -364,14 +246,10 @@ export class AdminProductsService {
|
||||
const existing = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('商品不存在');
|
||||
|
||||
const skuCount = await this.prisma.commonProductSku.count({ where: { productId: id } });
|
||||
/** 多规格时履约只在规格 SKU 上改;忽略基础信息里的履约字段,避免误覆盖默认 SKU 冗余 */
|
||||
const applyFulfillment = skuCount <= 1;
|
||||
const fulfillmentTouched =
|
||||
applyFulfillment &&
|
||||
(dto.allowOnlinePurchase !== undefined ||
|
||||
dto.allowCrossCityDelivery !== undefined ||
|
||||
dto.allowOnSitePickup !== undefined);
|
||||
dto.allowOnlinePurchase !== undefined ||
|
||||
dto.allowCrossCityDelivery !== undefined ||
|
||||
dto.allowOnSitePickup !== undefined;
|
||||
const flags = fulfillmentTouched
|
||||
? resolveFulfillmentFlags({
|
||||
allowOnlinePurchase: dto.allowOnlinePurchase,
|
||||
@@ -677,8 +555,7 @@ export class AdminProductsService {
|
||||
bottlesPerUnit,
|
||||
isDefault: !!row.isDefault,
|
||||
sortOrder: row.sortOrder ?? i,
|
||||
imageUrl: row.imageUrl?.trim() || null,
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
await tx.commonProductSkuSpec.deleteMany({ where: { skuId } });
|
||||
} else {
|
||||
@@ -697,8 +574,7 @@ export class AdminProductsService {
|
||||
bottlesPerUnit,
|
||||
isDefault: !!row.isDefault,
|
||||
sortOrder: row.sortOrder ?? i,
|
||||
imageUrl: row.imageUrl?.trim() || null,
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
skuId = created.id;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
@@ -1401,12 +1400,6 @@ class AdminSkuRowDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
/** 规格主图 URL;空则回落商品封面 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
imageUrl?: string | null;
|
||||
}
|
||||
|
||||
export class SaveProductSkusDto {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { 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,66 +93,6 @@ 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 聚合筛选 */
|
||||
|
||||
Reference in New Issue
Block a user