Compare commits
6 Commits
cd9696bc1c
...
dev_jacy
| Author | SHA1 | Date | |
|---|---|---|---|
| 22cd00da42 | |||
| f9161368e1 | |||
| 8a061f085f | |||
| b20e566a65 | |||
| 4d434c9c67 | |||
| 62e61a9e63 |
@@ -21,4 +21,7 @@ coverage/
|
|||||||
server/dukang-api/prisma/migrations/
|
server/dukang-api/prisma/migrations/
|
||||||
debug_v3.xlsx
|
debug_v3.xlsx
|
||||||
~$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
|
deploy/auto-release.env
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Input, InputNumber, Radio, Select, Space, Switch, Table, Typography, message,
|
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
||||||
|
import OssUpload from './OssUpload';
|
||||||
|
|
||||||
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
||||||
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
||||||
@@ -24,6 +25,7 @@ type SkuRow = {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
specText?: string;
|
specText?: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function cartesian(attrs: SpecAttr[]): string[][] {
|
function cartesian(attrs: SpecAttr[]): string[][] {
|
||||||
@@ -73,6 +75,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||||
isDefault: !!s.isDefault,
|
isDefault: !!s.isDefault,
|
||||||
|
imageUrl: s.imageUrl ?? '',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -86,10 +89,12 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
saleUnit: 'BOTTLE',
|
saleUnit: 'BOTTLE',
|
||||||
bottlesPerUnit: 1,
|
bottlesPerUnit: 1,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
imageUrl: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const [saving, setSaving] = useState(false);
|
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]);
|
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||||
|
|
||||||
@@ -111,12 +116,21 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
bottlesPerUnit: 1,
|
bottlesPerUnit: 1,
|
||||||
isDefault: i === 0,
|
isDefault: i === 0,
|
||||||
sortOrder: i,
|
sortOrder: i,
|
||||||
|
imageUrl: '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
||||||
setSkus(next.length ? next : skus);
|
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() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -157,8 +171,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
const payloadSkus = skus.map((s, i) => ({
|
const payloadSkus = skus.map((s, i) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
specValueIds: resolveIds(s.specValueIds ?? []),
|
specValueIds: resolveIds(s.specValueIds ?? []),
|
||||||
skuCode: s.skuCode,
|
barcode69: s.barcode69.trim(),
|
||||||
barcode69: s.barcode69,
|
|
||||||
price: s.price,
|
price: s.price,
|
||||||
benefitAmount: s.benefitAmount,
|
benefitAmount: s.benefitAmount,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
@@ -169,10 +182,13 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
||||||
isDefault: !!s.isDefault,
|
isDefault: !!s.isDefault,
|
||||||
sortOrder: i,
|
sortOrder: i,
|
||||||
|
imageUrl: s.imageUrl?.trim() || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
for (const row of payloadSkus) {
|
const barcodes = payloadSkus.map((row) => row.barcode69);
|
||||||
if (!row.barcode69?.trim()) throw new Error('请填写全部 SKU 的 69 码');
|
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
|
||||||
|
if (new Set(barcodes).size !== barcodes.length) {
|
||||||
|
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
|
||||||
}
|
}
|
||||||
|
|
||||||
await request(`/admin/products/${productId}/skus`, {
|
await request(`/admin/products/${productId}/skus`, {
|
||||||
@@ -188,10 +204,13 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editing = editIndex != null ? skus[editIndex] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
先配置销售规格轴(如「包装」),再生成 SKU 矩阵。未配置规格时仅保留默认一行 SKU,与基础信息价/码同步。
|
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
||||||
|
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Typography.Title level={5}>规格轴</Typography.Title>
|
<Typography.Title level={5}>规格轴</Typography.Title>
|
||||||
@@ -272,7 +291,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
size="small"
|
size="small"
|
||||||
rowKey={(_, i) => String(i)}
|
rowKey={(_, i) => String(i)}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1240 }}
|
||||||
dataSource={skus}
|
dataSource={skus}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@@ -281,10 +300,20 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
|
|||||||
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '69码',
|
title: 'SKU',
|
||||||
width: 140,
|
width: 110,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{row.skuCode || '保存后自动生成'}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <span>69码 <Typography.Text type="danger">*</Typography.Text></span>,
|
||||||
|
width: 180,
|
||||||
render: (_, row, index) => (
|
render: (_, row, index) => (
|
||||||
<Input
|
<Input
|
||||||
|
placeholder="本规格独立 69 码"
|
||||||
value={row.barcode69}
|
value={row.barcode69}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...skus];
|
const next = [...skus];
|
||||||
@@ -434,12 +463,154 @@ 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()}>
|
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
||||||
保存规格与 SKU
|
保存规格与 SKU
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,18 @@ export type AdminOrderRow = {
|
|||||||
quantity?: number;
|
quantity?: number;
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: 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;
|
createdAt: string;
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
city?: { id: string; name: string; code: string };
|
city?: { id: string; name: string; code: string };
|
||||||
|
|||||||
@@ -8,3 +8,19 @@ export function downloadExcelCsv(csv: string, filename: string) {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
INVOICE_CATEGORY_LABELS,
|
||||||
INVOICE_KIND_LABELS,
|
INVOICE_KIND_LABELS,
|
||||||
INVOICE_STATUS_LABELS,
|
INVOICE_STATUS_LABELS,
|
||||||
INVOICE_TITLE_TYPE_LABELS,
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceCategory,
|
||||||
type InvoiceKind,
|
type InvoiceKind,
|
||||||
type InvoiceStatus,
|
type InvoiceStatus,
|
||||||
type InvoiceTitleType,
|
type InvoiceTitleType,
|
||||||
@@ -35,6 +37,7 @@ type Row = {
|
|||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
@@ -54,6 +57,7 @@ type CreateFormValues = {
|
|||||||
orderNo: string;
|
orderNo: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
@@ -156,6 +160,7 @@ export default function InvoicesPage() {
|
|||||||
orderNo: values.orderNo.trim(),
|
orderNo: values.orderNo.trim(),
|
||||||
titleType: values.titleType,
|
titleType: values.titleType,
|
||||||
invoiceKind: values.invoiceKind,
|
invoiceKind: values.invoiceKind,
|
||||||
|
invoiceCategory: values.invoiceCategory,
|
||||||
titleName: values.titleName.trim(),
|
titleName: values.titleName.trim(),
|
||||||
taxNo: values.taxNo?.trim() || undefined,
|
taxNo: values.taxNo?.trim() || undefined,
|
||||||
addressPhone: values.addressPhone?.trim() || undefined,
|
addressPhone: values.addressPhone?.trim() || undefined,
|
||||||
@@ -186,9 +191,17 @@ export default function InvoicesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '票种',
|
title: '票种',
|
||||||
width: 120,
|
width: 140,
|
||||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
width: 88,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.invoiceCategory
|
||||||
|
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -233,6 +246,7 @@ export default function InvoicesPage() {
|
|||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
titleType: 'PERSONAL',
|
titleType: 'PERSONAL',
|
||||||
invoiceKind: 'NORMAL',
|
invoiceKind: 'NORMAL',
|
||||||
|
invoiceCategory: 'LIQUOR',
|
||||||
});
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
@@ -324,6 +338,11 @@ export default function InvoicesPage() {
|
|||||||
<Descriptions.Item label="发票类型">
|
<Descriptions.Item label="发票类型">
|
||||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="类型">
|
||||||
|
{detail.invoiceCategory
|
||||||
|
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||||
@@ -382,6 +401,18 @@ export default function InvoicesPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="invoiceCategory"
|
||||||
|
label="类型"
|
||||||
|
rules={[{ required: true, message: '请选择类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
|
||||||
|
value: k,
|
||||||
|
label: INVOICE_CATEGORY_LABELS[k],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="titleType"
|
name="titleType"
|
||||||
label="抬头类型"
|
label="抬头类型"
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Col,
|
||||||
Collapse,
|
Collapse,
|
||||||
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Radio,
|
Radio,
|
||||||
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -20,7 +23,10 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
DELIVERY_PROVIDER_LABELS,
|
DELIVERY_PROVIDER_LABELS,
|
||||||
@@ -130,6 +136,110 @@ type OrderDetail = AdminOrderRow & {
|
|||||||
|
|
||||||
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
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[] {
|
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
|
||||||
if (detail.items?.length) return detail.items;
|
if (detail.items?.length) return detail.items;
|
||||||
if (!detail.productName) return [];
|
if (!detail.productName) return [];
|
||||||
@@ -192,8 +302,11 @@ export default function OrdersPage() {
|
|||||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
const [proxyOpen, setProxyOpen] = useState(false);
|
const [proxyOpen, setProxyOpen] = useState(false);
|
||||||
const [trackOpen, setTrackOpen] = 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 canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
|
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
|
|
||||||
const selectedOrders = useMemo(
|
const selectedOrders = useMemo(
|
||||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||||
@@ -238,6 +351,7 @@ export default function OrdersPage() {
|
|||||||
try {
|
try {
|
||||||
const values = form.getFieldsValue();
|
const values = form.getFieldsValue();
|
||||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
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.orderNo) qs.set('orderNo', values.orderNo);
|
||||||
if (values.status) qs.set('status', values.status);
|
if (values.status) qs.set('status', values.status);
|
||||||
if (values.orderType) qs.set('orderType', values.orderType);
|
if (values.orderType) qs.set('orderType', values.orderType);
|
||||||
@@ -245,12 +359,15 @@ export default function OrdersPage() {
|
|||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||||
if (values.excludeTest) qs.set('excludeTest', '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}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [form, page, pageSize]);
|
}, [form, page, pageSize, initialOrderNo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -451,79 +568,93 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitExport(scope: OrderExportScope) {
|
||||||
|
const values = form.getFieldsValue() as OrderExportFilters;
|
||||||
|
if (scope === 'selected' && !selectedRowKeys.length) {
|
||||||
|
message.warning('请先勾选要导出的订单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
|
||||||
|
const result = await request<OrderExportResult>('/admin/orders/export', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||||
|
message.success(`已导出 ${result.count} 条订单`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<AdminOrderRow> = [
|
const columns: ColumnsType<AdminOrderRow> = [
|
||||||
{
|
{
|
||||||
title: '订单号',
|
title: '商品',
|
||||||
dataIndex: 'orderNo',
|
width: 240,
|
||||||
width: 200,
|
render: (_, row) => (
|
||||||
render: (v, row) => (
|
<div>
|
||||||
<Space size={4}>
|
<Space size={4} wrap>
|
||||||
<span>{v}</span>
|
<span>{row.productName || '—'}</span>
|
||||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
</Space>
|
{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: '城市',
|
title: '状态 / 实付',
|
||||||
width: 90,
|
width: 130,
|
||||||
render: (_, row) => row.city?.name || '—',
|
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: '好客权益',
|
||||||
width: 180,
|
width: 200,
|
||||||
ellipsis: true,
|
render: (_, row) => formatBenefitBrief(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收货信息',
|
||||||
|
width: 240,
|
||||||
render: (_, row) => {
|
render: (_, row) => {
|
||||||
const name = row.productName || '—';
|
const address = formatReceiverAddress(row);
|
||||||
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
|
|
||||||
return (
|
return (
|
||||||
<span title={row.productSpec ? `${name}(${row.productSpec})` : name}>
|
<div>
|
||||||
{name}{qty}
|
<div>
|
||||||
</span>
|
{row.receiverName || '—'} {row.receiverPhone || ''}
|
||||||
|
</div>
|
||||||
|
{address ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{address}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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: '下单时间',
|
title: '下单时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
render: fmtTime,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -557,24 +688,15 @@ export default function OrdersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||||
<Space>
|
<Space size={12}>
|
||||||
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||||
{canProxyOrder ? (
|
{canProxyOrder ? (
|
||||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||||
代下单
|
代下单
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canDeleteOrders ? (
|
|
||||||
<Button
|
|
||||||
danger
|
|
||||||
disabled={!selectedRowKeys.length}
|
|
||||||
onClick={() => setBatchDeleteOpen(true)}
|
|
||||||
>
|
|
||||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -592,54 +714,115 @@ export default function OrdersPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="orderNo" label="订单号">
|
<Row gutter={[16, 8]}>
|
||||||
<Input placeholder="DK..." allowClear />
|
<Col xs={24} md={14} lg={12}>
|
||||||
</Form.Item>
|
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
|
||||||
<Form.Item name="status" label="状态">
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Form.Item name="dateRange" noStyle>
|
||||||
</Form.Item>
|
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
|
||||||
<Form.Item name="orderType" label="类型">
|
</Form.Item>
|
||||||
<Select
|
<Space size={8} wrap>
|
||||||
allowClear
|
{DATE_PRESETS.map((preset) => (
|
||||||
style={{ width: 120 }}
|
<Button
|
||||||
placeholder="全部"
|
key={preset.key}
|
||||||
options={[
|
size="small"
|
||||||
{ value: 'NORMAL', label: '普通订单' },
|
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
|
||||||
{ value: 'PROXY', label: '代下单' },
|
>
|
||||||
]}
|
{preset.label}
|
||||||
/>
|
</Button>
|
||||||
</Form.Item>
|
))}
|
||||||
<Form.Item name="cityId" label="城市">
|
</Space>
|
||||||
<Select
|
</Space>
|
||||||
allowClear
|
</Form.Item>
|
||||||
showSearch
|
</Col>
|
||||||
optionFilterProp="label"
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
style={{ width: 140 }}
|
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||||
placeholder="全部"
|
<Select
|
||||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
allowClear
|
||||||
/>
|
placeholder="全部"
|
||||||
</Form.Item>
|
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
<Form.Item name="receiverPhone" label="收货手机">
|
/>
|
||||||
<Input allowClear />
|
</Form.Item>
|
||||||
</Form.Item>
|
</Col>
|
||||||
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
<Select
|
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
|
||||||
allowClear
|
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
||||||
style={{ width: 140 }}
|
</Form.Item>
|
||||||
placeholder="全部"
|
</Col>
|
||||||
options={[{ value: true, label: '仅待确认大单' }]}
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
/>
|
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
|
||||||
</Form.Item>
|
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
||||||
<Form.Item name="excludeTest" valuePropName="checked">
|
</Form.Item>
|
||||||
<Checkbox>过滤测试账号</Checkbox>
|
</Col>
|
||||||
</Form.Item>
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
<Form.Item>
|
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
|
||||||
<Space>
|
<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>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</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>
|
</Space>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -647,16 +830,18 @@ export default function OrdersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1500 }}
|
scroll={{ x: 1100 }}
|
||||||
rowSelection={canDeleteOrders ? {
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
} : undefined}
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
onChange: (p, ps) => {
|
onChange: (p, ps) => {
|
||||||
setPage(p);
|
setPage(p);
|
||||||
setPageSize(ps);
|
setPageSize(ps);
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ type ProductDetailContentDto = {
|
|||||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
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 = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
skuCode: string;
|
skuCode: string;
|
||||||
@@ -30,9 +46,15 @@ type Row = {
|
|||||||
aromaType: string;
|
aromaType: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
priceMin?: number;
|
||||||
|
priceMax?: number;
|
||||||
|
soldBottles?: number;
|
||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
skuCount?: number;
|
||||||
|
specEnabled?: boolean;
|
||||||
|
skus?: SkuListRow[];
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
@@ -214,7 +236,33 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; 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;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
@@ -222,8 +270,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
|||||||
<Form.Item label="SKU">
|
<Form.Item label="SKU">
|
||||||
<Input disabled placeholder="保存后自动生成" />
|
<Input disabled placeholder="保存后自动生成" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Input />
|
name="barcode69"
|
||||||
|
label="69码"
|
||||||
|
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
|
||||||
|
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
|
||||||
|
>
|
||||||
|
<Input placeholder="默认规格 69 码" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
@@ -251,39 +304,47 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
|||||||
<Form.Item name="sortOrder" label="排序">
|
<Form.Item name="sortOrder" label="排序">
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
{hideFulfillment ? (
|
||||||
name="allowOnlinePurchase"
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||||
label="允许线上购买"
|
该商品有多种规格,履约(线上 / 跨城 / 现场)请到「规格与 SKU」中按规格设置。
|
||||||
valuePropName="checked"
|
</Typography.Paragraph>
|
||||||
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
|
<Form.Item
|
||||||
name="allowCrossCityDelivery"
|
name="allowOnlinePurchase"
|
||||||
label="允许跨城配送"
|
label="允许线上购买"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="须先开启线上购买"
|
extra="配送到址(同城)"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checkedChildren="开"
|
checkedChildren="开"
|
||||||
unCheckedChildren="关"
|
unCheckedChildren="关"
|
||||||
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
onChange={(checked) => {
|
||||||
|
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
||||||
</Form.Item>
|
{() => (
|
||||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
<Form.Item
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
name="allowCrossCityDelivery"
|
||||||
</Form.Item>
|
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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<VisibilityWhitelistFields form={form} />
|
<VisibilityWhitelistFields form={form} />
|
||||||
<Form.Item name="coverUrl" label="封面">
|
<Form.Item name="coverUrl" label="封面">
|
||||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||||
@@ -327,12 +388,35 @@ export default function ProductsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = useMemo(() => [
|
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: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||||
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
{ title: '品名', dataIndex: 'name', width: 200, ellipsis: true },
|
||||||
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
{
|
||||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
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: 'status', width: 80, render: (s) => (
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||||
) },
|
) },
|
||||||
@@ -343,23 +427,7 @@ export default function ProductsPage() {
|
|||||||
render: (v: boolean) =>
|
render: (v: boolean) =>
|
||||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
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: 'sortOrder', width: 60 },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
|
||||||
{
|
{
|
||||||
title: '操作', width: 120,
|
title: '操作', width: 120,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
@@ -372,7 +440,7 @@ export default function ProductsPage() {
|
|||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确认删除该商品?"
|
title="确认删除该商品?"
|
||||||
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
|
description={`将永久删除「${row.name}」,此操作不可恢复。`}
|
||||||
okText="确认删除"
|
okText="确认删除"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
@@ -385,6 +453,46 @@ export default function ProductsPage() {
|
|||||||
},
|
},
|
||||||
], [detail, editForm]);
|
], [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
@@ -392,7 +500,9 @@ export default function ProductsPage() {
|
|||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -401,17 +511,46 @@ export default function ProductsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
rowKey="id"
|
||||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
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)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" onClick={async () => {
|
||||||
const v = await editForm.validateFields();
|
const v = await editForm.validateFields();
|
||||||
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
|
||||||
|
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||||
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = buildProductPayload(v);
|
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) });
|
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
@@ -421,13 +560,24 @@ export default function ProductsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<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="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</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>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
|
{
|
||||||
|
key: 'base',
|
||||||
|
label: '基础信息',
|
||||||
|
children: (
|
||||||
|
<BaseInfoFields
|
||||||
|
mode="edit"
|
||||||
|
form={editForm}
|
||||||
|
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'detail',
|
key: 'detail',
|
||||||
label: '详情页',
|
label: '详情页',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "3.4.15",
|
"version": "3.5.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
|
|||||||
import { fetchClientConfig } from './pay-wechat';
|
import { fetchClientConfig } from './pay-wechat';
|
||||||
|
|
||||||
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
|
||||||
export const APP_VERSION = '3.4.15';
|
export const APP_VERSION = '3.5.4';
|
||||||
|
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import SubPageHeader from '../../components/SubPageHeader';
|
|||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { usePageView } from '../../lib/usePageView';
|
import { usePageView } from '../../lib/usePageView';
|
||||||
import {
|
import {
|
||||||
INVOICE_KIND_LABELS,
|
INVOICE_CATEGORY_LABELS,
|
||||||
INVOICE_TITLE_TYPE_LABELS,
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
type InvoiceKind,
|
type InvoiceCategory,
|
||||||
type UserInvoiceTitleDto,
|
type UserInvoiceTitleDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
@@ -42,7 +42,8 @@ export default function InvoiceApplyPage() {
|
|||||||
|
|
||||||
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
|
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState('');
|
const [selectedId, setSelectedId] = useState('');
|
||||||
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
|
const [invoiceCategory, setInvoiceCategory] = useState<InvoiceCategory>('LIQUOR');
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
const [emailOverride, setEmailOverride] = useState('');
|
const [emailOverride, setEmailOverride] = useState('');
|
||||||
const [phoneOverride, setPhoneOverride] = useState('');
|
const [phoneOverride, setPhoneOverride] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -103,19 +104,17 @@ export default function InvoiceApplyPage() {
|
|||||||
toast('请填写联系电话');
|
toast('请填写联系电话');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (invoiceKind === 'SPECIAL' && selected.titleType !== 'ENTERPRISE') {
|
|
||||||
toast('专用发票仅支持企业抬头');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await request(`/trade/orders/${orderId}/invoices`, {
|
await request(`/trade/orders/${orderId}/invoices`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
titleId: selectedId,
|
titleId: selectedId,
|
||||||
invoiceKind,
|
invoiceKind: 'NORMAL',
|
||||||
|
invoiceCategory,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
|
remark: remark.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast('发票申请已提交', 'success');
|
toast('发票申请已提交', 'success');
|
||||||
@@ -192,20 +191,26 @@ export default function InvoiceApplyPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canApply ? (
|
{canApply ? (
|
||||||
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
|
<>
|
||||||
<Text className="invoice-title-label">发票类型</Text>
|
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
|
||||||
<View className="invoice-title-type-row">
|
<Text className="invoice-title-label">发票类型</Text>
|
||||||
{(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => (
|
<Text className="invoice-kind-static">增值税普通发票</Text>
|
||||||
<Text
|
|
||||||
key={k}
|
|
||||||
className={`invoice-title-type-chip${invoiceKind === k ? ' active' : ''}`}
|
|
||||||
onClick={() => setInvoiceKind(k)}
|
|
||||||
>
|
|
||||||
{INVOICE_KIND_LABELS[k]}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
|
||||||
|
<Text className="invoice-title-label">类型</Text>
|
||||||
|
<View className="invoice-title-type-row">
|
||||||
|
{(['LIQUOR', 'CATERING'] as InvoiceCategory[]).map((k) => (
|
||||||
|
<Text
|
||||||
|
key={k}
|
||||||
|
className={`invoice-title-type-chip${invoiceCategory === k ? ' active' : ''}`}
|
||||||
|
onClick={() => setInvoiceCategory(k)}
|
||||||
|
>
|
||||||
|
{INVOICE_CATEGORY_LABELS[k]}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canApply ? (
|
{canApply ? (
|
||||||
@@ -258,7 +263,11 @@ export default function InvoiceApplyPage() {
|
|||||||
|
|
||||||
{canApply && selected && !selected.email ? (
|
{canApply && selected && !selected.email ? (
|
||||||
<View className="invoice-title-field" style={{ marginTop: 16 }}>
|
<View className="invoice-title-field" style={{ marginTop: 16 }}>
|
||||||
<Text className="invoice-title-label">接收邮箱</Text>
|
<Text className="invoice-title-label">
|
||||||
|
<Text className="invoice-title-star">*</Text>
|
||||||
|
接收邮箱
|
||||||
|
<Text className="invoice-title-label-hint">必填</Text>
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
placeholder="电子发票将发送至此邮箱"
|
placeholder="电子发票将发送至此邮箱"
|
||||||
@@ -279,6 +288,19 @@ export default function InvoiceApplyPage() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{canApply ? (
|
||||||
|
<View className="invoice-title-field" style={{ marginTop: 16 }}>
|
||||||
|
<Text className="invoice-title-label">备注</Text>
|
||||||
|
<Input
|
||||||
|
className="invoice-title-input"
|
||||||
|
maxlength={200}
|
||||||
|
placeholder="选填,将显示在发票申请备注中"
|
||||||
|
value={remark}
|
||||||
|
onInput={(e) => setRemark(e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{canApply && titles.length > 0 ? (
|
{canApply && titles.length > 0 ? (
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ const ACTION_ICONS = {
|
|||||||
|
|
||||||
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
|
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
children,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text className="invoice-title-label">
|
||||||
|
{required ? <Text className="invoice-title-star">*</Text> : null}
|
||||||
|
{children}
|
||||||
|
{hint ? <Text className="invoice-title-label-hint">{hint}</Text> : null}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TitleActionIcon({
|
function TitleActionIcon({
|
||||||
kind,
|
kind,
|
||||||
label,
|
label,
|
||||||
@@ -120,6 +138,15 @@ export default function InvoiceTitlesPage() {
|
|||||||
toast('企业抬头须填写税号');
|
toast('企业抬头须填写税号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const email = draft.email?.trim() || '';
|
||||||
|
if (!email) {
|
||||||
|
toast('请填写接收邮箱(必填)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
toast('邮箱格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload: UpsertInvoiceTitleRequest = {
|
const payload: UpsertInvoiceTitleRequest = {
|
||||||
@@ -152,6 +179,10 @@ export default function InvoiceTitlesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setDefault(t: UserInvoiceTitleDto) {
|
async function setDefault(t: UserInvoiceTitleDto) {
|
||||||
|
if (!t.email?.trim()) {
|
||||||
|
toast('请先补全接收邮箱后再设为默认');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await request(`/trade/invoice-titles/${t.id}`, {
|
await request(`/trade/invoice-titles/${t.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -241,7 +272,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">抬头类型</Text>
|
<FieldLabel required>抬头类型</FieldLabel>
|
||||||
<View className="invoice-title-type-row">
|
<View className="invoice-title-type-row">
|
||||||
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
|
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
|
||||||
<Text
|
<Text
|
||||||
@@ -258,7 +289,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">抬头名称</Text>
|
<FieldLabel required>抬头名称</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={128}
|
maxlength={128}
|
||||||
@@ -270,7 +301,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
|
|
||||||
{draft.titleType === 'ENTERPRISE' ? (
|
{draft.titleType === 'ENTERPRISE' ? (
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">税号</Text>
|
<FieldLabel required>税号</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={32}
|
maxlength={32}
|
||||||
@@ -282,11 +313,13 @@ export default function InvoiceTitlesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View className="invoice-title-field">
|
<View className="invoice-title-field">
|
||||||
<Text className="invoice-title-label">接收邮箱</Text>
|
<FieldLabel required hint="必填,电子发票将发送至此">
|
||||||
|
接收邮箱
|
||||||
|
</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={128}
|
maxlength={128}
|
||||||
placeholder="电子发票将发送至此邮箱"
|
placeholder="请填写邮箱(必填)"
|
||||||
value={draft.email || ''}
|
value={draft.email || ''}
|
||||||
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
|
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
|
||||||
/>
|
/>
|
||||||
@@ -311,7 +344,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={256}
|
maxlength={256}
|
||||||
placeholder="专用发票需要,选填"
|
placeholder="选填"
|
||||||
value={draft.addressPhone || ''}
|
value={draft.addressPhone || ''}
|
||||||
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
|
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
|
||||||
/>
|
/>
|
||||||
@@ -321,7 +354,7 @@ export default function InvoiceTitlesPage() {
|
|||||||
<Input
|
<Input
|
||||||
className="invoice-title-input"
|
className="invoice-title-input"
|
||||||
maxlength={256}
|
maxlength={256}
|
||||||
placeholder="专用发票需要,选填"
|
placeholder="选填"
|
||||||
value={draft.bankAccount || ''}
|
value={draft.bankAccount || ''}
|
||||||
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
|
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
import '../../styles/order.css';
|
import '../../styles/order.css';
|
||||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
@@ -19,13 +19,13 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
|
|||||||
import { isWechatEnv } from '../../lib/weixin';
|
import { isWechatEnv } from '../../lib/weixin';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
import payLogo from '../../assets/logo2.png';
|
||||||
|
|
||||||
export default function PayPage() {
|
export default function PayPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const orderId = router.params.orderId ?? '';
|
const orderId = router.params.orderId ?? '';
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
const [mockMode, setMockMode] = useState(true);
|
|
||||||
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
|
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [orderNo, setOrderNo] = useState('');
|
const [orderNo, setOrderNo] = useState('');
|
||||||
@@ -39,7 +39,6 @@ export default function PayPage() {
|
|||||||
const refreshPayReadiness = useCallback(async () => {
|
const refreshPayReadiness = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||||
setMockMode(config.mockPay);
|
|
||||||
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
|
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
|
||||||
return profile;
|
return profile;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -174,7 +173,7 @@ export default function PayPage() {
|
|||||||
<View className="sub-page-body">
|
<View className="sub-page-body">
|
||||||
<View className="pay-status">
|
<View className="pay-status">
|
||||||
<View className="pay-status-icon">
|
<View className="pay-status-icon">
|
||||||
<Text>¥</Text>
|
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
|
||||||
</View>
|
</View>
|
||||||
<Text className="pay-status-title">
|
<Text className="pay-status-title">
|
||||||
{needsWechatAuth ? '需完成微信授权' : '待支付'}
|
{needsWechatAuth ? '需完成微信授权' : '待支付'}
|
||||||
@@ -204,12 +203,6 @@ export default function PayPage() {
|
|||||||
<Text className="order-row-label">支付方式</Text>
|
<Text className="order-row-label">支付方式</Text>
|
||||||
<Text className="order-row-value">微信支付</Text>
|
<Text className="order-row-value">微信支付</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="order-row">
|
|
||||||
<Text className="order-row-label">说明</Text>
|
|
||||||
<Text className="order-row-value">
|
|
||||||
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
{msg ? (
|
{msg ? (
|
||||||
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
|
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ export default function ProductDetailPage() {
|
|||||||
allowOnSitePickup: activeSku.allowOnSitePickup,
|
allowOnSitePickup: activeSku.allowOnSitePickup,
|
||||||
}
|
}
|
||||||
: product;
|
: 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(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -172,9 +178,9 @@ export default function ProductDetailPage() {
|
|||||||
path: `/pages/product-detail/index?id=${productId}`,
|
path: `/pages/product-detail/index?id=${productId}`,
|
||||||
dynamicTitle: product?.name,
|
dynamicTitle: product?.name,
|
||||||
dynamicDesc: product?.subtitle,
|
dynamicDesc: product?.subtitle,
|
||||||
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
|
dynamicImageUrl: (activeSku?.imageUrl?.trim() || (product ? getProductMainImage(product) : undefined)),
|
||||||
}),
|
}),
|
||||||
[product, productId],
|
[product, productId, activeSku],
|
||||||
);
|
);
|
||||||
|
|
||||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||||
@@ -249,7 +255,6 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
const allowOnline = canBuyOnline(fulfillment ?? {});
|
const allowOnline = canBuyOnline(fulfillment ?? {});
|
||||||
const allowOnSite = canPickupOnSite(fulfillment ?? {});
|
const allowOnSite = canPickupOnSite(fulfillment ?? {});
|
||||||
const carouselImages = getProductCarouselImages(product);
|
|
||||||
const detailImages = getProductDetailImages(product);
|
const detailImages = getProductDetailImages(product);
|
||||||
const detail = product.detailContent ?? {};
|
const detail = product.detailContent ?? {};
|
||||||
const features = detail.features ?? [];
|
const features = detail.features ?? [];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image, ScrollView } from '@tarojs/components';
|
||||||
import '../../styles/store-detail.css';
|
import '../../styles/store-detail.css';
|
||||||
import Taro, {
|
import Taro, {
|
||||||
useDidShow,
|
useDidShow,
|
||||||
@@ -444,17 +444,19 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{intro ? (
|
{benefitRule ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">门店详情</Text>
|
<Text className="store-detail-section-title store-detail-section-title--rule">使用规则</Text>
|
||||||
<Text className="store-detail-intro">{intro}</Text>
|
<Text className="store-detail-intro">{benefitRule}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{benefitRule ? (
|
{intro ? (
|
||||||
<View className="store-detail-section">
|
<View className="store-detail-section">
|
||||||
<Text className="store-detail-section-title">好客权益使用规则</Text>
|
<Text className="store-detail-section-title">门店详情</Text>
|
||||||
<Text className="store-detail-intro">{benefitRule}</Text>
|
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
|
||||||
|
<Text className="store-detail-intro">{intro}</Text>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,30 @@
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invoice-title-star {
|
||||||
|
color: #c62828;
|
||||||
|
margin-right: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-title-label-hint {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c62828;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-kind-static {
|
||||||
|
display: block;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-on-surface, #1a1a1a);
|
||||||
|
line-height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--color-surface, #fafafa);
|
||||||
|
border: 1px solid var(--color-outline, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.invoice-title-input {
|
.invoice-title-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
@@ -488,16 +488,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pay-status-icon {
|
.pay-status-icon {
|
||||||
width: 72px;
|
width: 88px;
|
||||||
height: 72px;
|
height: 88px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(166, 29, 36, 0.08);
|
background: #f7f4ee;
|
||||||
color: var(--color-heritage-red);
|
|
||||||
font-size: 36px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 auto 16px;
|
margin: 0 auto 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-status-brand {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pay-status-title {
|
.pay-status-title {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@
|
|||||||
|
|
||||||
.product-detail-promo {
|
.product-detail-promo {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-top: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
|
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
|
||||||
@@ -377,7 +378,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-detail-specs {
|
.product-detail-specs {
|
||||||
margin: 12px 0 4px;
|
margin: 16px 0 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -211,6 +211,17 @@
|
|||||||
color: var(--color-on-surface);
|
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 {
|
.store-detail-env-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
| 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.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.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
|
||||||
|
| 3.5.4 | 08-21 | 商品 SPU+SKU 规格;DK 码/独立 69 码/规格主图;下单兼容回落;发票品类;权益文案与门店详情/收银台 UI;客户端版本号 | [`v3.5.4`](./杜康好客-v3.5.4-开发文档.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,6 +69,13 @@
|
|||||||
|
|
||||||
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
||||||
|
|
||||||
|
**HQ 订单导出**(`admin-web` 订单监控):
|
||||||
|
|
||||||
|
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
|
||||||
|
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
||||||
|
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
||||||
|
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
||||||
|
|
||||||
### 3.3 佣金与结算
|
### 3.3 佣金与结算
|
||||||
|
|
||||||
| 类型 | 默认 | 释放 |
|
| 类型 | 默认 | 释放 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
||||||
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
||||||
| 近期版本 | … · v3.4.16 · **v3.5.3 门店照片/企微审核/分享 + 核销金额/提现补修 + 权益图标** |
|
| 近期版本 | … · v3.5.3 · **v3.5.4 商品规格 SPU+SKU + 发票品类 + 权益/门店详情 UI** |
|
||||||
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
||||||
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
||||||
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
||||||
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
||||||
| **admin-web** | 商品/开城/门店/订单/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
||||||
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
||||||
|
|
||||||
## 3. 场景 SC-01~09
|
## 3. 场景 SC-01~09
|
||||||
@@ -57,10 +57,14 @@
|
|||||||
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
||||||
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.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.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
|
||||||
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | 🔶 开发中 |
|
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | ✅ 生产 |
|
||||||
|
| 3.5.4 | [`商品规格 SPU+SKU + 发票/文案 UI`](./杜康好客-v3.5.4-开发文档.md) | ✅ 生产(weapp 版本号需单独上传) |
|
||||||
|
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF;`POST /admin/orders/export` |
|
||||||
|
| 2026-08-21 | v3.5.4 发生产:规格表/回填 SKU/DK 码/主图列/发票品类;H5+API 200 |
|
||||||
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
|
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
|
||||||
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态 |
|
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态 |
|
||||||
| 2026-08-07 | v3.4.14 增补统一测试白名单(限测可见 / Mock 旁路;测试流水计入结算) |
|
| 2026-08-07 | v3.4.14 增补统一测试白名单(限测可见 / Mock 旁路;测试流水计入结算) |
|
||||||
|
|||||||
+122
-39
@@ -1,62 +1,145 @@
|
|||||||
# 杜康好客 · v3.5.4 商品规格(SPU + SKU)
|
# 杜康好客 · v3.5.4 开发文档
|
||||||
|
|
||||||
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
|
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
|
||||||
> 目标:商品详情页内可选销售规格;价格、权益、69 码、履约、上下架按 SKU;箱装按瓶当量起购与物流。
|
> **生产 commit**:`5fdddae`(规格主图与门店使用规则合并入 `main`)及后续客户端版本号 `3.5.4`
|
||||||
|
> **主题**:商品规格(SPU + SKU)上线 + 同批次发票 / 文案 / 门店详情体验
|
||||||
|
|
||||||
## 范围
|
---
|
||||||
|
|
||||||
| # | 项 | 交付 |
|
## 1. 版本目标
|
||||||
|
|
||||||
|
1. 商品由单规格升级为 **SPU + 多 SKU**:价格、权益、69 码、履约、上下架、主图按规格。
|
||||||
|
2. 箱装按 **瓶当量** 起购与物流,不改城市起购阈值口径。
|
||||||
|
3. **旧客户端兼容**:不传 `skuId` 仍可下单;无 SKU 回落 SPU,禁止「商品暂无可售规格」误伤。
|
||||||
|
4. 同批次补齐:发票品类、权益文案、收银台 logo、门店详情「使用规则」、C 端版本号。
|
||||||
|
|
||||||
|
**不做**:SKU 库存、把现有酒祖 10/15/20 合并为一个 SPU。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 功能清单(完整)
|
||||||
|
|
||||||
|
### 2.1 商品规格(核心)
|
||||||
|
|
||||||
|
| # | 模块 | 交付说明 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 1 | 数据模型 | `common_product_item` 作 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
|
||||||
|
| 2 | 存量迁移 | 每商品默认 1 个瓶装 SKU;SQL `migrate-product-sku-v354.sql`;脚本 `backfill-product-skus.ts` |
|
||||||
|
| 3 | Catalog | 列表 `specEnabled`/`saleUnit`;详情 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
|
||||||
|
| 4 | Trade | preview/create 可选 `skuId`;有可售 SKU → 默认/唯一;**0 可售 → 回落 SPU**;订单 `sku_id` 可空 |
|
||||||
|
| 5 | Domain | 起购/大单/物流按 `quantity × bottlesPerUnit` 瓶当量 |
|
||||||
|
| 6 | Admin | `PUT .../specs`、`PUT .../skus`;抽屉加宽;「填写」弹窗;规格轴 + SKU 矩阵 |
|
||||||
|
| 7 | mini-user | 详情规格 chips;确认页带 `skuId`;箱装数量文案;选规格切主图 |
|
||||||
|
| 8 | 代下单 | HQ / 合伙人可选规格 SKU |
|
||||||
|
|
||||||
|
### 2.2 SKU 码与 69 码
|
||||||
|
|
||||||
|
| # | 项 | 说明 |
|
||||||
|---|----|------|
|
|---|----|------|
|
||||||
| 1 | 数据模型 | `common_product_item` 升级为 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
|
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可手填;脚本 `rewrite-sku-codes-dk.ts` 改存量 |
|
||||||
| 2 | 存量迁移 | 每商品 1 个默认瓶装 SKU;SQL:`prisma/migrate-product-sku-v354.sql`;脚本:`prisma/backfill-product-skus.ts` |
|
| 10 | 69 码 | **每规格独立**;同商品内 + 跨商品唯一;不可与其它 SKU 冲突 |
|
||||||
| 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) |
|
|
||||||
|
|
||||||
**不做**:SKU 规格图、库存、把现有酒祖 10/15/20 合并为一个 SPU。
|
### 2.3 规格主图
|
||||||
|
|
||||||
## 兼容规则(线上)
|
| # | 项 | 说明 |
|
||||||
|
|---|----|------|
|
||||||
|
| 11 | DB | `common_product_sku.image_url`(`migrate-sku-image.sql`) |
|
||||||
|
| 12 | Admin | 弹窗可为每 SKU 上传主图 |
|
||||||
|
| 13 | C 端 | 选规格优先 SKU 主图,空则回落商品封面 |
|
||||||
|
|
||||||
- 不改 `/api/v1` 前缀;**只增字段,不删旧字段语义**。
|
### 2.4 发票(同批次)
|
||||||
- 旧客户端不传 `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 | 发票列表展示品类;专票仍可走后台 |
|
||||||
|
|
||||||
1. API + DB 迁移(每商品仅默认 SKU)。**此时不要给任何商品加第二 SKU。**
|
### 2.5 文案与 UI(同批次)
|
||||||
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`
|
- `bottleQty = quantity × bottlesPerUnit`
|
||||||
- 城市起购阈值仍是瓶;箱装 SKU(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6。
|
- 城市起购仍按瓶;箱装(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6
|
||||||
- 大单拦截、承运商 `goodsNum` 使用瓶当量。
|
- 大单拦截、承运商 `goodsNum` 用瓶当量
|
||||||
|
|
||||||
## 关键表摘要
|
---
|
||||||
|
|
||||||
|
## 6. 关键表 / 字段摘要
|
||||||
|
|
||||||
- `common_product_spec_attr` / `common_product_spec_value`
|
- `common_product_spec_attr` / `common_product_spec_value`
|
||||||
- `common_product_sku`(`sale_unit`=`BOTTLE|BOX`,`bottles_per_unit`)
|
- `common_product_sku`(`sale_unit`、`bottles_per_unit`、`image_url`)
|
||||||
- `common_product_sku_spec`
|
- `common_product_sku_spec`
|
||||||
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
|
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
|
||||||
|
- `user_invoice.invoice_category`
|
||||||
|
|
||||||
## 权益文案(mini-user)
|
---
|
||||||
|
|
||||||
| 位置 | 变更 |
|
## 7. 关键路径(便于排查)
|
||||||
|------|------|
|
|
||||||
| 门店详情等 | 「好客权益券」→「好客权益」(如「好客权益使用规则」) |
|
|
||||||
| 首页商品角标 `CouponBadge` | 由「享 + 门店 icon + 金额 + 好客权益」改为纯文案 **「享{amount}好客权益」** |
|
|
||||||
|
|
||||||
其它页 `BenefitFigure`(我的/权益/核销等)仍保留门店核销图标,仅首页角标去 icon。
|
| 域 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| 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/` |
|
||||||
|
|
||||||
## 验收
|
---
|
||||||
|
|
||||||
- [ ] 未配规格:旧小程序/H5/代下单路径与现网一致
|
## 8. 验收清单
|
||||||
- [ ] 多规格:详情选规格后价格/权益/履约变化;漏选下单 400
|
|
||||||
- [ ] 整箱 SKU:数量 1 过起购;订单快照 `bottles_per_unit=6`
|
- [ ] 未配规格:旧端下单与现网一致(无 SKU 也能下)
|
||||||
- [ ] `GET /catalog/products/:id` 仍含 `id/name/price/spec/skuCode/...`
|
- [ ] 多规格:选规格后价格/权益/履约/主图变化;漏选 400
|
||||||
- [ ] 小程序无「好客权益券」字样;首页角标为「享{金额}好客权益」且无门店 icon
|
- [ ] 整箱:数量 1 过起购;订单 `bottles_per_unit=6`
|
||||||
|
- [ ] 详情 API 仍含 `id/name/price/spec/skuCode/...`
|
||||||
|
- [ ] 后台 SKU 码 DK 开头不可手填;每规格独立 69 码与主图
|
||||||
|
- [ ] 无「好客权益券」;首页角标「享{金额}好客权益」无门店 icon
|
||||||
|
- [ ] 门店详情「使用规则」红色且在详情上方;详情超长可滚
|
||||||
|
- [ ] 待支付页为 logo2 印章
|
||||||
|
- [ ] 发票:普票固定 + 酒水/餐饮品类 + 邮箱必填
|
||||||
|
- [ ] 「我的」页版本 **杜康好客 v3.5.4**(需 weapp 新包)
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# 杜康好客 · v3.5.5 开发文档
|
||||||
|
|
||||||
|
> **2026-08-23** · admin-web / API
|
||||||
|
> **主题**:HQ 订单监控导出(Excel / PDF)+ 订单列表页布局优化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
1. 总部 **订单监控** 支持按筛选或勾选导出 **Excel(.xlsx)** / **PDF(.pdf)**。
|
||||||
|
2. 导出内容聚焦运营履约:**商品明细、收货信息、好客权益摘要**;次要字段从列表与导出列中精简。
|
||||||
|
3. **订单列表页** 筛选区改为网格布局,支持日期快捷选择;导出、查询、重置、批量删除同一操作行。
|
||||||
|
4. 修复现场取货订单地址展示为「现场现场取货现场取货」的问题。
|
||||||
|
|
||||||
|
**不做**:C 端 / 合伙人 / 门店端改动;无 Prisma 迁移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 功能清单
|
||||||
|
|
||||||
|
### 2.1 HQ 订单导出(核心)
|
||||||
|
|
||||||
|
| # | 模块 | 交付说明 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 1 | API | `POST /api/v1/admin/orders/export` — 生成文件,响应 `{ filename, mimeType, contentBase64, count }` |
|
||||||
|
| 2 | 预览 | `POST /api/v1/admin/orders/export/preview` — 返回 `{ count, max, exceeds }`(上限 5000) |
|
||||||
|
| 3 | 导出范围 | `scope=filter` 按当前筛选;`scope=selected` 仅导出勾选 `ids` |
|
||||||
|
| 4 | 格式 | `format=xlsx`(exceljs)/ `format=pdf`(pdfkit,横向 A4) |
|
||||||
|
| 5 | 权限 | `HqAuthGuard` + `orders`;操作审计 `ORDER_EXPORT` |
|
||||||
|
| 6 | 筛选复用 | `buildOrderWhere()` 与列表查询共用;新增 `deliveryType` 筛选 |
|
||||||
|
|
||||||
|
**导出列**(11 列):
|
||||||
|
|
||||||
|
订单号、下单时间、状态、商品、规格、数量、实付、好客权益、收货人、手机、收货地址。
|
||||||
|
|
||||||
|
好客权益列:有权益券时 `总额¥X / 已用¥Y / 余¥Z`;否则回落订单 `benefitAmount`。
|
||||||
|
|
||||||
|
### 2.2 订单列表页(admin-web)
|
||||||
|
|
||||||
|
| # | 项 | 说明 |
|
||||||
|
|---|----|------|
|
||||||
|
| 7 | 筛选布局 | 网格表单:下单日期、状态、类型、配送、城市、收货手机;大单拦截 / 过滤测试 |
|
||||||
|
| 8 | 日期快捷 | 当日、当周(周一至周日)、当月、当季、当年 |
|
||||||
|
| 9 | 列表列 | 商品(含规格/数量/标签)、状态+实付、好客权益、收货信息、下单时间、操作 |
|
||||||
|
| 10 | 勾选导出 | 列表常驻勾选;左侧 Excel/PDF +「导出已勾选」「导出全部筛选」 |
|
||||||
|
| 11 | 操作行 | 右侧同一行:查询、重置、批量删除(有权限时) |
|
||||||
|
| 12 | 现场取货 | 收货地址识别 `ON_SITE_PICKUP` 或省市区占位后,统一显示「现场取货」 |
|
||||||
|
|
||||||
|
### 2.3 列表数据增强
|
||||||
|
|
||||||
|
| # | 项 | 说明 |
|
||||||
|
|---|----|------|
|
||||||
|
| 13 | 权益关联 | `GET /admin/orders` include `benefitCoupon`(券号、总额/已用/余额、状态) |
|
||||||
|
| 14 | 收货字段 | `AdminOrderRow` 增加省市区、地址、`benefitAmount`、`benefitCoupon` 类型 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 契约
|
||||||
|
|
||||||
|
### 3.1 导出请求体 `AdminOrdersExportDto`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scope": "filter",
|
||||||
|
"format": "xlsx",
|
||||||
|
"ids": ["123"],
|
||||||
|
"status": "PENDING_SHIP",
|
||||||
|
"orderType": "NORMAL",
|
||||||
|
"cityId": "1",
|
||||||
|
"receiverPhone": "138",
|
||||||
|
"deliveryType": "LOCAL",
|
||||||
|
"fulfillmentHold": true,
|
||||||
|
"excludeTest": true,
|
||||||
|
"createdFrom": "2026-08-01",
|
||||||
|
"createdTo": "2026-08-23"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `scope` | `filter` \| `selected`(勾选时仅传 `ids`,忽略其它筛选) |
|
||||||
|
| `format` | `xlsx` \| `pdf` |
|
||||||
|
| `createdFrom` / `createdTo` | 按筛选导出且均未填时,服务端默认近 30 天 |
|
||||||
|
|
||||||
|
### 3.2 错误码
|
||||||
|
|
||||||
|
| 场景 | HTTP | message |
|
||||||
|
|------|------|---------|
|
||||||
|
| 超过 5000 条 | 400 | 超过 5000 条,请缩小日期或筛选条件 |
|
||||||
|
| 无数据 | 400 | 没有可导出的订单 |
|
||||||
|
| 勾选为空 | 400 | 请先勾选要导出的订单 |
|
||||||
|
| PDF 无字体 | 500 | 未找到可用于 PDF 的中文字体… |
|
||||||
|
|
||||||
|
### 3.3 列表查询增量
|
||||||
|
|
||||||
|
`GET /admin/orders` 查询参数新增(与导出筛选一致):
|
||||||
|
|
||||||
|
- `deliveryType`:`LOCAL` \| `CROSS_CITY` \| `ON_SITE_PICKUP`
|
||||||
|
- `createdFrom` / `createdTo`:下单日期(已有 DTO,本版接 UI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 依赖与部署
|
||||||
|
|
||||||
|
### 4.1 新增 npm 依赖(仅 API)
|
||||||
|
|
||||||
|
- `exceljs` — xlsx 生成
|
||||||
|
- `pdfkit` + `@types/pdfkit` — pdf 生成
|
||||||
|
|
||||||
|
### 4.2 PDF 中文字体
|
||||||
|
|
||||||
|
路径:`server/dukang-api/assets/fonts/`(见 `README.md`)。
|
||||||
|
|
||||||
|
解析顺序:
|
||||||
|
|
||||||
|
1. 环境变量 `EXPORT_PDF_FONT_PATH`
|
||||||
|
2. `assets/fonts/NotoSansSC-Regular.otf` / `simhei.ttf` / `msyh.ttc`
|
||||||
|
3. Linux 系统 Noto CJK
|
||||||
|
4. Windows 系统字体(开发机)
|
||||||
|
|
||||||
|
> 字体二进制已加入 `.gitignore`;生产部署需放置字体或安装 `fonts-noto-cjk`。
|
||||||
|
|
||||||
|
### 4.3 发版步骤
|
||||||
|
|
||||||
|
| 步骤 | 动作 |
|
||||||
|
|------|------|
|
||||||
|
| 1 | `pnpm install`(根目录,拉取 exceljs/pdfkit) |
|
||||||
|
| 2 | 确认 `server/dukang-api/assets/fonts/` 有中文字体(PDF 必需) |
|
||||||
|
| 3 | `pnpm build`(API + admin-web) |
|
||||||
|
| 4 | 发 `dukang-api` + `admin-web`;**无需** `prisma migrate` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 关键路径(便于排查)
|
||||||
|
|
||||||
|
| 域 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| 导出工具 | `server/dukang-api/src/modules/ops/admin-order-export.util.ts` |
|
||||||
|
| 订单服务 | `server/dukang-api/src/modules/ops/admin-orders.service.ts` |
|
||||||
|
| 控制器 | `server/dukang-api/src/modules/ops/admin-orders.controller.ts` |
|
||||||
|
| DTO | `server/dukang-api/src/modules/ops/dto/admin-query.dto.ts` |
|
||||||
|
| 审计常量 | `server/dukang-api/src/common/hq-operation/hq-operation.constants.ts`(`ORDER_EXPORT`) |
|
||||||
|
| 前端页面 | `apps/admin-web/src/pages/OrdersPage.tsx` |
|
||||||
|
| 下载工具 | `apps/admin-web/src/lib/exportExcel.ts`(`downloadBase64File`) |
|
||||||
|
| 类型 | `apps/admin-web/src/lib/api.ts`(`AdminOrderRow`) |
|
||||||
|
| 需求 | `docs/杜康好客-v3-PRD.md` §3.2 HQ 订单导出 |
|
||||||
|
| 现状 | `docs/杜康好客-v3-现状对照.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 验收清单
|
||||||
|
|
||||||
|
- [ ] 订单列表:网格筛选 + 日期快捷(当日/当周/当月/当季/当年)可用
|
||||||
|
- [ ] 列表展示:商品(规格×数量)、状态/实付、好客权益、收货人+手机+地址
|
||||||
|
- [ ] 现场取货订单地址仅显示「现场取货」,不出现重复拼接
|
||||||
|
- [ ] 勾选若干订单 →「导出已勾选」→ xlsx / pdf 条数与勾选一致
|
||||||
|
- [ ] 设筛选条件 →「导出全部筛选」→ 文件含筛选范围内全部订单(≤5000)
|
||||||
|
- [ ] 超 5000 条时导出全部筛选返回明确错误
|
||||||
|
- [ ] Excel / PDF 中文正常;PDF 表头与分页可读
|
||||||
|
- [ ] 无 `orders` 权限账号无法调用导出接口
|
||||||
|
- [ ] HQ 操作日志有 `ORDER_EXPORT` 记录
|
||||||
|
- [ ] 无删除权限账号仍可勾选并导出;有删除权限时批量删除与导出/查询同一行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 与 v3.5.4 关系
|
||||||
|
|
||||||
|
- **仅 HQ 内部工具**,不影响 C 端小程序版本号。
|
||||||
|
- 商品规格(SPU/SKU)、发票等 v3.5.4 能力保持不变。
|
||||||
|
- PRD §3.2 已补充 HQ 订单导出业务规则;本版为实现与 UI 落地说明。
|
||||||
@@ -35,6 +35,8 @@ export interface ProductSkuDto {
|
|||||||
saleUnit: ProductSaleUnit;
|
saleUnit: ProductSaleUnit;
|
||||||
bottlesPerUnit: number;
|
bottlesPerUnit: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
/** 规格主图;无则详情页回落商品封面/轮播 */
|
||||||
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductDto {
|
export interface ProductDto {
|
||||||
@@ -129,6 +131,7 @@ export interface AdminProductSkuInput {
|
|||||||
id?: string;
|
id?: string;
|
||||||
/** 规格值 id;无规格时为空数组 */
|
/** 规格值 id;无规格时为空数组 */
|
||||||
specValueIds?: string[];
|
specValueIds?: string[];
|
||||||
|
/** 忽略;服务端自动生成 DK 码 */
|
||||||
skuCode?: string;
|
skuCode?: string;
|
||||||
barcode69: string;
|
barcode69: string;
|
||||||
price: number;
|
price: number;
|
||||||
@@ -141,4 +144,5 @@ export interface AdminProductSkuInput {
|
|||||||
bottlesPerUnit?: number;
|
bottlesPerUnit?: number;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
|
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
|
||||||
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
|
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
|
||||||
|
/** C 端开票品类:酒水类 / 餐饮类(票种固定增值税普通发票) */
|
||||||
|
export type InvoiceCategory = 'LIQUOR' | 'CATERING';
|
||||||
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
|
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
|
||||||
|
|
||||||
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
|
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
|
||||||
@@ -12,6 +14,11 @@ export const INVOICE_KIND_LABELS: Record<InvoiceKind, string> = {
|
|||||||
SPECIAL: '增值税专用发票',
|
SPECIAL: '增值税专用发票',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const INVOICE_CATEGORY_LABELS: Record<InvoiceCategory, string> = {
|
||||||
|
LIQUOR: '酒水类',
|
||||||
|
CATERING: '餐饮类',
|
||||||
|
};
|
||||||
|
|
||||||
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
||||||
PENDING: '待开票',
|
PENDING: '待开票',
|
||||||
ISSUED: '已开票',
|
ISSUED: '已开票',
|
||||||
@@ -21,6 +28,7 @@ export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
|||||||
export interface CreateInvoiceRequest {
|
export interface CreateInvoiceRequest {
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
@@ -37,6 +45,7 @@ export interface InvoiceDto {
|
|||||||
userId: string;
|
userId: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string | null;
|
taxNo?: string | null;
|
||||||
addressPhone?: string | null;
|
addressPhone?: string | null;
|
||||||
|
|||||||
Generated
+598
-77
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 921 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
# PDF 导出字体
|
||||||
|
|
||||||
|
订单 PDF 导出需要支持中文的 TTF/OTF/TTC 字体。按优先级尝试:
|
||||||
|
|
||||||
|
1. 环境变量 `EXPORT_PDF_FONT_PATH`
|
||||||
|
2. `assets/fonts/NotoSansSC-Regular.otf`(推荐,SIL OFL)
|
||||||
|
3. `assets/fonts/simhei.ttf`
|
||||||
|
4. `assets/fonts/msyh.ttc`
|
||||||
|
5. Linux 系统字体(如 `fonts-noto-cjk`)
|
||||||
|
6. Windows 系统字体(开发机)
|
||||||
|
|
||||||
|
本地开发可将 `C:\Windows\Fonts\simhei.ttf` 复制到本目录。
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": [{ "include": "../assets/**/*", "outDir": "dist", "watchAssets": true }]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
|
"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: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:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
||||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
|
||||||
|
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alicloud/dysmsapi20170525": "^4.6.0",
|
"@alicloud/dysmsapi20170525": "^4.6.0",
|
||||||
@@ -43,9 +44,11 @@
|
|||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"ip2region": "^2.3.0",
|
"ip2region": "^2.3.0",
|
||||||
|
"pdfkit": "^0.19.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
@@ -57,6 +60,7 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"prisma": "^5.18.0",
|
"prisma": "^5.18.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|||||||
@@ -5,6 +5,30 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
const PREFIX = 'DK';
|
||||||
|
const PAD = 6;
|
||||||
|
const RE = /^DK(\d+)$/;
|
||||||
|
|
||||||
|
async function nextDkCode() {
|
||||||
|
const [items, skus] = await Promise.all([
|
||||||
|
prisma.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
prisma.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let maxSeq = 0;
|
||||||
|
for (const row of [...items, ...skus]) {
|
||||||
|
const m = RE.exec(row.skuCode);
|
||||||
|
if (!m) continue;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
|
}
|
||||||
|
return `${PREFIX}${String(maxSeq + 1).padStart(PAD, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const products = await prisma.commonProductItem.findMany({
|
const products = await prisma.commonProductItem.findMany({
|
||||||
@@ -13,10 +37,11 @@ async function main() {
|
|||||||
let created = 0;
|
let created = 0;
|
||||||
for (const p of products) {
|
for (const p of products) {
|
||||||
if (p.skus.length > 0) continue;
|
if (p.skus.length > 0) continue;
|
||||||
|
const skuCode = RE.test(p.skuCode) ? p.skuCode : await nextDkCode();
|
||||||
await prisma.commonProductSku.create({
|
await prisma.commonProductSku.create({
|
||||||
data: {
|
data: {
|
||||||
productId: p.id,
|
productId: p.id,
|
||||||
skuCode: p.skuCode,
|
skuCode,
|
||||||
barcode69: p.barcode69,
|
barcode69: p.barcode69,
|
||||||
specKey: '',
|
specKey: '',
|
||||||
specText: p.spec,
|
specText: p.spec,
|
||||||
@@ -32,8 +57,14 @@ async function main() {
|
|||||||
sortOrder: 0,
|
sortOrder: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (p.skuCode !== skuCode) {
|
||||||
|
await prisma.commonProductItem.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { skuCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
created += 1;
|
created += 1;
|
||||||
console.log(`created default sku for product ${p.id} ${p.skuCode}`);
|
console.log(`created default sku for product ${p.id} ${skuCode}`);
|
||||||
}
|
}
|
||||||
console.log(`done, created=${created}, scanned=${products.length}`);
|
console.log(`done, created=${created}, scanned=${products.length}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* 方案 A:把 DK000007 / DK000008 合并为同一 SPU,保留原 SKU id 与 DK 码,订单改绑到对应 SKU。
|
||||||
|
*
|
||||||
|
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts
|
||||||
|
* 预览:npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts --dry-run
|
||||||
|
*
|
||||||
|
* 幸存 SPU = DK000007 所在商品;默认规格 = 国标特级20。
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const CODES = ['DK000007', 'DK000008'] as const;
|
||||||
|
const SURVIVOR_CODE = 'DK000007';
|
||||||
|
const SPEC_ATTR_NAME = '规格';
|
||||||
|
const SPU_NAME = '酒祖杜康(国标特级)';
|
||||||
|
const SPEC_LABEL: Record<(typeof CODES)[number], string> = {
|
||||||
|
DK000007: '国标特级20',
|
||||||
|
DK000008: '国标特级30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
function buildSpecKey(valueIds: bigint[]): string {
|
||||||
|
if (!valueIds.length) return '';
|
||||||
|
return [...valueIds]
|
||||||
|
.map((id) => id.toString())
|
||||||
|
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0))
|
||||||
|
.join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSkuImageColumn() {
|
||||||
|
const rows = await prisma.$queryRaw<Array<{ cnt: bigint | number }>>`
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'common_product_sku'
|
||||||
|
AND COLUMN_NAME = 'image_url'
|
||||||
|
`;
|
||||||
|
const cnt = Number(rows[0]?.cnt ?? 0);
|
||||||
|
if (cnt > 0) return;
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
'ALTER TABLE `common_product_sku` ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`',
|
||||||
|
);
|
||||||
|
console.log('added column common_product_sku.image_url');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProductByCode(skuCode: string) {
|
||||||
|
const sku = await prisma.commonProductSku.findUnique({
|
||||||
|
where: { skuCode },
|
||||||
|
include: { product: true },
|
||||||
|
});
|
||||||
|
if (sku) return { product: sku.product, sku };
|
||||||
|
const product = await prisma.commonProductItem.findFirst({
|
||||||
|
where: { skuCode },
|
||||||
|
});
|
||||||
|
if (!product) {
|
||||||
|
throw new Error(`找不到商品/SKU:${skuCode}`);
|
||||||
|
}
|
||||||
|
return { product, sku: null as null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function coverUrlOf(productId: bigint, coverResourceId: bigint | null) {
|
||||||
|
if (coverResourceId) {
|
||||||
|
const res = await prisma.commonResource.findUnique({
|
||||||
|
where: { id: coverResourceId },
|
||||||
|
select: { url: true },
|
||||||
|
});
|
||||||
|
if (res?.url) return res.url;
|
||||||
|
}
|
||||||
|
const fallback = await prisma.commonResource.findFirst({
|
||||||
|
where: { ownerType: 'PRODUCT', ownerId: productId, bizType: 'COVER', status: 'ACTIVE' },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { url: true },
|
||||||
|
});
|
||||||
|
return fallback?.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSkuFromProduct(
|
||||||
|
tx: PrismaClient,
|
||||||
|
product: {
|
||||||
|
id: bigint;
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
spec: string;
|
||||||
|
price: unknown;
|
||||||
|
benefitAmount: unknown;
|
||||||
|
status: 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const existing =
|
||||||
|
(await tx.commonProductSku.findUnique({ where: { skuCode: product.skuCode } })) ??
|
||||||
|
(await tx.commonProductSku.findFirst({
|
||||||
|
where: { productId: product.id, isDefault: true },
|
||||||
|
}));
|
||||||
|
if (existing) return existing;
|
||||||
|
return tx.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId: product.id,
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specKey: '',
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price as never,
|
||||||
|
benefitAmount: product.benefitAmount as never,
|
||||||
|
status: product.status,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await ensureSkuImageColumn();
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const code of CODES) {
|
||||||
|
const resolved = await resolveProductByCode(code);
|
||||||
|
rows.push({ code, ...resolved });
|
||||||
|
}
|
||||||
|
|
||||||
|
const survivorRow = rows.find((r) => r.code === SURVIVOR_CODE);
|
||||||
|
if (!survivorRow) throw new Error(`缺少幸存码 ${SURVIVOR_CODE}`);
|
||||||
|
const discardRows = rows.filter((r) => r.product.id !== survivorRow.product.id);
|
||||||
|
const survivorId = survivorRow.product.id;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
dryRun,
|
||||||
|
survivor: {
|
||||||
|
productId: survivorId.toString(),
|
||||||
|
name: survivorRow.product.name,
|
||||||
|
skuCode: survivorRow.product.skuCode,
|
||||||
|
},
|
||||||
|
items: await Promise.all(
|
||||||
|
rows.map(async (r) => {
|
||||||
|
const orderCount = await prisma.order.count({ where: { productId: r.product.id } });
|
||||||
|
const nullSku = await prisma.order.count({
|
||||||
|
where: { productId: r.product.id, skuId: null },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: r.code,
|
||||||
|
productId: r.product.id.toString(),
|
||||||
|
name: r.product.name,
|
||||||
|
skuId: r.sku?.id.toString() ?? null,
|
||||||
|
orders: orderCount,
|
||||||
|
nullSkuOrders: nullSku,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (discardRows.length === 0 && survivorRow.sku) {
|
||||||
|
const sibling = await prisma.commonProductSku.findMany({
|
||||||
|
where: { productId: survivorId, skuCode: { in: [...CODES] } },
|
||||||
|
});
|
||||||
|
if (sibling.length === CODES.length) {
|
||||||
|
console.log('SKU 已在同一 SPU 上,继续核对订单 product_id / sku_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('[dry-run] skip writes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const db = tx as unknown as PrismaClient;
|
||||||
|
const skuByCode = new Map<string, { id: bigint }>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const sku = await ensureSkuFromProduct(db, row.product);
|
||||||
|
skuByCode.set(row.code, sku);
|
||||||
|
console.log(`ensured sku ${row.code} id=${sku.id} fromProduct=${row.product.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let attr = await db.commonProductSpecAttr.findFirst({
|
||||||
|
where: { productId: survivorId, name: SPEC_ATTR_NAME },
|
||||||
|
});
|
||||||
|
if (!attr) {
|
||||||
|
attr = await db.commonProductSpecAttr.create({
|
||||||
|
data: { productId: survivorId, name: SPEC_ATTR_NAME, sortOrder: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueIdByCode = new Map<string, bigint>();
|
||||||
|
for (let i = 0; i < CODES.length; i++) {
|
||||||
|
const code = CODES[i];
|
||||||
|
const label = SPEC_LABEL[code];
|
||||||
|
let value = await db.commonProductSpecValue.findFirst({
|
||||||
|
where: { attrId: attr.id, name: label },
|
||||||
|
});
|
||||||
|
if (!value) {
|
||||||
|
value = await db.commonProductSpecValue.create({
|
||||||
|
data: { attrId: attr.id, name: label, sortOrder: i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
valueIdByCode.set(code, value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < CODES.length; i++) {
|
||||||
|
const code = CODES[i];
|
||||||
|
const sku = skuByCode.get(code)!;
|
||||||
|
const valueId = valueIdByCode.get(code)!;
|
||||||
|
const specKey = buildSpecKey([valueId]);
|
||||||
|
const specText = SPEC_LABEL[code];
|
||||||
|
const sourceProduct = rows.find((r) => r.code === code)!.product;
|
||||||
|
const imageUrl = await coverUrlOf(sourceProduct.id, sourceProduct.coverResourceId);
|
||||||
|
const isDefault = code === SURVIVOR_CODE;
|
||||||
|
|
||||||
|
await db.commonProductSkuSpec.deleteMany({ where: { skuId: sku.id } });
|
||||||
|
await db.commonProductSku.update({
|
||||||
|
where: { id: sku.id },
|
||||||
|
data: {
|
||||||
|
product: { connect: { id: survivorId } },
|
||||||
|
specKey,
|
||||||
|
specText,
|
||||||
|
isDefault,
|
||||||
|
sortOrder: i,
|
||||||
|
imageUrl,
|
||||||
|
status: sourceProduct.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.commonProductSkuSpec.create({
|
||||||
|
data: { skuId: sku.id, valueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderRes = await db.order.updateMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ skuId: sku.id }, { productId: sourceProduct.id, skuId: null }],
|
||||||
|
},
|
||||||
|
data: { productId: survivorId, skuId: sku.id },
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`bound ${code} → product ${survivorId} sku ${sku.id} spec=${specText} orders=${orderRes.count}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftover = await db.order.findMany({
|
||||||
|
where: { productId: { in: discardRows.map((r) => r.product.id) } },
|
||||||
|
select: { id: true, orderNo: true, productId: true, skuId: true },
|
||||||
|
});
|
||||||
|
if (leftover.length) {
|
||||||
|
throw new Error(
|
||||||
|
`仍有订单绑在被合并 SPU 上:${leftover.map((o) => o.orderNo).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const discard of discardRows) {
|
||||||
|
const phones = await db.commonProductVisibilityPhone.findMany({
|
||||||
|
where: { productId: discard.product.id },
|
||||||
|
});
|
||||||
|
for (const p of phones) {
|
||||||
|
await db.commonProductVisibilityPhone.upsert({
|
||||||
|
where: { productId_phone: { productId: survivorId, phone: p.phone } },
|
||||||
|
create: { productId: survivorId, phone: p.phone },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.commonProductVisibilityPhone.deleteMany({
|
||||||
|
where: { productId: discard.product.id },
|
||||||
|
});
|
||||||
|
await db.commonProductSkuSpec.deleteMany({
|
||||||
|
where: { sku: { productId: discard.product.id } },
|
||||||
|
});
|
||||||
|
await db.commonProductSku.deleteMany({ where: { productId: discard.product.id } });
|
||||||
|
await db.commonProductSpecAttr.deleteMany({ where: { productId: discard.product.id } });
|
||||||
|
await db.commonResource.deleteMany({
|
||||||
|
where: { ownerType: 'PRODUCT', ownerId: discard.product.id },
|
||||||
|
});
|
||||||
|
await db.commonProductItem.update({
|
||||||
|
where: { id: discard.product.id },
|
||||||
|
data: { status: 'OFF_SALE', coverResourceId: null },
|
||||||
|
});
|
||||||
|
await db.commonProductItem.delete({ where: { id: discard.product.id } });
|
||||||
|
console.log(`deleted discard SPU ${discard.product.id} ${discard.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSku = await db.commonProductSku.findUniqueOrThrow({
|
||||||
|
where: { skuCode: SURVIVOR_CODE },
|
||||||
|
});
|
||||||
|
await db.commonProductItem.update({
|
||||||
|
where: { id: survivorId },
|
||||||
|
data: {
|
||||||
|
name: SPU_NAME,
|
||||||
|
skuCode: defaultSku.skuCode,
|
||||||
|
barcode69: defaultSku.barcode69,
|
||||||
|
spec: defaultSku.specText,
|
||||||
|
price: defaultSku.price,
|
||||||
|
benefitAmount: defaultSku.benefitAmount,
|
||||||
|
allowOnSitePickup: defaultSku.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: defaultSku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: defaultSku.allowCrossCityDelivery,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const skus = await prisma.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { in: [...CODES] } },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
const orders = await prisma.order.groupBy({
|
||||||
|
by: ['productId', 'skuId'],
|
||||||
|
where: { sku: { skuCode: { in: [...CODES] } } },
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
done: true,
|
||||||
|
skus: skus.map((s) => ({
|
||||||
|
id: s.id.toString(),
|
||||||
|
productId: s.productId.toString(),
|
||||||
|
skuCode: s.skuCode,
|
||||||
|
specText: s.specText,
|
||||||
|
isDefault: s.isDefault,
|
||||||
|
})),
|
||||||
|
orders: orders.map((o) => ({
|
||||||
|
productId: o.productId.toString(),
|
||||||
|
skuId: o.skuId?.toString() ?? null,
|
||||||
|
count: o._count,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 发票开票品类:酒水类 / 餐饮类(票种仍为增值税普通发票)
|
||||||
|
-- 存量申请默认酒水类,与 Prisma InvoiceCategory 默认值一致
|
||||||
|
|
||||||
|
ALTER TABLE `user_invoice`
|
||||||
|
ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- SKU 规格主图(可选;空则 C 端回落商品封面)
|
||||||
|
ALTER TABLE `common_product_sku`
|
||||||
|
ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`;
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 将存量商品 / SKU 的 sku_code 全部改成 DK + 6 位数字。
|
||||||
|
* 已是 DK 数字码的保持不变;其余先写临时码再分配,避免唯一约束冲突。
|
||||||
|
*
|
||||||
|
* 用法:cd server/dukang-api && npx ts-node prisma/rewrite-sku-codes-dk.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const PREFIX = 'DK';
|
||||||
|
const PAD = 6;
|
||||||
|
const RE = /^DK(\d+)$/;
|
||||||
|
|
||||||
|
function isDk(code: string) {
|
||||||
|
return RE.test(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCode(seq: number) {
|
||||||
|
return `${PREFIX}${String(seq).padStart(PAD, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maxExistingSeq(): Promise<number> {
|
||||||
|
const [items, skus] = await Promise.all([
|
||||||
|
prisma.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
prisma.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let maxSeq = 0;
|
||||||
|
for (const row of [...items, ...skus]) {
|
||||||
|
const m = RE.exec(row.skuCode);
|
||||||
|
if (!m) continue;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
|
}
|
||||||
|
return maxSeq;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const skus = await prisma.commonProductSku.findMany({
|
||||||
|
orderBy: [{ productId: 'asc' }, { id: 'asc' }],
|
||||||
|
select: { id: true, productId: true, skuCode: true, isDefault: true },
|
||||||
|
});
|
||||||
|
const products = await prisma.commonProductItem.findMany({
|
||||||
|
select: { id: true, skuCode: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const skuNeed = skus.filter((s) => !isDk(s.skuCode));
|
||||||
|
const productNeed = products.filter((p) => !isDk(p.skuCode));
|
||||||
|
console.log(`sku total=${skus.length} rewrite=${skuNeed.length}`);
|
||||||
|
console.log(`product total=${products.length} rewrite=${productNeed.length}`);
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const s of skuNeed) {
|
||||||
|
await tx.commonProductSku.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: { skuCode: `TMP${s.id.toString()}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const p of productNeed) {
|
||||||
|
await tx.commonProductItem.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { skuCode: `TMPP${p.id.toString()}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seqStart = await (async () => {
|
||||||
|
const [items, skuRows] = await Promise.all([
|
||||||
|
tx.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
tx.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { startsWith: PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let maxSeq = 0;
|
||||||
|
for (const row of [...items, ...skuRows]) {
|
||||||
|
const m = RE.exec(row.skuCode);
|
||||||
|
if (!m) continue;
|
||||||
|
const n = Number(m[1]);
|
||||||
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
|
}
|
||||||
|
return maxSeq;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let seq = seqStart;
|
||||||
|
const defaultSkuCodeByProduct = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const s of skuNeed) {
|
||||||
|
seq += 1;
|
||||||
|
const code = formatCode(seq);
|
||||||
|
await tx.commonProductSku.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: { skuCode: code },
|
||||||
|
});
|
||||||
|
if (s.isDefault) defaultSkuCodeByProduct.set(s.productId.toString(), code);
|
||||||
|
console.log(`sku ${s.id} ${s.skuCode} -> ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of productNeed) {
|
||||||
|
const fromDefault = defaultSkuCodeByProduct.get(p.id.toString());
|
||||||
|
let code = fromDefault;
|
||||||
|
if (!code) {
|
||||||
|
seq += 1;
|
||||||
|
code = formatCode(seq);
|
||||||
|
}
|
||||||
|
await tx.commonProductItem.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { skuCode: code },
|
||||||
|
});
|
||||||
|
console.log(`product ${p.id} ${p.skuCode} -> ${code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stillDefault = await tx.commonProductSku.findMany({
|
||||||
|
where: { isDefault: true },
|
||||||
|
select: { productId: true, skuCode: true },
|
||||||
|
});
|
||||||
|
for (const s of stillDefault) {
|
||||||
|
if (!isDk(s.skuCode)) continue;
|
||||||
|
await tx.commonProductItem.updateMany({
|
||||||
|
where: { id: s.productId, NOT: { skuCode: s.skuCode } },
|
||||||
|
data: { skuCode: s.skuCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftoverSku = await prisma.commonProductSku.count({
|
||||||
|
where: { NOT: { skuCode: { startsWith: PREFIX } } },
|
||||||
|
});
|
||||||
|
const leftoverProduct = await prisma.commonProductItem.count({
|
||||||
|
where: { NOT: { skuCode: { startsWith: PREFIX } } },
|
||||||
|
});
|
||||||
|
console.log(`done leftoverSku=${leftoverSku} leftoverProduct=${leftoverProduct} maxWas=${await maxExistingSeq()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -146,6 +146,12 @@ enum InvoiceKind {
|
|||||||
SPECIAL
|
SPECIAL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// C 端开票品类(增值税票种固定普通发票)
|
||||||
|
enum InvoiceCategory {
|
||||||
|
LIQUOR
|
||||||
|
CATERING
|
||||||
|
}
|
||||||
|
|
||||||
enum InvoiceStatus {
|
enum InvoiceStatus {
|
||||||
PENDING
|
PENDING
|
||||||
ISSUED
|
ISSUED
|
||||||
@@ -888,6 +894,8 @@ model CommonProductSku {
|
|||||||
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
|
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
|
||||||
isDefault Boolean @default(false) @map("is_default")
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
@@ -1678,6 +1686,8 @@ model UserInvoice {
|
|||||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||||
titleType InvoiceTitleType @map("title_type")
|
titleType InvoiceTitleType @map("title_type")
|
||||||
invoiceKind InvoiceKind @map("invoice_kind")
|
invoiceKind InvoiceKind @map("invoice_kind")
|
||||||
|
/// 酒水类 / 餐饮类;历史单默认酒水类
|
||||||
|
invoiceCategory InvoiceCategory @default(LIQUOR) @map("invoice_category")
|
||||||
titleName String @map("title_name") @db.VarChar(128)
|
titleName String @map("title_name") @db.VarChar(128)
|
||||||
taxNo String? @map("tax_no") @db.VarChar(32)
|
taxNo String? @map("tax_no") @db.VarChar(32)
|
||||||
addressPhone String? @map("address_phone") @db.VarChar(256)
|
addressPhone String? @map("address_phone") @db.VarChar(256)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const HqOperationAction = {
|
|||||||
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
||||||
ORDER_DELETE: 'ORDER_DELETE',
|
ORDER_DELETE: 'ORDER_DELETE',
|
||||||
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
||||||
|
ORDER_EXPORT: 'ORDER_EXPORT',
|
||||||
STORE_CREATE: 'STORE_CREATE',
|
STORE_CREATE: 'STORE_CREATE',
|
||||||
STORE_UPDATE: 'STORE_UPDATE',
|
STORE_UPDATE: 'STORE_UPDATE',
|
||||||
STORE_STATUS: 'STORE_STATUS',
|
STORE_STATUS: 'STORE_STATUS',
|
||||||
@@ -147,6 +148,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
||||||
[HqOperationAction.ORDER_DELETE]: '删除订单',
|
[HqOperationAction.ORDER_DELETE]: '删除订单',
|
||||||
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
||||||
|
[HqOperationAction.ORDER_EXPORT]: '导出订单',
|
||||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
mapSkuDto,
|
mapSkuDto,
|
||||||
mapSpecAttrsDto,
|
mapSpecAttrsDto,
|
||||||
pickDisplaySku,
|
pickDisplaySku,
|
||||||
resolveOrderSku,
|
resolveOrderSale,
|
||||||
} from './product-sku.util';
|
} from './product-sku.util';
|
||||||
|
|
||||||
export type CatalogViewer = {
|
export type CatalogViewer = {
|
||||||
@@ -204,7 +204,7 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;返回 SPU + 解析后的 SKU */
|
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;无 SKU 时回落 SPU 字段 */
|
||||||
async assertPurchasable(
|
async assertPurchasable(
|
||||||
productId: bigint,
|
productId: bigint,
|
||||||
viewerPhone?: string | null,
|
viewerPhone?: string | null,
|
||||||
@@ -224,8 +224,8 @@ export class CatalogService {
|
|||||||
throw new BadRequestException('该商品暂不对当前账号开放');
|
throw new BadRequestException('该商品暂不对当前账号开放');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sku = resolveOrderSku(product.skus, skuId);
|
const sale = resolveOrderSale(product, product.skus ?? [], skuId);
|
||||||
return { product, sku };
|
return { product, sale };
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
||||||
|
|||||||
@@ -37,23 +37,79 @@ export function buildSpecText(
|
|||||||
.join(' / ');
|
.join(' / ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 下单用销售快照:有可售 SKU 则用 SKU,否则回落 SPU(旧客户端 / 未回填) */
|
||||||
|
export type OrderSaleSnapshot = {
|
||||||
|
skuId: bigint | null;
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
specText: string;
|
||||||
|
price: CommonProductSku['price'];
|
||||||
|
benefitAmount: CommonProductSku['benefitAmount'];
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
saleUnit: ProductSaleUnit;
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function saleSnapshotFromProduct(product: CommonProductItem): OrderSaleSnapshot {
|
||||||
|
return {
|
||||||
|
skuId: null,
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price,
|
||||||
|
benefitAmount: product.benefitAmount,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saleSnapshotFromSku(
|
||||||
|
sku: CommonProductSku,
|
||||||
|
product: CommonProductItem,
|
||||||
|
): OrderSaleSnapshot {
|
||||||
|
return {
|
||||||
|
skuId: sku.id,
|
||||||
|
skuCode: sku.skuCode,
|
||||||
|
barcode69: sku.barcode69,
|
||||||
|
specText: sku.specText || product.spec,
|
||||||
|
price: sku.price,
|
||||||
|
benefitAmount: sku.benefitAmount,
|
||||||
|
allowOnSitePickup: sku.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||||
|
saleUnit: sku.saleUnit,
|
||||||
|
bottlesPerUnit: sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : sku.saleUnit === 'BOX' ? BOTTLES_PER_BOX : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析下单 SKU:显式 skuId,或单可售自动回落;多可售且未传则 400。
|
* 现网兼容:
|
||||||
|
* - 不传 skuId:有可售 SKU 用默认/唯一可售;一个都没有则回落 SPU 字段(未回填也能下单)
|
||||||
|
* - 传入 skuId:按 SKU 校验(新客户端选规格)
|
||||||
|
* 不在旧接口上因「无 SKU」或「多规格未选」打断现网下单。
|
||||||
*/
|
*/
|
||||||
export function resolveOrderSku(
|
export function resolveOrderSale(
|
||||||
|
product: CommonProductItem,
|
||||||
skus: CommonProductSku[],
|
skus: CommonProductSku[],
|
||||||
skuId?: string | null,
|
skuId?: string | null,
|
||||||
): CommonProductSku {
|
): OrderSaleSnapshot {
|
||||||
const onSale = skus.filter(isSkuOnSale);
|
|
||||||
if (skuId) {
|
if (skuId) {
|
||||||
const found = skus.find((s) => s.id.toString() === String(skuId));
|
const found = skus.find((s) => s.id.toString() === String(skuId));
|
||||||
if (!found) throw new BadRequestException('规格不存在');
|
if (!found) throw new BadRequestException('规格不存在');
|
||||||
if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买');
|
if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买');
|
||||||
return found;
|
return saleSnapshotFromSku(found, product);
|
||||||
}
|
}
|
||||||
if (onSale.length === 1) return onSale[0];
|
const onSale = skus.filter(isSkuOnSale);
|
||||||
if (onSale.length === 0) throw new BadRequestException('商品暂无可售规格');
|
if (onSale.length >= 1) {
|
||||||
throw new BadRequestException('请选择规格');
|
const picked = onSale.find((s) => s.isDefault) ?? onSale[0];
|
||||||
|
return saleSnapshotFromSku(picked, product);
|
||||||
|
}
|
||||||
|
return saleSnapshotFromProduct(product);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */
|
/** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */
|
||||||
@@ -132,6 +188,7 @@ export function mapSkuDto(sku: SkuWithSpecs) {
|
|||||||
isDefault: sku.isDefault,
|
isDefault: sku.isDefault,
|
||||||
skuCode: sku.skuCode,
|
skuCode: sku.skuCode,
|
||||||
barcode69: sku.barcode69,
|
barcode69: sku.barcode69,
|
||||||
|
imageUrl: (sku as { imageUrl?: string | null }).imageUrl || undefined,
|
||||||
sortOrder: sku.sortOrder,
|
sortOrder: sku.sortOrder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
|
||||||
|
const EXPORT_HEADERS = [
|
||||||
|
'订单号',
|
||||||
|
'下单时间',
|
||||||
|
'状态',
|
||||||
|
'商品',
|
||||||
|
'规格',
|
||||||
|
'数量',
|
||||||
|
'实付',
|
||||||
|
'好客权益',
|
||||||
|
'收货人',
|
||||||
|
'手机',
|
||||||
|
'收货地址',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OrderExportRow = {
|
||||||
|
orderNo: string;
|
||||||
|
createdAt: string;
|
||||||
|
status: string;
|
||||||
|
productName: string;
|
||||||
|
productSpec: string;
|
||||||
|
quantity: number;
|
||||||
|
payAmount: number;
|
||||||
|
benefitBrief: string;
|
||||||
|
receiverName: string;
|
||||||
|
receiverPhone: string;
|
||||||
|
address: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBenefitBrief(order: {
|
||||||
|
benefitAmount?: { toString(): string } | number | null;
|
||||||
|
benefitCoupon?: {
|
||||||
|
totalAmount?: { toString(): string } | number;
|
||||||
|
usedAmount?: { toString(): string } | number;
|
||||||
|
balance?: { toString(): string } | number;
|
||||||
|
} | null;
|
||||||
|
}): string {
|
||||||
|
const coupon = order.benefitCoupon;
|
||||||
|
if (coupon) {
|
||||||
|
return `总额¥${Number(coupon.totalAmount).toFixed(2)} / 已用¥${Number(coupon.usedAmount).toFixed(2)} / 余¥${Number(coupon.balance).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
if (order.benefitAmount != null) return `¥${Number(order.benefitAmount).toFixed(2)}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatExportDateTime(value?: Date | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapOrderToExportRow(order: {
|
||||||
|
orderNo: string;
|
||||||
|
createdAt: Date;
|
||||||
|
status: string;
|
||||||
|
productName: string;
|
||||||
|
productSpec: string;
|
||||||
|
quantity: number;
|
||||||
|
payAmount: { toString(): string } | number;
|
||||||
|
benefitAmount?: { toString(): string } | number | null;
|
||||||
|
receiverName: string;
|
||||||
|
receiverPhone: string;
|
||||||
|
receiverProvince: string;
|
||||||
|
receiverCity: string;
|
||||||
|
receiverDistrict: string;
|
||||||
|
receiverAddress: string;
|
||||||
|
benefitCoupon?: {
|
||||||
|
totalAmount?: { toString(): string } | number;
|
||||||
|
usedAmount?: { toString(): string } | number;
|
||||||
|
balance?: { toString(): string } | number;
|
||||||
|
} | null;
|
||||||
|
deliveryType?: string;
|
||||||
|
}): OrderExportRow {
|
||||||
|
const isOnSite =
|
||||||
|
order.deliveryType === 'ON_SITE_PICKUP' ||
|
||||||
|
order.receiverAddress === '现场取货' ||
|
||||||
|
order.receiverAddress === '现场提货' ||
|
||||||
|
(order.receiverProvince === '现场' && order.receiverCity === '现场');
|
||||||
|
const address = isOnSite
|
||||||
|
? '现场取货'
|
||||||
|
: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}${order.receiverAddress}`;
|
||||||
|
return {
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
createdAt: formatExportDateTime(order.createdAt),
|
||||||
|
status: ORDER_STATUS_LABELS[order.status] || order.status,
|
||||||
|
productName: order.productName,
|
||||||
|
productSpec: order.productSpec,
|
||||||
|
quantity: order.quantity,
|
||||||
|
payAmount: Number(order.payAmount),
|
||||||
|
benefitBrief: formatBenefitBrief(order),
|
||||||
|
receiverName: order.receiverName,
|
||||||
|
receiverPhone: order.receiverPhone,
|
||||||
|
address,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToCells(row: OrderExportRow): string[] {
|
||||||
|
return [
|
||||||
|
row.orderNo,
|
||||||
|
row.createdAt,
|
||||||
|
row.status,
|
||||||
|
row.productName,
|
||||||
|
row.productSpec,
|
||||||
|
String(row.quantity),
|
||||||
|
row.payAmount.toFixed(2),
|
||||||
|
row.benefitBrief,
|
||||||
|
row.receiverName,
|
||||||
|
row.receiverPhone,
|
||||||
|
row.address,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePdfFontPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
process.env.EXPORT_PDF_FONT_PATH,
|
||||||
|
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
|
||||||
|
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
|
||||||
|
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
|
||||||
|
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
|
||||||
|
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'C:\\Windows\\Fonts\\simhei.ttf',
|
||||||
|
'C:\\Windows\\Fonts\\msyh.ttc',
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildOrdersXlsx(rows: OrderExportRow[]): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('订单');
|
||||||
|
sheet.addRow([...EXPORT_HEADERS]);
|
||||||
|
for (const row of rows) {
|
||||||
|
sheet.addRow(rowToCells(row));
|
||||||
|
}
|
||||||
|
sheet.columns.forEach((col) => {
|
||||||
|
col.width = 16;
|
||||||
|
});
|
||||||
|
sheet.getColumn(4).width = 22;
|
||||||
|
sheet.getColumn(8).width = 36;
|
||||||
|
sheet.getColumn(11).width = 36;
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildOrdersPdf(rows: OrderExportRow[]): Promise<Buffer> {
|
||||||
|
const fontPath = resolvePdfFontPath();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const doc = new PDFDocument({
|
||||||
|
size: 'A4',
|
||||||
|
layout: 'landscape',
|
||||||
|
margin: 24,
|
||||||
|
bufferPages: true,
|
||||||
|
});
|
||||||
|
doc.on('data', (chunk) => chunks.push(chunk as Buffer));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
doc.registerFont('zh', fontPath);
|
||||||
|
doc.font('zh');
|
||||||
|
|
||||||
|
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||||
|
const colWidths = [72, 78, 48, 88, 64, 32, 48, 110, 48, 72, 120];
|
||||||
|
const scale = pageWidth / colWidths.reduce((sum, w) => sum + w, 0);
|
||||||
|
const widths = colWidths.map((w) => w * scale);
|
||||||
|
const rowHeight = 28;
|
||||||
|
const fontSize = 7;
|
||||||
|
let y = doc.page.margins.top;
|
||||||
|
|
||||||
|
const drawRow = (cells: string[], isHeader = false) => {
|
||||||
|
let x = doc.page.margins.left;
|
||||||
|
const height = isHeader ? 24 : rowHeight;
|
||||||
|
if (y + height > doc.page.height - doc.page.margins.bottom) {
|
||||||
|
doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 });
|
||||||
|
y = doc.page.margins.top;
|
||||||
|
}
|
||||||
|
doc.fontSize(isHeader ? 8 : fontSize);
|
||||||
|
cells.forEach((cell, index) => {
|
||||||
|
doc.rect(x, y, widths[index], height).stroke('#dddddd');
|
||||||
|
doc.text(cell || '', x + 2, y + 4, {
|
||||||
|
width: widths[index] - 4,
|
||||||
|
height: height - 6,
|
||||||
|
lineBreak: true,
|
||||||
|
});
|
||||||
|
x += widths[index];
|
||||||
|
});
|
||||||
|
y += height;
|
||||||
|
};
|
||||||
|
|
||||||
|
drawRow([...EXPORT_HEADERS], true);
|
||||||
|
for (const row of rows) {
|
||||||
|
drawRow(rowToCells(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExportFilename(format: 'xlsx' | 'pdf', count: number): string {
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
return `订单导出_${stamp}_${count}条.${format}`;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
|||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { AdminOrdersService } from './admin-orders.service';
|
import { AdminOrdersService } from './admin-orders.service';
|
||||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
import { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||||
|
|
||||||
@Controller('admin/orders')
|
@Controller('admin/orders')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
@@ -26,6 +26,25 @@ export class AdminOrdersController {
|
|||||||
return this.ordersService.listBigScreen(limit);
|
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')
|
@Post('batch-delete')
|
||||||
@UseGuards(HqPermissionGuard)
|
@UseGuards(HqPermissionGuard)
|
||||||
@RequireHqPermissions('orders_delete')
|
@RequireHqPermissions('orders_delete')
|
||||||
|
|||||||
@@ -7,12 +7,35 @@ import { orderStatusLogWhere } from '../../common/event/event.helpers';
|
|||||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||||
import { TradeService } from '../trade/trade.service';
|
import { TradeService } from '../trade/trade.service';
|
||||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||||
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
import type { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||||
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
||||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||||
import { AdminRedeemService } from './admin-redeem.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()
|
@Injectable()
|
||||||
export class AdminOrdersService {
|
export class AdminOrdersService {
|
||||||
@@ -60,23 +83,7 @@ export class AdminOrdersService {
|
|||||||
async list(query: AdminOrdersQueryDto) {
|
async list(query: AdminOrdersQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
const where: Prisma.OrderWhereInput = {};
|
const where = this.buildOrderWhere(query);
|
||||||
|
|
||||||
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([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.order.findMany({
|
this.prisma.order.findMany({
|
||||||
@@ -89,6 +96,9 @@ export class AdminOrdersService {
|
|||||||
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
||||||
city: { select: { id: true, name: true, code: true } },
|
city: { select: { id: true, name: true, code: true } },
|
||||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||||
|
benefitCoupon: {
|
||||||
|
select: { couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.order.count({ where }),
|
this.prisma.order.count({ where }),
|
||||||
@@ -97,6 +107,114 @@ export class AdminOrdersService {
|
|||||||
return serializeBigInt({ items, total, page, pageSize });
|
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) {
|
async getOrderTrack(id: bigint) {
|
||||||
const order = await this.prisma.order.findUnique({
|
const order = await this.prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ function normalizePhones(phones?: string[]): string[] {
|
|||||||
|
|
||||||
const SKU_AUTO_PREFIX = 'DK';
|
const SKU_AUTO_PREFIX = 'DK';
|
||||||
const SKU_AUTO_PAD = 6;
|
const SKU_AUTO_PAD = 6;
|
||||||
|
const SKU_AUTO_RE = /^DK(\d+)$/;
|
||||||
|
|
||||||
|
function isDkSkuCode(code: string | null | undefined): boolean {
|
||||||
|
return !!code && SKU_AUTO_RE.test(code);
|
||||||
|
}
|
||||||
const MAX_SPEC_ATTRS = 3;
|
const MAX_SPEC_ATTRS = 3;
|
||||||
const MAX_SPEC_VALUES = 10;
|
const MAX_SPEC_VALUES = 10;
|
||||||
|
|
||||||
@@ -81,7 +86,19 @@ export class AdminProductsService {
|
|||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
const where: Prisma.CommonProductItemWhereInput = {};
|
const where: Prisma.CommonProductItemWhereInput = {};
|
||||||
if (query.name) where.name = { contains: query.name };
|
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.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals'];
|
if (query.status) where.status = query.status as Prisma.EnumProductStatusFilter['equals'];
|
||||||
if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
|
if (query.aromaType) where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
|
||||||
|
|
||||||
@@ -94,7 +111,25 @@ export class AdminProductsService {
|
|||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||||
skus: { select: { id: true }, take: 2 },
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
specAttrs: { select: { id: true } },
|
specAttrs: { select: { id: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -115,13 +150,101 @@ export class AdminProductsService {
|
|||||||
: [];
|
: [];
|
||||||
const resourceMap = groupResourcesByProductId(resources);
|
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({
|
return serializeBigInt({
|
||||||
items: items.map((p) => {
|
items: items.map((p) => {
|
||||||
const { skus, specAttrs, ...rest } = 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 {
|
return {
|
||||||
...this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []),
|
...formatted,
|
||||||
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
skuCount: skus.length,
|
skuCount: skus.length,
|
||||||
|
priceMin,
|
||||||
|
priceMax,
|
||||||
|
soldBottles,
|
||||||
|
skus: skuRows,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
total,
|
total,
|
||||||
@@ -241,10 +364,14 @@ export class AdminProductsService {
|
|||||||
const existing = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
const existing = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
||||||
if (!existing) throw new NotFoundException('商品不存在');
|
if (!existing) throw new NotFoundException('商品不存在');
|
||||||
|
|
||||||
|
const skuCount = await this.prisma.commonProductSku.count({ where: { productId: id } });
|
||||||
|
/** 多规格时履约只在规格 SKU 上改;忽略基础信息里的履约字段,避免误覆盖默认 SKU 冗余 */
|
||||||
|
const applyFulfillment = skuCount <= 1;
|
||||||
const fulfillmentTouched =
|
const fulfillmentTouched =
|
||||||
dto.allowOnlinePurchase !== undefined ||
|
applyFulfillment &&
|
||||||
dto.allowCrossCityDelivery !== undefined ||
|
(dto.allowOnlinePurchase !== undefined ||
|
||||||
dto.allowOnSitePickup !== undefined;
|
dto.allowCrossCityDelivery !== undefined ||
|
||||||
|
dto.allowOnSitePickup !== undefined);
|
||||||
const flags = fulfillmentTouched
|
const flags = fulfillmentTouched
|
||||||
? resolveFulfillmentFlags({
|
? resolveFulfillmentFlags({
|
||||||
allowOnlinePurchase: dto.allowOnlinePurchase,
|
allowOnlinePurchase: dto.allowOnlinePurchase,
|
||||||
@@ -465,14 +592,31 @@ export class AdminProductsService {
|
|||||||
throw new BadRequestException('请且仅指定一个默认 SKU');
|
throw new BadRequestException('请且仅指定一个默认 SKU');
|
||||||
}
|
}
|
||||||
|
|
||||||
const barcodes = rows.map((r) => r.barcode69.trim());
|
const barcodes = rows.map((r) => r.barcode69.trim()).filter(Boolean);
|
||||||
|
if (barcodes.length !== rows.length) {
|
||||||
|
throw new BadRequestException('每个规格须填写 69 码');
|
||||||
|
}
|
||||||
if (new Set(barcodes).size !== barcodes.length) {
|
if (new Set(barcodes).size !== barcodes.length) {
|
||||||
throw new BadRequestException('69 码不可重复');
|
throw new BadRequestException('同一商品内 69 码不可重复,每个规格须使用不同 69 码');
|
||||||
|
}
|
||||||
|
const barcodeTaken = await this.prisma.commonProductSku.findFirst({
|
||||||
|
where: { barcode69: { in: barcodes }, productId: { not: productId } },
|
||||||
|
select: { barcode69: true },
|
||||||
|
});
|
||||||
|
if (barcodeTaken) {
|
||||||
|
throw new BadRequestException(`69 码已存在:${barcodeTaken.barcode69}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
const existing = await tx.commonProductSku.findMany({ where: { productId } });
|
const existing = await tx.commonProductSku.findMany({ where: { productId } });
|
||||||
const keepIds = new Set(rows.map((r) => r.id).filter(Boolean) as string[]);
|
const keepIds = new Set(rows.map((r) => r.id).filter(Boolean) as string[]);
|
||||||
|
const needGenerate = rows.filter((row) => {
|
||||||
|
if (!row.id) return true;
|
||||||
|
const old = existing.find((s) => s.id.toString() === row.id);
|
||||||
|
return !old || !isDkSkuCode(old.skuCode);
|
||||||
|
}).length;
|
||||||
|
const generatedCodes = await this.allocateAutoSkuCodesTx(tx, needGenerate);
|
||||||
|
let genIdx = 0;
|
||||||
|
|
||||||
for (const old of existing) {
|
for (const old of existing) {
|
||||||
if (keepIds.has(old.id.toString())) continue;
|
if (keepIds.has(old.id.toString())) continue;
|
||||||
@@ -508,7 +652,12 @@ export class AdminProductsService {
|
|||||||
? BOTTLES_PER_BOX
|
? BOTTLES_PER_BOX
|
||||||
: 1;
|
: 1;
|
||||||
const status = (row.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
|
const status = (row.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
|
||||||
const skuCode = row.skuCode?.trim() || (await this.nextAutoSkuCodeTx(tx));
|
const old = row.id ? existing.find((s) => s.id.toString() === row.id) : undefined;
|
||||||
|
const skuCode =
|
||||||
|
old && isDkSkuCode(old.skuCode) ? old.skuCode : generatedCodes[genIdx++];
|
||||||
|
if (!skuCode) {
|
||||||
|
throw new BadRequestException('SKU 码生成失败,请重试');
|
||||||
|
}
|
||||||
|
|
||||||
let skuId: bigint;
|
let skuId: bigint;
|
||||||
if (row.id) {
|
if (row.id) {
|
||||||
@@ -528,7 +677,8 @@ export class AdminProductsService {
|
|||||||
bottlesPerUnit,
|
bottlesPerUnit,
|
||||||
isDefault: !!row.isDefault,
|
isDefault: !!row.isDefault,
|
||||||
sortOrder: row.sortOrder ?? i,
|
sortOrder: row.sortOrder ?? i,
|
||||||
},
|
imageUrl: row.imageUrl?.trim() || null,
|
||||||
|
} as never,
|
||||||
});
|
});
|
||||||
await tx.commonProductSkuSpec.deleteMany({ where: { skuId } });
|
await tx.commonProductSkuSpec.deleteMany({ where: { skuId } });
|
||||||
} else {
|
} else {
|
||||||
@@ -547,7 +697,8 @@ export class AdminProductsService {
|
|||||||
bottlesPerUnit,
|
bottlesPerUnit,
|
||||||
isDefault: !!row.isDefault,
|
isDefault: !!row.isDefault,
|
||||||
sortOrder: row.sortOrder ?? i,
|
sortOrder: row.sortOrder ?? i,
|
||||||
},
|
imageUrl: row.imageUrl?.trim() || null,
|
||||||
|
} as never,
|
||||||
});
|
});
|
||||||
skuId = created.id;
|
skuId = created.id;
|
||||||
}
|
}
|
||||||
@@ -609,7 +760,7 @@ export class AdminProductsService {
|
|||||||
await this.prisma.commonProductSku.create({
|
await this.prisma.commonProductSku.create({
|
||||||
data: {
|
data: {
|
||||||
productId,
|
productId,
|
||||||
skuCode: product.skuCode,
|
skuCode: await this.nextAutoSkuCode(),
|
||||||
barcode69: product.barcode69,
|
barcode69: product.barcode69,
|
||||||
specKey: '',
|
specKey: '',
|
||||||
specText: product.spec,
|
specText: product.spec,
|
||||||
@@ -635,7 +786,6 @@ export class AdminProductsService {
|
|||||||
await this.prisma.commonProductSku.update({
|
await this.prisma.commonProductSku.update({
|
||||||
where: { id: defaultSku.id },
|
where: { id: defaultSku.id },
|
||||||
data: {
|
data: {
|
||||||
skuCode: product.skuCode,
|
|
||||||
barcode69: product.barcode69,
|
barcode69: product.barcode69,
|
||||||
specText: product.spec,
|
specText: product.spec,
|
||||||
price: product.price,
|
price: product.price,
|
||||||
@@ -651,12 +801,22 @@ export class AdminProductsService {
|
|||||||
|
|
||||||
/** 生成 DK + 6 位自增 SKU,冲突重试 */
|
/** 生成 DK + 6 位自增 SKU,冲突重试 */
|
||||||
private async nextAutoSkuCode(): Promise<string> {
|
private async nextAutoSkuCode(): Promise<string> {
|
||||||
return this.nextAutoSkuCodeTx(this.prisma);
|
const [code] = await this.allocateAutoSkuCodesTx(this.prisma, 1);
|
||||||
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async nextAutoSkuCodeTx(
|
private async nextAutoSkuCodeTx(
|
||||||
db: Prisma.TransactionClient | PrismaService,
|
db: Prisma.TransactionClient | PrismaService,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const [code] = await this.allocateAutoSkuCodesTx(db, 1);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async allocateAutoSkuCodesTx(
|
||||||
|
db: Prisma.TransactionClient | PrismaService,
|
||||||
|
count: number,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (count <= 0) return [];
|
||||||
const [fromItem, fromSku] = await Promise.all([
|
const [fromItem, fromSku] = await Promise.all([
|
||||||
db.commonProductItem.findMany({
|
db.commonProductItem.findMany({
|
||||||
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
||||||
@@ -668,14 +828,15 @@ export class AdminProductsService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
let maxSeq = 0;
|
let maxSeq = 0;
|
||||||
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
|
|
||||||
for (const row of [...fromItem, ...fromSku]) {
|
for (const row of [...fromItem, ...fromSku]) {
|
||||||
const m = re.exec(row.skuCode);
|
const m = SKU_AUTO_RE.exec(row.skuCode);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
const n = Number(m[1]);
|
const n = Number(m[1]);
|
||||||
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
if (Number.isFinite(n) && n > maxSeq) maxSeq = n;
|
||||||
}
|
}
|
||||||
return `${SKU_AUTO_PREFIX}${String(maxSeq + 1).padStart(SKU_AUTO_PAD, '0')}`;
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
return `${SKU_AUTO_PREFIX}${String(maxSeq + 1 + i).padStart(SKU_AUTO_PAD, '0')}`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createWithGeneratedSku(
|
private async createWithGeneratedSku(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
MinLength,
|
MinLength,
|
||||||
@@ -1353,6 +1354,7 @@ class AdminSkuRowDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
specValueIds?: string[];
|
specValueIds?: string[];
|
||||||
|
|
||||||
|
/** 忽略;服务端自动生成 DK 码 */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
skuCode?: string;
|
skuCode?: string;
|
||||||
@@ -1399,6 +1401,12 @@ class AdminSkuRowDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
|
||||||
|
/** 规格主图 URL;空则回落商品封面 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
imageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SaveProductSkusDto {
|
export class SaveProductSkusDto {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Type, Transform } from 'class-transformer';
|
import { Type, Transform } from 'class-transformer';
|
||||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
function toOptionalBoolean(value: unknown): boolean | undefined {
|
function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||||
if (value === undefined || value === null || value === '') return undefined;
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
@@ -93,6 +93,66 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
|||||||
@Transform(({ value }) => toOptionalBoolean(value))
|
@Transform(({ value }) => toOptionalBoolean(value))
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
excludeTest?: boolean;
|
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 聚合筛选 */
|
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export class CreateInvoiceDto {
|
|||||||
@IsIn(['NORMAL', 'SPECIAL'])
|
@IsIn(['NORMAL', 'SPECIAL'])
|
||||||
invoiceKind?: string;
|
invoiceKind?: string;
|
||||||
|
|
||||||
|
/** 酒水类 / 餐饮类;缺省酒水类。C 端票种固定普通发票 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['LIQUOR', 'CATERING'])
|
||||||
|
invoiceCategory?: string;
|
||||||
|
|
||||||
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class InvoiceTitleService {
|
|||||||
titleType: body.titleType,
|
titleType: body.titleType,
|
||||||
titleName: body.titleName.trim(),
|
titleName: body.titleName.trim(),
|
||||||
taxNo: body.taxNo?.trim() || null,
|
taxNo: body.taxNo?.trim() || null,
|
||||||
email: body.email?.trim() || null,
|
email: body.email!.trim(),
|
||||||
phone: body.phone?.trim() || null,
|
phone: body.phone?.trim() || null,
|
||||||
addressPhone: body.addressPhone?.trim() || null,
|
addressPhone: body.addressPhone?.trim() || null,
|
||||||
bankAccount: body.bankAccount?.trim() || null,
|
bankAccount: body.bankAccount?.trim() || null,
|
||||||
@@ -73,7 +73,7 @@ export class InvoiceTitleService {
|
|||||||
titleType: body.titleType,
|
titleType: body.titleType,
|
||||||
titleName: body.titleName.trim(),
|
titleName: body.titleName.trim(),
|
||||||
taxNo: body.taxNo?.trim() || null,
|
taxNo: body.taxNo?.trim() || null,
|
||||||
email: body.email?.trim() || null,
|
email: body.email!.trim(),
|
||||||
phone: body.phone?.trim() || null,
|
phone: body.phone?.trim() || null,
|
||||||
addressPhone: body.addressPhone?.trim() || null,
|
addressPhone: body.addressPhone?.trim() || null,
|
||||||
bankAccount: body.bankAccount?.trim() || null,
|
bankAccount: body.bankAccount?.trim() || null,
|
||||||
@@ -101,6 +101,13 @@ export class InvoiceTitleService {
|
|||||||
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
|
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
|
||||||
throw new BadRequestException('企业抬头须填写税号');
|
throw new BadRequestException('企业抬头须填写税号');
|
||||||
}
|
}
|
||||||
|
const email = body.email?.trim() || '';
|
||||||
|
if (!email) {
|
||||||
|
throw new BadRequestException('请填写接收邮箱');
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
throw new BadRequestException('邮箱格式不正确');
|
||||||
|
}
|
||||||
if (isUpdate && body.titleType === undefined) {
|
if (isUpdate && body.titleType === undefined) {
|
||||||
throw new BadRequestException('titleType 必填');
|
throw new BadRequestException('titleType 必填');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { AlertService } from '../../common/alert/alert.service';
|
|||||||
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
|
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
|
||||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import type { CommonProductSku } from '@prisma/client';
|
import type { ProductSaleUnit } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TradeService {
|
export class TradeService {
|
||||||
@@ -64,23 +64,34 @@ export class TradeService {
|
|||||||
|
|
||||||
private readonly logger = new Logger(TradeService.name);
|
private readonly logger = new Logger(TradeService.name);
|
||||||
|
|
||||||
private overlayProductWithSku(
|
private overlayProductWithSale(
|
||||||
productDto: Record<string, unknown>,
|
productDto: Record<string, unknown>,
|
||||||
sku: CommonProductSku,
|
sale: {
|
||||||
|
skuId: bigint | null;
|
||||||
|
skuCode: string;
|
||||||
|
specText: string;
|
||||||
|
price: unknown;
|
||||||
|
benefitAmount: unknown;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
saleUnit: ProductSaleUnit;
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
...productDto,
|
...productDto,
|
||||||
skuCode: sku.skuCode,
|
skuCode: sale.skuCode,
|
||||||
spec: sku.specText || productDto.spec,
|
spec: sale.specText || productDto.spec,
|
||||||
price: Number(sku.price),
|
price: Number(sale.price),
|
||||||
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
benefitAmount: Number(sale.benefitAmount ?? sale.price),
|
||||||
benefitDisplay: Number(sku.benefitAmount ?? sku.price),
|
benefitDisplay: Number(sale.benefitAmount ?? sale.price),
|
||||||
allowOnSitePickup: sku.allowOnSitePickup,
|
allowOnSitePickup: sale.allowOnSitePickup,
|
||||||
allowOnlinePurchase: sku.allowOnlinePurchase,
|
allowOnlinePurchase: sale.allowOnlinePurchase,
|
||||||
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
allowCrossCityDelivery: sale.allowCrossCityDelivery,
|
||||||
saleUnit: sku.saleUnit,
|
saleUnit: sale.saleUnit,
|
||||||
bottlesPerUnit: sku.bottlesPerUnit,
|
bottlesPerUnit: sale.bottlesPerUnit,
|
||||||
selectedSkuId: sku.id.toString(),
|
...(sale.skuId ? { selectedSkuId: sale.skuId.toString() } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +106,7 @@ export class TradeService {
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
|
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
|
||||||
const { product: spu, sku } = await this.catalogService.assertPurchasable(
|
const { product: spu, sale } = await this.catalogService.assertPurchasable(
|
||||||
BigInt(body.productId),
|
BigInt(body.productId),
|
||||||
viewerPhone,
|
viewerPhone,
|
||||||
body.skuId,
|
body.skuId,
|
||||||
@@ -104,13 +115,13 @@ export class TradeService {
|
|||||||
if (!productDto || productDto.status !== 'ON_SALE') {
|
if (!productDto || productDto.status !== 'ON_SALE') {
|
||||||
throw new BadRequestException('商品不可购买');
|
throw new BadRequestException('商品不可购买');
|
||||||
}
|
}
|
||||||
const product = this.overlayProductWithSku(productDto as Record<string, unknown>, sku);
|
const product = this.overlayProductWithSale(productDto as Record<string, unknown>, sale);
|
||||||
|
|
||||||
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
|
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
|
||||||
if (!city) throw new BadRequestException('暂无开城城市');
|
if (!city) throw new BadRequestException('暂无开城城市');
|
||||||
|
|
||||||
const onSitePickup = !!body.onSitePickup;
|
const onSitePickup = !!body.onSitePickup;
|
||||||
if (onSitePickup && !sku.allowOnSitePickup) {
|
if (onSitePickup && !sale.allowOnSitePickup) {
|
||||||
throw new BadRequestException('该规格不支持现场取货');
|
throw new BadRequestException('该规格不支持现场取货');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +140,8 @@ export class TradeService {
|
|||||||
let addressOk = true;
|
let addressOk = true;
|
||||||
let addressMessage: string | null = null;
|
let addressMessage: string | null = null;
|
||||||
if (!onSitePickup) {
|
if (!onSitePickup) {
|
||||||
const allowOnline = sku.allowOnlinePurchase !== false;
|
const allowOnline = sale.allowOnlinePurchase !== false;
|
||||||
const allowCross = sku.allowCrossCityDelivery !== false;
|
const allowCross = sale.allowCrossCityDelivery !== false;
|
||||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||||
addressOk = false;
|
addressOk = false;
|
||||||
addressMessage = '该规格不支持线上购买';
|
addressMessage = '该规格不支持线上购买';
|
||||||
@@ -145,20 +156,20 @@ export class TradeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1;
|
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
|
||||||
const check = validateMinPurchase(
|
const check = validateMinPurchase(
|
||||||
deliveryType,
|
deliveryType,
|
||||||
body.quantity,
|
body.quantity,
|
||||||
city.localMinQty,
|
city.localMinQty,
|
||||||
city.crossMinQty,
|
city.crossMinQty,
|
||||||
{ bottlesPerUnit, saleUnit: sku.saleUnit },
|
{ bottlesPerUnit, saleUnit: sale.saleUnit },
|
||||||
);
|
);
|
||||||
|
|
||||||
const unitPrice = Number(sku.price);
|
const unitPrice = Number(sale.price);
|
||||||
const productAmount = unitPrice * body.quantity;
|
const productAmount = unitPrice * body.quantity;
|
||||||
const benefitPerUnit = calcBenefitAmount({
|
const benefitPerUnit = calcBenefitAmount({
|
||||||
price: unitPrice,
|
price: unitPrice,
|
||||||
benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null,
|
benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
||||||
@@ -186,10 +197,13 @@ export class TradeService {
|
|||||||
addressMessage,
|
addressMessage,
|
||||||
minQty,
|
minQty,
|
||||||
onSitePickup,
|
onSitePickup,
|
||||||
allowCrossCityDelivery: sku.allowCrossCityDelivery !== false,
|
allowCrossCityDelivery: sale.allowCrossCityDelivery !== false,
|
||||||
allowOnlinePurchase: sku.allowOnlinePurchase !== false,
|
allowOnlinePurchase: sale.allowOnlinePurchase !== false,
|
||||||
skuId: sku.id.toString(),
|
skuId: sale.skuId?.toString(),
|
||||||
saleUnit: sku.saleUnit,
|
barcode69: sale.barcode69,
|
||||||
|
productSpec: sale.specText,
|
||||||
|
unitPrice,
|
||||||
|
saleUnit: sale.saleUnit,
|
||||||
bottlesPerUnit,
|
bottlesPerUnit,
|
||||||
bottleQuantity: body.quantity * bottlesPerUnit,
|
bottleQuantity: body.quantity * bottlesPerUnit,
|
||||||
};
|
};
|
||||||
@@ -247,9 +261,6 @@ export class TradeService {
|
|||||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||||
where: { id: BigInt(body.productId) },
|
where: { id: BigInt(body.productId) },
|
||||||
});
|
});
|
||||||
const sku = await this.prisma.commonProductSku.findUniqueOrThrow({
|
|
||||||
where: { id: BigInt(preview.skuId) },
|
|
||||||
});
|
|
||||||
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
||||||
const orderNo = generateOrderNo();
|
const orderNo = generateOrderNo();
|
||||||
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
const payExpireAt = new Date(Date.now() + 30 * 60 * 1000);
|
||||||
@@ -282,15 +293,15 @@ export class TradeService {
|
|||||||
payStatus: 'UNPAID',
|
payStatus: 'UNPAID',
|
||||||
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
skuId: sku.id,
|
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||||
barcode69: sku.barcode69,
|
barcode69: preview.barcode69 || product.barcode69,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
productSpec: sku.specText || product.spec,
|
productSpec: preview.productSpec || product.spec,
|
||||||
imageResourceId: product.coverResourceId,
|
imageResourceId: product.coverResourceId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
saleUnit: sku.saleUnit,
|
saleUnit: preview.saleUnit,
|
||||||
bottlesPerUnit: preview.bottlesPerUnit,
|
bottlesPerUnit: preview.bottlesPerUnit,
|
||||||
listUnitPrice: sku.price,
|
listUnitPrice: preview.unitPrice,
|
||||||
listAmount: preview.productAmount,
|
listAmount: preview.productAmount,
|
||||||
productAmount: preview.productAmount,
|
productAmount: preview.productAmount,
|
||||||
receiverName,
|
receiverName,
|
||||||
@@ -346,7 +357,7 @@ export class TradeService {
|
|||||||
extraJson: {
|
extraJson: {
|
||||||
orderId: order.id.toString(),
|
orderId: order.id.toString(),
|
||||||
productId: body.productId,
|
productId: body.productId,
|
||||||
skuId: sku.id.toString(),
|
skuId: preview.skuId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
onSitePickup,
|
onSitePickup,
|
||||||
},
|
},
|
||||||
@@ -1080,6 +1091,7 @@ export class TradeService {
|
|||||||
titleId?: string;
|
titleId?: string;
|
||||||
titleType?: string;
|
titleType?: string;
|
||||||
invoiceKind?: string;
|
invoiceKind?: string;
|
||||||
|
invoiceCategory?: string;
|
||||||
titleName?: string;
|
titleName?: string;
|
||||||
taxNo?: string | null;
|
taxNo?: string | null;
|
||||||
addressPhone?: string | null;
|
addressPhone?: string | null;
|
||||||
@@ -1088,6 +1100,7 @@ export class TradeService {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
},
|
},
|
||||||
|
opts?: { allowSpecialKind?: boolean },
|
||||||
) {
|
) {
|
||||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
@@ -1137,7 +1150,8 @@ export class TradeService {
|
|||||||
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
|
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
|
||||||
throw new BadRequestException('企业抬头须填写税号');
|
throw new BadRequestException('企业抬头须填写税号');
|
||||||
}
|
}
|
||||||
const invoiceKind = body.invoiceKind || 'NORMAL';
|
const invoiceKind =
|
||||||
|
opts?.allowSpecialKind && body.invoiceKind === 'SPECIAL' ? 'SPECIAL' : 'NORMAL';
|
||||||
if (invoiceKind === 'SPECIAL') {
|
if (invoiceKind === 'SPECIAL') {
|
||||||
if (resolved.titleType !== 'ENTERPRISE') {
|
if (resolved.titleType !== 'ENTERPRISE') {
|
||||||
throw new BadRequestException('专用发票仅支持企业抬头');
|
throw new BadRequestException('专用发票仅支持企业抬头');
|
||||||
@@ -1146,6 +1160,7 @@ export class TradeService {
|
|||||||
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const invoiceCategory = body.invoiceCategory === 'CATERING' ? 'CATERING' : 'LIQUOR';
|
||||||
|
|
||||||
const invoice = await this.prisma.userInvoice.create({
|
const invoice = await this.prisma.userInvoice.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1154,6 +1169,7 @@ export class TradeService {
|
|||||||
userId,
|
userId,
|
||||||
titleType: resolved.titleType as never,
|
titleType: resolved.titleType as never,
|
||||||
invoiceKind: invoiceKind as never,
|
invoiceKind: invoiceKind as never,
|
||||||
|
invoiceCategory: invoiceCategory as never,
|
||||||
titleName: resolved.titleName.trim(),
|
titleName: resolved.titleName.trim(),
|
||||||
taxNo: resolved.taxNo?.trim() || null,
|
taxNo: resolved.taxNo?.trim() || null,
|
||||||
addressPhone: resolved.addressPhone?.trim() || null,
|
addressPhone: resolved.addressPhone?.trim() || null,
|
||||||
@@ -1161,7 +1177,7 @@ export class TradeService {
|
|||||||
email: resolved.email.trim(),
|
email: resolved.email.trim(),
|
||||||
phone: resolved.phone.trim(),
|
phone: resolved.phone.trim(),
|
||||||
remark: body.remark?.trim() || null,
|
remark: body.remark?.trim() || null,
|
||||||
},
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!order.isTest) {
|
if (!order.isTest) {
|
||||||
@@ -1254,6 +1270,7 @@ export class TradeService {
|
|||||||
titleId?: string;
|
titleId?: string;
|
||||||
titleType?: string;
|
titleType?: string;
|
||||||
invoiceKind?: string;
|
invoiceKind?: string;
|
||||||
|
invoiceCategory?: string;
|
||||||
titleName?: string;
|
titleName?: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
@@ -1267,7 +1284,7 @@ export class TradeService {
|
|||||||
if (!orderNo) throw new BadRequestException('请填写订单号');
|
if (!orderNo) throw new BadRequestException('请填写订单号');
|
||||||
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
return this.createInvoice(order.userId, order.id, body);
|
return this.createInvoice(order.userId, order.id, body, { allowSpecialKind: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async adminListInvoices(query: {
|
async adminListInvoices(query: {
|
||||||
@@ -1598,7 +1615,7 @@ export class TradeService {
|
|||||||
},
|
},
|
||||||
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
|
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
|
||||||
) {
|
) {
|
||||||
const { product: spu, sku } = await this.catalogService.assertPurchasable(
|
const { product: spu, sale } = await this.catalogService.assertPurchasable(
|
||||||
BigInt(body.productId),
|
BigInt(body.productId),
|
||||||
viewer?.phone,
|
viewer?.phone,
|
||||||
body.skuId,
|
body.skuId,
|
||||||
@@ -1620,7 +1637,7 @@ export class TradeService {
|
|||||||
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
|
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
|
||||||
|
|
||||||
if (deliveryMode === 'ON_SITE_PICKUP') {
|
if (deliveryMode === 'ON_SITE_PICKUP') {
|
||||||
if (!sku.allowOnSitePickup) {
|
if (!sale.allowOnSitePickup) {
|
||||||
throw new BadRequestException('该规格不支持现场提货');
|
throw new BadRequestException('该规格不支持现场提货');
|
||||||
}
|
}
|
||||||
deliveryType = 'ON_SITE_PICKUP';
|
deliveryType = 'ON_SITE_PICKUP';
|
||||||
@@ -1629,8 +1646,8 @@ export class TradeService {
|
|||||||
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
||||||
deliveryType = 'CROSS_CITY';
|
deliveryType = 'CROSS_CITY';
|
||||||
}
|
}
|
||||||
const allowOnline = sku.allowOnlinePurchase !== false;
|
const allowOnline = sale.allowOnlinePurchase !== false;
|
||||||
const allowCross = sku.allowCrossCityDelivery !== false;
|
const allowCross = sale.allowCrossCityDelivery !== false;
|
||||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||||
throw new BadRequestException('该规格不支持线上购买');
|
throw new BadRequestException('该规格不支持线上购买');
|
||||||
}
|
}
|
||||||
@@ -1644,21 +1661,21 @@ export class TradeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bottlesPerUnit = sku.bottlesPerUnit > 0 ? sku.bottlesPerUnit : 1;
|
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
|
||||||
const check = validateMinPurchase(
|
const check = validateMinPurchase(
|
||||||
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL',
|
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL',
|
||||||
body.quantity,
|
body.quantity,
|
||||||
city.localMinQty,
|
city.localMinQty,
|
||||||
city.crossMinQty,
|
city.crossMinQty,
|
||||||
{ bottlesPerUnit, saleUnit: sku.saleUnit },
|
{ bottlesPerUnit, saleUnit: sale.saleUnit },
|
||||||
);
|
);
|
||||||
if (!check.ok) throw new BadRequestException(check.message);
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
|
|
||||||
const unitPrice = Number(sku.price);
|
const unitPrice = Number(sale.price);
|
||||||
const productAmount = unitPrice * body.quantity;
|
const productAmount = unitPrice * body.quantity;
|
||||||
const benefitPerUnit = calcBenefitAmount({
|
const benefitPerUnit = calcBenefitAmount({
|
||||||
price: unitPrice,
|
price: unitPrice,
|
||||||
benefitAmount: sku.benefitAmount != null ? Number(sku.benefitAmount) : null,
|
benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1667,8 +1684,10 @@ export class TradeService {
|
|||||||
benefitAmount: benefitPerUnit * body.quantity,
|
benefitAmount: benefitPerUnit * body.quantity,
|
||||||
deliveryType,
|
deliveryType,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
skuId: sku.id.toString(),
|
skuId: sale.skuId?.toString(),
|
||||||
saleUnit: sku.saleUnit,
|
barcode69: sale.barcode69,
|
||||||
|
productSpec: sale.specText,
|
||||||
|
saleUnit: sale.saleUnit,
|
||||||
bottlesPerUnit,
|
bottlesPerUnit,
|
||||||
bottleQuantity: body.quantity * bottlesPerUnit,
|
bottleQuantity: body.quantity * bottlesPerUnit,
|
||||||
minQuantity: toMinSaleQuantity(
|
minQuantity: toMinSaleQuantity(
|
||||||
@@ -1755,9 +1774,6 @@ export class TradeService {
|
|||||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||||
where: { id: BigInt(body.productId) },
|
where: { id: BigInt(body.productId) },
|
||||||
});
|
});
|
||||||
const sku = await this.prisma.commonProductSku.findUniqueOrThrow({
|
|
||||||
where: { id: BigInt(preview.skuId) },
|
|
||||||
});
|
|
||||||
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
||||||
|
|
||||||
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||||
@@ -1815,15 +1831,15 @@ export class TradeService {
|
|||||||
channelSource: 'PROXY_ONLINE',
|
channelSource: 'PROXY_ONLINE',
|
||||||
promoCodeId,
|
promoCodeId,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
skuId: sku.id,
|
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||||
barcode69: sku.barcode69,
|
barcode69: preview.barcode69 || product.barcode69,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
productSpec: sku.specText || product.spec,
|
productSpec: preview.productSpec || product.spec,
|
||||||
imageResourceId: product.coverResourceId,
|
imageResourceId: product.coverResourceId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
saleUnit: sku.saleUnit,
|
saleUnit: preview.saleUnit,
|
||||||
bottlesPerUnit: preview.bottlesPerUnit,
|
bottlesPerUnit: preview.bottlesPerUnit,
|
||||||
listUnitPrice: sku.price,
|
listUnitPrice: preview.unitPrice,
|
||||||
listAmount: preview.productAmount,
|
listAmount: preview.productAmount,
|
||||||
productAmount: preview.productAmount,
|
productAmount: preview.productAmount,
|
||||||
payAmount: preview.payAmount,
|
payAmount: preview.payAmount,
|
||||||
@@ -2171,9 +2187,6 @@ export class TradeService {
|
|||||||
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
const product = await this.prisma.commonProductItem.findUniqueOrThrow({
|
||||||
where: { id: BigInt(body.productId) },
|
where: { id: BigInt(body.productId) },
|
||||||
});
|
});
|
||||||
const sku = await this.prisma.commonProductSku.findUniqueOrThrow({
|
|
||||||
where: { id: BigInt(preview.skuId) },
|
|
||||||
});
|
|
||||||
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } });
|
||||||
|
|
||||||
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`;
|
||||||
@@ -2231,15 +2244,15 @@ export class TradeService {
|
|||||||
channelSource: 'PROXY_ONLINE',
|
channelSource: 'PROXY_ONLINE',
|
||||||
promoCodeId,
|
promoCodeId,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
skuId: sku.id,
|
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||||
barcode69: sku.barcode69,
|
barcode69: preview.barcode69 || product.barcode69,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
productSpec: sku.specText || product.spec,
|
productSpec: preview.productSpec || product.spec,
|
||||||
imageResourceId: product.coverResourceId,
|
imageResourceId: product.coverResourceId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
saleUnit: sku.saleUnit,
|
saleUnit: preview.saleUnit,
|
||||||
bottlesPerUnit: preview.bottlesPerUnit,
|
bottlesPerUnit: preview.bottlesPerUnit,
|
||||||
listUnitPrice: sku.price,
|
listUnitPrice: preview.unitPrice,
|
||||||
listAmount: preview.productAmount,
|
listAmount: preview.productAmount,
|
||||||
productAmount: preview.productAmount,
|
productAmount: preview.productAmount,
|
||||||
payAmount: preview.payAmount,
|
payAmount: preview.payAmount,
|
||||||
|
|||||||
Reference in New Issue
Block a user