Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3004a65b35 | |||
| f9161368e1 | |||
| 8a061f085f | |||
| b20e566a65 | |||
| 7d2e4ef57e | |||
| 4d434c9c67 | |||
| 73fd27bdf6 | |||
| 62e61a9e63 | |||
| cd9696bc1c | |||
| a01217539c |
@@ -0,0 +1,616 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
||||||
|
import OssUpload from './OssUpload';
|
||||||
|
|
||||||
|
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
||||||
|
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
||||||
|
type SkuRow = {
|
||||||
|
id?: string;
|
||||||
|
specValueIds: string[];
|
||||||
|
skuCode?: string;
|
||||||
|
barcode69: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount?: number;
|
||||||
|
status: string;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
saleUnit: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
specText?: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cartesian(attrs: SpecAttr[]): string[][] {
|
||||||
|
if (!attrs.length) return [[]];
|
||||||
|
return attrs.reduce<string[][]>(
|
||||||
|
(acc, attr) => {
|
||||||
|
const next: string[][] = [];
|
||||||
|
const values = attr.values.filter((v) => v.name?.trim());
|
||||||
|
for (const prev of acc) {
|
||||||
|
for (const v of values) {
|
||||||
|
const vid = v.id || `__new__:${attr.name}:${v.name}`;
|
||||||
|
next.push([...prev, vid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next.length ? next : acc;
|
||||||
|
},
|
||||||
|
[[]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(ids: string[]) {
|
||||||
|
return [...ids].sort().join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
productId: string;
|
||||||
|
initialAttrs: SpecAttr[];
|
||||||
|
initialSkus: SkuRow[];
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||||
|
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
||||||
|
initialAttrs.length
|
||||||
|
? initialAttrs
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const [skus, setSkus] = useState<SkuRow[]>(
|
||||||
|
initialSkus.length
|
||||||
|
? initialSkus.map((s) => ({
|
||||||
|
...s,
|
||||||
|
specValueIds: s.specValueIds ?? [],
|
||||||
|
status: s.status || 'ON_SALE',
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||||
|
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||||
|
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
imageUrl: s.imageUrl ?? '',
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
specValueIds: [],
|
||||||
|
barcode69: '',
|
||||||
|
price: 0,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
imageUrl: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||||
|
|
||||||
|
function regenerateMatrix() {
|
||||||
|
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
|
||||||
|
const next: SkuRow[] = combos.map((ids, i) => {
|
||||||
|
const existing = byKey.get(keyOf(ids));
|
||||||
|
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
|
||||||
|
return {
|
||||||
|
specValueIds: ids,
|
||||||
|
barcode69: '',
|
||||||
|
price: skus[0]?.price ?? 0,
|
||||||
|
benefitAmount: skus[0]?.benefitAmount,
|
||||||
|
status: 'DRAFT',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: i === 0,
|
||||||
|
sortOrder: i,
|
||||||
|
imageUrl: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
||||||
|
setSkus(next.length ? next : skus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSku(index: number, patch: Partial<SkuRow>) {
|
||||||
|
setSkus((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = { ...next[index], ...patch };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
|
||||||
|
const specsRes = await request<{
|
||||||
|
specAttrs: SpecAttr[];
|
||||||
|
skus: SkuRow[];
|
||||||
|
}>(`/admin/products/${productId}/specs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
attrs: attrs.map((a, i) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name.trim(),
|
||||||
|
sortOrder: i,
|
||||||
|
values: a.values
|
||||||
|
.filter((v) => v.name.trim())
|
||||||
|
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 映射临时 id → 真实 id
|
||||||
|
const nameToId = new Map<string, string>();
|
||||||
|
for (const a of specsRes.specAttrs ?? []) {
|
||||||
|
for (const v of a.values ?? []) {
|
||||||
|
nameToId.set(`${a.name}:${v.name}`, v.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolveIds = (ids: string[]) =>
|
||||||
|
ids.map((id) => {
|
||||||
|
if (!id.startsWith('__new__:')) return id;
|
||||||
|
const [, attrName, valueName] = id.split(':');
|
||||||
|
const real = nameToId.get(`${attrName}:${valueName}`);
|
||||||
|
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
|
||||||
|
return real;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloadSkus = skus.map((s, i) => ({
|
||||||
|
id: s.id,
|
||||||
|
specValueIds: resolveIds(s.specValueIds ?? []),
|
||||||
|
barcode69: s.barcode69.trim(),
|
||||||
|
price: s.price,
|
||||||
|
benefitAmount: s.benefitAmount,
|
||||||
|
status: s.status,
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit,
|
||||||
|
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
sortOrder: i,
|
||||||
|
imageUrl: s.imageUrl?.trim() || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const barcodes = payloadSkus.map((row) => row.barcode69);
|
||||||
|
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`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ skus: payloadSkus }),
|
||||||
|
});
|
||||||
|
message.success('规格与 SKU 已保存');
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editing = editIndex != null ? skus[editIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
||||||
|
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Typography.Title level={5}>规格轴</Typography.Title>
|
||||||
|
{attrs.map((attr, ai) => (
|
||||||
|
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||||
|
<Space align="start" style={{ width: '100%' }} wrap>
|
||||||
|
<Input
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="规格名"
|
||||||
|
value={attr.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, name: e.target.value };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<MinusCircleOutlined />}
|
||||||
|
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{attr.values.map((val, vi) => (
|
||||||
|
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="规格值"
|
||||||
|
value={val.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...attrs];
|
||||||
|
const values = [...attr.values];
|
||||||
|
values[vi] = { ...val, name: e.target.value };
|
||||||
|
next[ai] = { ...attr, values };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<MinusCircleOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加规格值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={attrs.length >= 3}
|
||||||
|
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
|
||||||
|
>
|
||||||
|
添加规格轴
|
||||||
|
</Button>
|
||||||
|
<Button onClick={regenerateMatrix}>按规格生成 SKU 矩阵</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Title level={5}>SKU 列表</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey={(_, i) => String(i)}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1240 }}
|
||||||
|
dataSource={skus}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '规格',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SKU',
|
||||||
|
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) => (
|
||||||
|
<Input
|
||||||
|
placeholder="本规格独立 69 码"
|
||||||
|
value={row.barcode69}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, barcode69: e.target.value };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '售价',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.price}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, price: Number(v) || 0 };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '权益',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.benefitAmount}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.status}
|
||||||
|
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={(status) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, status };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单位',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ width: 80 }}
|
||||||
|
value={row.saleUnit}
|
||||||
|
options={[
|
||||||
|
{ value: 'BOTTLE', label: '瓶' },
|
||||||
|
{ value: 'BOX', label: '箱' },
|
||||||
|
]}
|
||||||
|
onChange={(saleUnit) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = {
|
||||||
|
...row,
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
|
||||||
|
};
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{row.saleUnit === 'BOX' ? (
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
style={{ width: 70 }}
|
||||||
|
value={row.bottlesPerUnit}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '履约',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="线上"
|
||||||
|
unCheckedChildren="线上"
|
||||||
|
checked={row.allowOnlinePurchase}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = {
|
||||||
|
...row,
|
||||||
|
allowOnlinePurchase: checked,
|
||||||
|
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
|
||||||
|
};
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="跨城"
|
||||||
|
unCheckedChildren="跨城"
|
||||||
|
disabled={!row.allowOnlinePurchase}
|
||||||
|
checked={row.allowCrossCityDelivery}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, allowCrossCityDelivery: checked };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="现场"
|
||||||
|
unCheckedChildren="现场"
|
||||||
|
checked={row.allowOnSitePickup}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, allowOnSitePickup: checked };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '默认',
|
||||||
|
width: 70,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Radio
|
||||||
|
checked={row.isDefault}
|
||||||
|
onChange={() => {
|
||||||
|
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主图',
|
||||||
|
width: 72,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.imageUrl ? (
|
||||||
|
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '编辑',
|
||||||
|
width: 80,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
|
||||||
|
填写
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
||||||
|
保存规格与 SKU
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
|
||||||
|
open={editIndex != null}
|
||||||
|
onCancel={() => setEditIndex(null)}
|
||||||
|
onOk={() => setEditIndex(null)}
|
||||||
|
okText="完成"
|
||||||
|
width={560}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{editing && editIndex != null ? (
|
||||||
|
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">SKU</Typography.Text>
|
||||||
|
<div>{editing.skuCode || '保存后自动生成'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>69 码 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder="本规格独立 69 码"
|
||||||
|
value={editing.barcode69}
|
||||||
|
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>主图</Typography.Text>
|
||||||
|
<OssUpload
|
||||||
|
bizType="COVER"
|
||||||
|
value={editing.imageUrl || ''}
|
||||||
|
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>售价</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={editing.price}
|
||||||
|
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>权益</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={editing.benefitAmount}
|
||||||
|
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>状态</Typography.Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={editing.status}
|
||||||
|
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={(status) => patchSku(editIndex, { status })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ width: 100 }}
|
||||||
|
value={editing.saleUnit}
|
||||||
|
options={[
|
||||||
|
{ value: 'BOTTLE', label: '瓶' },
|
||||||
|
{ value: 'BOX', label: '箱' },
|
||||||
|
]}
|
||||||
|
onChange={(saleUnit) =>
|
||||||
|
patchSku(editIndex, {
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{editing.saleUnit === 'BOX' ? (
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
value={editing.bottlesPerUnit}
|
||||||
|
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="线上"
|
||||||
|
unCheckedChildren="线上"
|
||||||
|
checked={editing.allowOnlinePurchase}
|
||||||
|
onChange={(checked) =>
|
||||||
|
patchSku(editIndex, {
|
||||||
|
allowOnlinePurchase: checked,
|
||||||
|
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="跨城"
|
||||||
|
unCheckedChildren="跨城"
|
||||||
|
disabled={!editing.allowOnlinePurchase}
|
||||||
|
checked={editing.allowCrossCityDelivery}
|
||||||
|
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="现场"
|
||||||
|
unCheckedChildren="现场"
|
||||||
|
checked={editing.allowOnSitePickup}
|
||||||
|
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="默认"
|
||||||
|
unCheckedChildren="默认"
|
||||||
|
checked={editing.isDefault}
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (!checked) return;
|
||||||
|
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||||
const [addressDetail, setAddressDetail] = useState('');
|
const [addressDetail, setAddressDetail] = useState('');
|
||||||
const [productId, setProductId] = useState<string>();
|
const [productId, setProductId] = useState<string>();
|
||||||
|
const [skuId, setSkuId] = useState<string>();
|
||||||
const [quantity, setQuantity] = useState(2);
|
const [quantity, setQuantity] = useState(2);
|
||||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
const selectedSku =
|
||||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
skuOptions.find((s) => s.id === skuId) ||
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||||
|
skuOptions[0];
|
||||||
|
const allowOnline = selectedSku
|
||||||
|
? selectedSku.allowOnlinePurchase !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowOnlinePurchase !== false
|
||||||
|
: true;
|
||||||
|
const allowOnSite = selectedSku
|
||||||
|
? !!selectedSku.allowOnSitePickup
|
||||||
|
: !!selectedProduct?.allowOnSitePickup;
|
||||||
|
const allowCrossCity = selectedSku
|
||||||
|
? selectedSku.allowCrossCityDelivery !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowCrossCityDelivery !== false
|
||||||
|
: true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !selectedProduct) return;
|
||||||
|
const def =
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||||
|
skuOptions.find((s) => s.isDefault) ||
|
||||||
|
skuOptions[0];
|
||||||
|
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||||
|
setSkuId(def.id);
|
||||||
|
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, productId, selectedProduct?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedProduct) return;
|
if (!open || !selectedProduct) return;
|
||||||
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
|
skuId: skuId || undefined,
|
||||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||||
|
|
||||||
useEffect(() => () => stopPoll(), []);
|
useEffect(() => () => stopPoll(), []);
|
||||||
|
|
||||||
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
setRegionCodes([]);
|
setRegionCodes([]);
|
||||||
setAddressDetail('');
|
setAddressDetail('');
|
||||||
setQuantity(2);
|
setQuantity(2);
|
||||||
|
setProductId(undefined);
|
||||||
|
setSkuId(undefined);
|
||||||
setPromoCodeId(undefined);
|
setPromoCodeId(undefined);
|
||||||
setDeliveryMode('ADDRESS');
|
setDeliveryMode('ADDRESS');
|
||||||
setAutoReceive(false);
|
setAutoReceive(false);
|
||||||
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId: productId!,
|
productId: productId!,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
|
skuId: skuId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -317,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
loading={loadingOptions}
|
loading={loadingOptions}
|
||||||
placeholder="选择商品"
|
placeholder="选择商品"
|
||||||
value={productId}
|
value={productId}
|
||||||
onChange={setProductId}
|
onChange={(id) => {
|
||||||
|
setProductId(id);
|
||||||
|
setSkuId(undefined);
|
||||||
|
}}
|
||||||
options={(options?.products ?? []).map((p) => ({
|
options={(options?.products ?? []).map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||||
@@ -325,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="数量" required>
|
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||||
|
<Form.Item label="规格" required>
|
||||||
|
<Select
|
||||||
|
placeholder="选择规格"
|
||||||
|
value={skuId}
|
||||||
|
onChange={(id) => {
|
||||||
|
setSkuId(id);
|
||||||
|
const sku = skuOptions.find((s) => s.id === id);
|
||||||
|
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||||
|
}}
|
||||||
|
options={skuOptions.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={quantity}
|
value={quantity}
|
||||||
|
|||||||
@@ -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="抬头类型"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import OssUpload from '../components/OssUpload';
|
|||||||
import MultiImageUpload from '../components/MultiImageUpload';
|
import MultiImageUpload from '../components/MultiImageUpload';
|
||||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||||
|
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
|
||||||
type ProductDetailContentDto = {
|
type ProductDetailContentDto = {
|
||||||
@@ -20,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;
|
||||||
@@ -29,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;
|
||||||
@@ -213,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' && (
|
||||||
@@ -221,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 }))} />
|
||||||
@@ -250,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" />
|
||||||
@@ -326,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>
|
||||||
) },
|
) },
|
||||||
@@ -342,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) => (
|
||||||
@@ -371,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 }}
|
||||||
@@ -384,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' }}>
|
||||||
@@ -391,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>
|
||||||
@@ -400,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);
|
||||||
@@ -420,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: '详情页',
|
||||||
@@ -437,6 +588,23 @@ export default function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'specs',
|
||||||
|
label: '规格与 SKU',
|
||||||
|
children: (
|
||||||
|
<ProductSpecsEditor
|
||||||
|
productId={String(detail.id)}
|
||||||
|
initialAttrs={(detail.specAttrs as never) ?? []}
|
||||||
|
initialSkus={(detail.skus as never) ?? []}
|
||||||
|
onSaved={async () => {
|
||||||
|
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue(mapDetailToForm(d));
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
]} />
|
]} />
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default function ProxyOrderPage() {
|
|||||||
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
||||||
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
||||||
const [productId, setProductId] = useState(draft0.productId);
|
const [productId, setProductId] = useState(draft0.productId);
|
||||||
|
const [skuId, setSkuId] = useState('');
|
||||||
const [quantity, setQuantity] = useState(draft0.quantity);
|
const [quantity, setQuantity] = useState(draft0.quantity);
|
||||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||||
@@ -156,9 +157,37 @@ export default function ProxyOrderPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
const selectedSku =
|
||||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
skuOptions.find((s) => s.id === skuId) ||
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||||
|
skuOptions[0];
|
||||||
|
const allowOnline = selectedSku
|
||||||
|
? selectedSku.allowOnlinePurchase !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowOnlinePurchase !== false
|
||||||
|
: true;
|
||||||
|
const allowOnSite = selectedSku
|
||||||
|
? !!selectedSku.allowOnSitePickup
|
||||||
|
: !!selectedProduct?.allowOnSitePickup;
|
||||||
|
const allowCrossCity = selectedSku
|
||||||
|
? selectedSku.allowCrossCityDelivery !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowCrossCityDelivery !== false
|
||||||
|
: true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProduct) return;
|
||||||
|
const def =
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||||
|
skuOptions.find((s) => s.isDefault) ||
|
||||||
|
skuOptions[0];
|
||||||
|
if (def && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||||
|
setSkuId(def.id);
|
||||||
|
if (def.saleUnit === 'BOX') setQuantity(1);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [productId, selectedProduct?.id, options]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProduct) return;
|
if (!selectedProduct) return;
|
||||||
@@ -184,6 +213,7 @@ export default function ProxyOrderPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
|
skuId: skuId || undefined,
|
||||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -200,7 +230,7 @@ export default function ProxyOrderPage() {
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
|
||||||
|
|
||||||
function validateForm(): string | null {
|
function validateForm(): string | null {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
@@ -359,6 +389,7 @@ export default function ProxyOrderPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
|
skuId: skuId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -524,8 +555,35 @@ export default function ProxyOrderPage() {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||||
|
<section className="partner-form-section">
|
||||||
|
<label className="partner-form-label">规格</label>
|
||||||
|
<div className="partner-input-wrap">
|
||||||
|
<select
|
||||||
|
className="partner-input"
|
||||||
|
value={skuId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value;
|
||||||
|
setSkuId(id);
|
||||||
|
const sku = skuOptions.find((s) => s.id === id);
|
||||||
|
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{skuOptions.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.specText || '默认'} · ¥{fmtMoney(s.price)} ·{' '}
|
||||||
|
{s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="partner-form-section">
|
<section className="partner-form-section">
|
||||||
<label className="partner-form-label">数量</label>
|
<label className="partner-form-label">
|
||||||
|
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
|
||||||
|
</label>
|
||||||
<div className="partner-input-wrap">
|
<div className="partner-input-wrap">
|
||||||
<input
|
<input
|
||||||
className="partner-input"
|
className="partner-input"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 224 B After Width: | Height: | Size: 3.0 KiB |
@@ -1,19 +1,20 @@
|
|||||||
import { Text, View } from '@tarojs/components';
|
import { Text, View } from '@tarojs/components';
|
||||||
import BenefitFigure from './BenefitFigure';
|
|
||||||
|
|
||||||
type CouponBadgeProps = {
|
type CouponBadgeProps = {
|
||||||
amount: number | string;
|
amount: number | string;
|
||||||
label?: string;
|
label?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge) */
|
/** 首页商品角标:纯文案「享{amount}好客权益」(无门店图标) */
|
||||||
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
||||||
const n = Number(amount);
|
const n = Number(amount);
|
||||||
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
|
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
|
||||||
return (
|
return (
|
||||||
<View className="coupon-badge">
|
<View className="coupon-badge">
|
||||||
<Text>享</Text>
|
<Text>
|
||||||
<BenefitFigure value={`${display} ${label}`} size="sm" />
|
享{display}
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type CheckoutContext = {
|
export type CheckoutContext = {
|
||||||
productId?: string;
|
productId?: string;
|
||||||
|
skuId?: string;
|
||||||
qty?: string;
|
qty?: string;
|
||||||
addressId?: string;
|
addressId?: string;
|
||||||
cross?: boolean;
|
cross?: boolean;
|
||||||
@@ -9,6 +10,7 @@ export type CheckoutContext = {
|
|||||||
export function buildQuery(ctx: CheckoutContext): string {
|
export function buildQuery(ctx: CheckoutContext): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
|
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
|
||||||
|
if (ctx.skuId) parts.push(`skuId=${encodeURIComponent(ctx.skuId)}`);
|
||||||
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
|
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
|
||||||
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
|
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
|
||||||
if (ctx.cross) parts.push('cross=1');
|
if (ctx.cross) parts.push('cross=1');
|
||||||
@@ -36,12 +38,14 @@ export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext
|
|||||||
export function buildPayUrl(params: {
|
export function buildPayUrl(params: {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
productId?: string;
|
productId?: string;
|
||||||
|
skuId?: string;
|
||||||
qty?: string;
|
qty?: string;
|
||||||
addressId?: string;
|
addressId?: string;
|
||||||
cross?: boolean;
|
cross?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
|
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
|
||||||
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
|
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
|
||||||
|
if (params.skuId) parts.push(`skuId=${encodeURIComponent(params.skuId)}`);
|
||||||
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
|
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
|
||||||
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
|
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
|
||||||
if (params.cross) parts.push('cross=1');
|
if (params.cross) parts.push('cross=1');
|
||||||
@@ -51,6 +55,7 @@ export function buildPayUrl(params: {
|
|||||||
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
|
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
|
||||||
return {
|
return {
|
||||||
productId: params.productId,
|
productId: params.productId,
|
||||||
|
skuId: params.skuId,
|
||||||
qty: params.qty,
|
qty: params.qty,
|
||||||
addressId: params.addressId,
|
addressId: params.addressId,
|
||||||
cross: params.cross === '1',
|
cross: params.cross === '1',
|
||||||
|
|||||||
@@ -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 })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ type OrderPreview = {
|
|||||||
quantityOk?: boolean;
|
quantityOk?: boolean;
|
||||||
quantityMessage?: string | null;
|
quantityMessage?: string | null;
|
||||||
minQty?: number;
|
minQty?: number;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrderConfirmPickupPage() {
|
export default function OrderConfirmPickupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const productId = router.params.productId ?? '';
|
const productId = router.params.productId ?? '';
|
||||||
const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
|
const skuId = router.params.skuId ?? '';
|
||||||
|
const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 2)));
|
||||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -50,7 +52,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
request<OrderPreview>('/trade/orders/preview', {
|
request<OrderPreview>('/trade/orders/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { productId, quantity, onSitePickup: true },
|
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -70,15 +72,16 @@ export default function OrderConfirmPickupPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [productId, quantity]);
|
}, [productId, skuId, quantity]);
|
||||||
|
|
||||||
|
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
const minQty = preview?.minQty ?? 2;
|
const minQty = preview?.minQty ?? 2;
|
||||||
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||||
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
||||||
|
|
||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < minQty) {
|
if (next < minQty) {
|
||||||
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
|
||||||
toast(tip);
|
toast(tip);
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
if (next < 1) return;
|
if (next < 1) return;
|
||||||
@@ -91,12 +94,13 @@ export default function OrderConfirmPickupPage() {
|
|||||||
async function doSubmit() {
|
async function doSubmit() {
|
||||||
const order = await request<{ id: string }>('/trade/orders', {
|
const order = await request<{ id: string }>('/trade/orders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { productId, quantity, onSitePickup: true },
|
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
|
||||||
});
|
});
|
||||||
Taro.redirectTo({
|
Taro.redirectTo({
|
||||||
url: buildPayUrl({
|
url: buildPayUrl({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
productId,
|
productId,
|
||||||
|
skuId: skuId || undefined,
|
||||||
qty: String(quantity),
|
qty: String(quantity),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ type OrderPreview = {
|
|||||||
minQty?: number;
|
minQty?: number;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
|
skuId?: string;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
|
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
|
||||||
@@ -66,6 +69,7 @@ export default function OrderConfirmPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const checkoutCtx = readCheckoutContext(router.params);
|
const checkoutCtx = readCheckoutContext(router.params);
|
||||||
const productId = checkoutCtx.productId ?? '';
|
const productId = checkoutCtx.productId ?? '';
|
||||||
|
const skuId = checkoutCtx.skuId ?? '';
|
||||||
const forceCross = checkoutCtx.cross === true;
|
const forceCross = checkoutCtx.cross === true;
|
||||||
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
|
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
|
||||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||||
@@ -96,11 +100,12 @@ export default function OrderConfirmPage() {
|
|||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setPreviewLoading(true);
|
setPreviewLoading(true);
|
||||||
const body: { productId: string; quantity: number; addressId?: string } = {
|
const body: { productId: string; quantity: number; addressId?: string; skuId?: string } = {
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
};
|
};
|
||||||
if (addressId) body.addressId = addressId;
|
if (addressId) body.addressId = addressId;
|
||||||
|
if (skuId) body.skuId = skuId;
|
||||||
|
|
||||||
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
|
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -139,7 +144,7 @@ export default function OrderConfirmPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [productId, quantity, addressId]);
|
}, [productId, skuId, quantity, addressId]);
|
||||||
|
|
||||||
const selectedAddress = useMemo(
|
const selectedAddress = useMemo(
|
||||||
() => addresses.find((a) => String(a.id) === addressId),
|
() => addresses.find((a) => String(a.id) === addressId),
|
||||||
@@ -157,6 +162,7 @@ export default function OrderConfirmPage() {
|
|||||||
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
||||||
const crossBlocked = isCross && !allowCross;
|
const crossBlocked = isCross && !allowCross;
|
||||||
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
|
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
|
||||||
|
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
const minQty =
|
const minQty =
|
||||||
preview?.minQty ??
|
preview?.minQty ??
|
||||||
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
|
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
|
||||||
@@ -171,8 +177,8 @@ export default function OrderConfirmPage() {
|
|||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < minQty) {
|
if (next < minQty) {
|
||||||
const tip = isCross
|
const tip = isCross
|
||||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
? `跨城配送至少购买 ${minQty}${unitLabel}`
|
||||||
: `同城配送至少购买 ${minQty} 瓶`;
|
: `同城配送至少购买 ${minQty}${unitLabel}`;
|
||||||
toast(tip);
|
toast(tip);
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
if (next < 1) return;
|
if (next < 1) return;
|
||||||
@@ -195,6 +201,7 @@ export default function OrderConfirmPage() {
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
addressId,
|
addressId,
|
||||||
|
...(skuId ? { skuId } : {}),
|
||||||
...(clientLocation ? { clientLocation } : {}),
|
...(clientLocation ? { clientLocation } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -202,6 +209,7 @@ export default function OrderConfirmPage() {
|
|||||||
url: buildPayUrl({
|
url: buildPayUrl({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
productId,
|
productId,
|
||||||
|
skuId: skuId || undefined,
|
||||||
qty: String(quantity),
|
qty: String(quantity),
|
||||||
addressId,
|
addressId,
|
||||||
cross: forceCross,
|
cross: forceCross,
|
||||||
@@ -223,15 +231,15 @@ export default function OrderConfirmPage() {
|
|||||||
}
|
}
|
||||||
if (!quantityOk) {
|
if (!quantityOk) {
|
||||||
const tip = isCross
|
const tip = isCross
|
||||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
? `跨城配送至少购买 ${minQty}${unitLabel}`
|
||||||
: `同城配送至少购买 ${minQty} 瓶`;
|
: `同城配送至少购买 ${minQty}${unitLabel}`;
|
||||||
setMsg(tip);
|
setMsg(tip);
|
||||||
toast(tip);
|
toast(tip);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
|
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${skuId ? `&skuId=${skuId}` : ''}${forceCross ? '&cross=1' : ''}`;
|
||||||
|
|
||||||
if (!phonePromptSkipped.current) {
|
if (!phonePromptSkipped.current) {
|
||||||
try {
|
try {
|
||||||
@@ -371,8 +379,8 @@ export default function OrderConfirmPage() {
|
|||||||
{!quantityOk ? (
|
{!quantityOk ? (
|
||||||
<Text className="order-qty-hint">
|
<Text className="order-qty-hint">
|
||||||
{isCross
|
{isCross
|
||||||
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
|
? `跨城配送至少购买 ${minQty}${unitLabel},请调整数量`
|
||||||
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
|
: `同城配送至少购买 ${minQty}${unitLabel},请调整数量`}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Taro, {
|
|||||||
useShareAppMessage,
|
useShareAppMessage,
|
||||||
useShareTimeline,
|
useShareTimeline,
|
||||||
} from '@tarojs/taro';
|
} from '@tarojs/taro';
|
||||||
import type { ProductDetailContentDto } from '@dukang/shared-types';
|
import type { ProductDetailContentDto, ProductSkuDto, ProductSpecAttrDto } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
@@ -47,8 +47,39 @@ type Product = ProductImageSource & {
|
|||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
|
specEnabled?: boolean;
|
||||||
|
specAttrs?: ProductSpecAttrDto[];
|
||||||
|
skus?: ProductSkuDto[];
|
||||||
|
defaultSkuId?: string;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function findSku(
|
||||||
|
skus: ProductSkuDto[],
|
||||||
|
selected: Record<string, string>,
|
||||||
|
attrs: ProductSpecAttrDto[],
|
||||||
|
): ProductSkuDto | undefined {
|
||||||
|
const valueIds = attrs.map((a) => selected[a.id]).filter(Boolean);
|
||||||
|
if (valueIds.length !== attrs.length) return undefined;
|
||||||
|
const key = [...valueIds].sort().join('_');
|
||||||
|
return skus.find((s) => [...s.specValueIds].sort().join('_') === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValueAvailable(
|
||||||
|
skus: ProductSkuDto[],
|
||||||
|
attrs: ProductSpecAttrDto[],
|
||||||
|
selected: Record<string, string>,
|
||||||
|
attrId: string,
|
||||||
|
valueId: string,
|
||||||
|
): boolean {
|
||||||
|
const trial = { ...selected, [attrId]: valueId };
|
||||||
|
const partialIds = attrs.map((a) => trial[a.id]).filter(Boolean);
|
||||||
|
return skus.some((s) => {
|
||||||
|
if (s.status !== 'ON_SALE') return false;
|
||||||
|
return partialIds.every((id) => s.specValueIds.includes(id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductDetailPage() {
|
export default function ProductDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const productId = router.params.id ?? '';
|
const productId = router.params.id ?? '';
|
||||||
@@ -58,6 +89,7 @@ export default function ProductDetailPage() {
|
|||||||
);
|
);
|
||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
usePageScroll(({ scrollTop }) => {
|
usePageScroll(({ scrollTop }) => {
|
||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
@@ -72,7 +104,25 @@ export default function ProductDetailPage() {
|
|||||||
toast('商品不存在或暂未开放');
|
toast('商品不存在或暂未开放');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setProduct(normalizeFulfillmentFlags(p));
|
const normalized = normalizeFulfillmentFlags(p);
|
||||||
|
setProduct(normalized);
|
||||||
|
const attrs = p.specAttrs ?? [];
|
||||||
|
const skus = p.skus ?? [];
|
||||||
|
const def =
|
||||||
|
skus.find((s) => s.id === p.defaultSkuId) ||
|
||||||
|
skus.find((s) => s.isDefault && s.status === 'ON_SALE') ||
|
||||||
|
skus.find((s) => s.status === 'ON_SALE') ||
|
||||||
|
skus[0];
|
||||||
|
if (def && attrs.length) {
|
||||||
|
const next: Record<string, string> = {};
|
||||||
|
for (const attr of attrs) {
|
||||||
|
const hit = attr.values.find((v) => def.specValueIds.includes(v.id));
|
||||||
|
if (hit) next[attr.id] = hit.id;
|
||||||
|
}
|
||||||
|
setSelected(next);
|
||||||
|
} else {
|
||||||
|
setSelected({});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setProduct(null);
|
setProduct(null);
|
||||||
@@ -84,20 +134,53 @@ export default function ProductDetailPage() {
|
|||||||
loadProduct();
|
loadProduct();
|
||||||
}, [loadProduct]);
|
}, [loadProduct]);
|
||||||
|
|
||||||
// 登录后返回详情须带 token 重拉,否则白名单商品会一直空白
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
loadProduct();
|
loadProduct();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const attrs = product?.specAttrs ?? [];
|
||||||
|
const skus = product?.skus ?? [];
|
||||||
|
const specEnabled = !!(product?.specEnabled && attrs.length > 0);
|
||||||
|
const activeSku = useMemo(() => {
|
||||||
|
if (!product) return undefined;
|
||||||
|
if (!specEnabled) {
|
||||||
|
return (
|
||||||
|
skus.find((s) => s.id === product.defaultSkuId) ||
|
||||||
|
skus.find((s) => s.isDefault) ||
|
||||||
|
skus.find((s) => s.status === 'ON_SALE') ||
|
||||||
|
skus[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return findSku(skus, selected, attrs);
|
||||||
|
}, [product, specEnabled, skus, selected, attrs]);
|
||||||
|
|
||||||
|
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
|
||||||
|
const displayBenefit = activeSku
|
||||||
|
? Number(activeSku.benefitAmount)
|
||||||
|
: Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0);
|
||||||
|
const fulfillment = activeSku
|
||||||
|
? {
|
||||||
|
allowOnlinePurchase: activeSku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: activeSku.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: activeSku.allowOnSitePickup,
|
||||||
|
}
|
||||||
|
: product;
|
||||||
|
const carouselImages = (() => {
|
||||||
|
const base = getProductCarouselImages(product);
|
||||||
|
const skuImg = activeSku?.imageUrl?.trim();
|
||||||
|
if (!skuImg) return base;
|
||||||
|
return [skuImg, ...base.filter((url) => url !== skuImg)];
|
||||||
|
})();
|
||||||
|
|
||||||
const sharePayload = useMemo(
|
const sharePayload = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSceneSharePayload('productDetail', {
|
buildSceneSharePayload('productDetail', {
|
||||||
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));
|
||||||
@@ -118,9 +201,23 @@ export default function ProductDetailPage() {
|
|||||||
Taro.switchTab({ url: '/pages/home/index' });
|
Taro.switchTab({ url: '/pages/home/index' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureSkuSelected(): string | null {
|
||||||
|
if (!specEnabled) return activeSku?.id ?? product?.defaultSkuId ?? null;
|
||||||
|
if (!activeSku || activeSku.status !== 'ON_SALE') {
|
||||||
|
toast('请选择完整规格');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return activeSku.id;
|
||||||
|
}
|
||||||
|
|
||||||
async function goBuy() {
|
async function goBuy() {
|
||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
|
const skuId = ensureSkuSelected();
|
||||||
|
if (specEnabled && !skuId) return;
|
||||||
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 2;
|
||||||
|
const qs = [`productId=${productId}`, `qty=${qty}`];
|
||||||
|
if (skuId) qs.push(`skuId=${skuId}`);
|
||||||
|
const returnPath = `/pages/order-confirm/index?${qs.join('&')}`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
@@ -132,7 +229,12 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
async function goOnSitePickup() {
|
async function goOnSitePickup() {
|
||||||
if (!productId) return;
|
if (!productId) return;
|
||||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
const skuId = ensureSkuSelected();
|
||||||
|
if (specEnabled && !skuId) return;
|
||||||
|
const qty = activeSku?.saleUnit === 'BOX' ? 1 : 1;
|
||||||
|
const qs = [`productId=${productId}`, `qty=${qty}`];
|
||||||
|
if (skuId) qs.push(`skuId=${skuId}`);
|
||||||
|
const returnPath = `/pages/order-confirm-pickup/index?${qs.join('&')}`;
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
goLogin(returnPath);
|
goLogin(returnPath);
|
||||||
return;
|
return;
|
||||||
@@ -151,10 +253,8 @@ export default function ProductDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowOnline = canBuyOnline(product);
|
const allowOnline = canBuyOnline(fulfillment ?? {});
|
||||||
const allowOnSite = canPickupOnSite(product);
|
const allowOnSite = canPickupOnSite(fulfillment ?? {});
|
||||||
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
|
|
||||||
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 ?? [];
|
||||||
@@ -177,12 +277,45 @@ export default function ProductDetailPage() {
|
|||||||
<View className="product-detail-info">
|
<View className="product-detail-info">
|
||||||
<View className="product-detail-price">
|
<View className="product-detail-price">
|
||||||
<Text className="product-detail-price-symbol">¥</Text>
|
<Text className="product-detail-price-symbol">¥</Text>
|
||||||
<Text className="product-detail-price-value">{Number(product.price).toFixed(2)}</Text>
|
<Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="product-detail-name">{product.name}</Text>
|
<Text className="product-detail-name">{product.name}</Text>
|
||||||
{product.subtitle ? (
|
{product.subtitle ? (
|
||||||
<Text className="product-detail-subtitle">{product.subtitle}</Text>
|
<Text className="product-detail-subtitle">{product.subtitle}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeSku?.specText ? (
|
||||||
|
<Text className="product-detail-subtitle">{activeSku.specText}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{specEnabled ? (
|
||||||
|
<View className="product-detail-specs">
|
||||||
|
{attrs.map((attr) => (
|
||||||
|
<View key={attr.id} className="product-detail-spec-row">
|
||||||
|
<Text className="product-detail-spec-label">{attr.name}</Text>
|
||||||
|
<View className="product-detail-spec-chips">
|
||||||
|
{attr.values.map((val) => {
|
||||||
|
const active = selected[attr.id] === val.id;
|
||||||
|
const available = isValueAvailable(skus, attrs, selected, attr.id, val.id);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={val.id}
|
||||||
|
className={`product-detail-spec-chip${active ? ' is-active' : ''}${
|
||||||
|
available ? '' : ' is-disabled'
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!available) return;
|
||||||
|
setSelected((prev) => ({ ...prev, [attr.id]: val.id }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="product-detail-spec-chip-text">{val.name}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View className="product-detail-promo">
|
<View className="product-detail-promo">
|
||||||
<View className="product-detail-promo-glow" />
|
<View className="product-detail-promo-glow" />
|
||||||
@@ -192,7 +325,7 @@ export default function ProductDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
<View className="product-detail-promo-title">
|
<View className="product-detail-promo-title">
|
||||||
<Text>买杜康美酒 · 享全城好客礼遇</Text>
|
<Text>买杜康美酒 · 享全城好客礼遇</Text>
|
||||||
<BenefitFigure value={String(benefit)} size="sm" className="product-detail-promo-amount" />
|
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text className="product-detail-promo-desc">
|
<Text className="product-detail-promo-desc">
|
||||||
|
|||||||
@@ -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%);
|
||||||
@@ -375,3 +376,53 @@
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-specs {
|
||||||
|
margin: 16px 0 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-on-surface-variant, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip.is-active {
|
||||||
|
background: rgba(166, 29, 36, 0.08);
|
||||||
|
border-color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip.is-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-spec-chip.is-active .product-detail-spec-chip-text {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# 杜康好客 · v3.5.4 商品规格(SPU + SKU)
|
||||||
|
|
||||||
|
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
|
||||||
|
> 目标:商品详情页内可选销售规格;价格、权益、69 码、履约、上下架按 SKU;箱装按瓶当量起购与物流。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
| # | 项 | 交付 |
|
||||||
|
|---|----|------|
|
||||||
|
| 1 | 数据模型 | `common_product_item` 升级为 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
|
||||||
|
| 2 | 存量迁移 | 每商品 1 个默认瓶装 SKU;SQL:`prisma/migrate-product-sku-v354.sql`;脚本:`prisma/backfill-product-skus.ts` |
|
||||||
|
| 3 | Catalog | 列表附加 `specEnabled`/`saleUnit`;详情附加 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
|
||||||
|
| 4 | Trade | preview/create 可选 `skuId`;无 SKU 回落 SPU;有可售 SKU 用默认/唯一 |
|
||||||
|
| 5 | Admin | `PUT /admin/products/:id/specs`、`PUT .../skus`;商品抽屉加宽;规格弹窗填写;每规格主图 |
|
||||||
|
| 6 | mini-user | 详情规格 chips;选规格切主图;确认页带 `skuId`;箱装数量文案 |
|
||||||
|
| 7 | 代下单 | HQ / 合伙人可选规格 SKU |
|
||||||
|
| 8 | 权益文案 | 「好客权益券」→「好客权益」;首页角标「享{amount}好客权益」(去掉门店 icon) |
|
||||||
|
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可填;每规格独立 69 码 |
|
||||||
|
| 10 | 规格主图 | `common_product_sku.image_url` 可选;空则 C 端回落商品封面 |
|
||||||
|
|
||||||
|
**不做**:库存、把现有酒祖 10/15/20 合并为一个 SPU。
|
||||||
|
|
||||||
|
## 兼容规则(线上)
|
||||||
|
|
||||||
|
- 不改 `/api/v1` 前缀;**只增字段,不删旧字段语义**。
|
||||||
|
- 旧客户端不传 `skuId`:
|
||||||
|
- 有 ≥1 个可售 SKU → 用默认(或第一个)可售 SKU
|
||||||
|
- **没有任何可售 SKU(未回填)→ 回落 SPU 字段,照常下单**(禁止再报「商品暂无可售规格」)
|
||||||
|
- 传入 `skuId`(新客户端选规格)→ 按该 SKU 校验。
|
||||||
|
- 旧 admin `PUT /admin/products/:id` 不传规格时:仅同步**唯一**默认 SKU(多规格时不同步,防误改);不同步覆盖已生成的 DK 码。
|
||||||
|
- SPU 上 `sku_code` / `barcode_69` 去掉唯一约束,唯一下沉到 `common_product_sku`;SPU 列保留为默认 SKU 冗余。
|
||||||
|
- 订单 `sku_id` 可空;无规格快照走商品 69 码 / 价格。
|
||||||
|
|
||||||
|
## 发版一并执行
|
||||||
|
|
||||||
|
按顺序在**目标环境库**执行(先测试后生产)。本机 `npx ts-node …` 只改本地 `.env` 库,不会自动打线上。
|
||||||
|
|
||||||
|
1. **规格表** `server/dukang-api/prisma/migrate-product-sku-v354.sql`
|
||||||
|
建 spec/sku 表,订单加 `sku_id` / `sale_unit` / `bottles_per_unit`。
|
||||||
|
2. **回填默认 SKU**
|
||||||
|
`cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts`
|
||||||
|
尚无 SKU 的商品各建 1 条默认瓶装 SKU(码用 DK)。
|
||||||
|
3. **SKU 主图列** `server/dukang-api/prisma/migrate-sku-image.sql`
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `common_product_sku`
|
||||||
|
ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`;
|
||||||
|
```
|
||||||
|
4. **SKU 码改 DK**(生产须在服务器用 `.env.production` 的 `DATABASE_URL`)
|
||||||
|
`npx dotenv -e .env.production -- npx ts-node prisma/rewrite-sku-codes-dk.ts`
|
||||||
|
已是 `DK`+数字的不动;`JZ-10` / `QX-001` 等会改。**不改 69 码、订单、价格。**
|
||||||
|
5. **发票品类列**(同批次)`server/dukang-api/prisma/migrate-invoice-category.sql`
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `user_invoice`
|
||||||
|
ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`;
|
||||||
|
```
|
||||||
|
6. 发布 API + mini-user + admin-web。
|
||||||
|
**此时不要给商品加第二规格**,等新小程序上线后再配「单瓶 / 整箱」。
|
||||||
|
|
||||||
|
## 起购与物流
|
||||||
|
|
||||||
|
- `bottleQty = quantity × bottlesPerUnit`
|
||||||
|
- 城市起购阈值仍是瓶;箱装 SKU(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6。
|
||||||
|
- 大单拦截、承运商 `goodsNum` 使用瓶当量。
|
||||||
|
|
||||||
|
## 关键表摘要
|
||||||
|
|
||||||
|
- `common_product_spec_attr` / `common_product_spec_value`
|
||||||
|
- `common_product_sku`(`sale_unit`=`BOTTLE|BOX`,`bottles_per_unit`,`image_url` 可选)
|
||||||
|
- `common_product_sku_spec`
|
||||||
|
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
|
||||||
|
- `user_invoice.invoice_category`:`LIQUOR` 酒水类 / `CATERING` 餐饮类;票种 C 端固定增值税普通发票
|
||||||
|
|
||||||
|
## SKU 码与 69 码
|
||||||
|
|
||||||
|
- SKU 码:服务端生成 `DK000001` 起,后台只展示。
|
||||||
|
- 每个规格必须填写**互不相同**的 69 码(全局不可与其它商品 SKU 冲突)。
|
||||||
|
- 总部「规格与 SKU」点「填写」弹窗编辑 69 码 / 价 / 履约 / 主图。
|
||||||
|
|
||||||
|
## 权益与小程序 UI(同批次)
|
||||||
|
|
||||||
|
| 位置 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| 门店详情等 | 「好客权益券」→「好客权益」 |
|
||||||
|
| 首页商品角标 `CouponBadge` | 纯文案 **「享{amount}好客权益」**,无门店 icon |
|
||||||
|
| 商品详情 | 规格区与「好客权益」说明之间加大间距 |
|
||||||
|
| 待支付 / 收银台 | 顶部 logo 使用 `public/images/logo2.png`(杜康印章) |
|
||||||
|
| 门店详情 | 「使用规则」四字红色,放在「门店详情」**上面**;门店详情正文限高,超出上下滚动 |
|
||||||
|
| 发票申请 | 票种写死增值税普通发票;类型选酒水类/餐饮类;申请备注;抬头邮箱必填打 `*` |
|
||||||
|
|
||||||
|
其它页 `BenefitFigure`(我的/权益/核销等)仍保留门店核销图标,仅首页角标去 icon。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 未配规格:旧小程序/H5/代下单路径与现网一致(无 SKU 也能下单)
|
||||||
|
- [ ] 多规格:详情选规格后价格/权益/履约/主图变化
|
||||||
|
- [ ] 整箱 SKU:数量 1 过起购;订单快照 `bottles_per_unit=6`
|
||||||
|
- [ ] `GET /catalog/products/:id` 仍含 `id/name/price/spec/skuCode/...`
|
||||||
|
- [ ] 小程序无「好客权益券」字样;首页角标为「享{金额}好客权益」且无门店 icon
|
||||||
|
- [ ] 后台 SKU 码为 DK 开头且不可手填;每规格独立 69 码与主图
|
||||||
|
- [ ] 门店详情「使用规则」红色且在门店详情上方;门店详情超长可滚动
|
||||||
|
- [ ] 待支付页顶部为 logo2 印章
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
calcOrderBoxCount,
|
calcOrderBoxCount,
|
||||||
shouldHoldAutoCourierDispatch,
|
shouldHoldAutoCourierDispatch,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
|
toBottleQuantity,
|
||||||
|
toMinSaleQuantity,
|
||||||
validateBusinessHours,
|
validateBusinessHours,
|
||||||
formatBusinessHours,
|
formatBusinessHours,
|
||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
@@ -47,6 +49,30 @@ describe('validateMinPurchase', () => {
|
|||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('box SKU: 1 box (6 bottles) satisfies local and cross mins', () => {
|
||||||
|
const opts = { bottlesPerUnit: 6, saleUnit: 'BOX' as const };
|
||||||
|
expect(validateMinPurchase('LOCAL', 1, 2, 6, opts).ok).toBe(true);
|
||||||
|
expect(validateMinPurchase('CROSS_CITY', 1, 2, 6, opts).ok).toBe(true);
|
||||||
|
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6, opts).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('box SKU message uses 箱', () => {
|
||||||
|
const r = validateMinPurchase('LOCAL', 0, 2, 6, { bottlesPerUnit: 6, saleUnit: 'BOX' });
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.message).toContain('箱');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toBottleQuantity / toMinSaleQuantity', () => {
|
||||||
|
it('converts sale qty to bottles', () => {
|
||||||
|
expect(toBottleQuantity(2, 1)).toBe(2);
|
||||||
|
expect(toBottleQuantity(1, 6)).toBe(6);
|
||||||
|
expect(toMinSaleQuantity(2, 1)).toBe(2);
|
||||||
|
expect(toMinSaleQuantity(2, 6)).toBe(1);
|
||||||
|
expect(toMinSaleQuantity(6, 6)).toBe(1);
|
||||||
|
expect(toMinSaleQuantity(7, 6)).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateBusinessHours', () => {
|
describe('validateBusinessHours', () => {
|
||||||
|
|||||||
@@ -7,27 +7,61 @@ export function calcBenefitAmount(product: ProductPricing): number {
|
|||||||
return product.benefitAmount ?? product.price;
|
return product.benefitAmount ?? product.price;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductSaleUnit = 'BOTTLE' | 'BOX';
|
||||||
|
|
||||||
|
/** 销售数量 → 瓶当量 */
|
||||||
|
export function toBottleQuantity(quantity: number, bottlesPerUnit = 1): number {
|
||||||
|
const qty = Math.floor(Number(quantity) || 0);
|
||||||
|
const per = Math.floor(Number(bottlesPerUnit) || 0);
|
||||||
|
if (qty <= 0 || per <= 0) return 0;
|
||||||
|
return qty * per;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 城市起购瓶数 → 最少销售单位数量(向上取整)
|
||||||
|
* 例:同城 2 瓶、整箱 6 瓶/箱 → minSaleQty = 1
|
||||||
|
*/
|
||||||
|
export function toMinSaleQuantity(minBottleQty: number, bottlesPerUnit = 1): number {
|
||||||
|
const minBottles = Math.floor(Number(minBottleQty) || 0);
|
||||||
|
const per = Math.floor(Number(bottlesPerUnit) || 0);
|
||||||
|
if (minBottles <= 0) return 1;
|
||||||
|
if (per <= 0) return minBottles;
|
||||||
|
return Math.max(1, Math.ceil(minBottles / per));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saleUnitLabel(saleUnit: ProductSaleUnit | string | null | undefined): string {
|
||||||
|
return saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
|
}
|
||||||
|
|
||||||
export function validateMinPurchase(
|
export function validateMinPurchase(
|
||||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
|
||||||
quantity: number,
|
quantity: number,
|
||||||
localMinQty: number,
|
localMinQty: number,
|
||||||
crossMinQty: number,
|
crossMinQty: number,
|
||||||
|
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
|
||||||
): { ok: boolean; message?: string } {
|
): { ok: boolean; message?: string } {
|
||||||
|
const bottlesPerUnit = options?.bottlesPerUnit ?? 1;
|
||||||
|
const saleUnit = options?.saleUnit ?? (bottlesPerUnit > 1 ? 'BOX' : 'BOTTLE');
|
||||||
|
const bottleQty = toBottleQuantity(quantity, bottlesPerUnit);
|
||||||
|
const unit = saleUnitLabel(saleUnit);
|
||||||
|
|
||||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||||
const min = localMinQty > 0 ? localMinQty : 2;
|
const minBottles = localMinQty > 0 ? localMinQty : 2;
|
||||||
if (quantity < min) {
|
if (bottleQty < minBottles) {
|
||||||
return { ok: false, message: `现场提货至少购买 ${min} 瓶` };
|
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||||
|
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
|
||||||
if (quantity < min) {
|
if (bottleQty < minBottles) {
|
||||||
|
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
message:
|
||||||
deliveryType === 'LOCAL'
|
deliveryType === 'LOCAL'
|
||||||
? `同城配送至少购买 ${min} 瓶`
|
? `同城配送至少购买 ${minSale}${unit}`
|
||||||
: `跨城配送至少购买 ${min} 瓶(1箱)`,
|
: `跨城配送至少购买 ${minSale}${unit}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -8,16 +8,50 @@ export interface CityDto {
|
|||||||
maxPartnerCommissionRate?: number;
|
maxPartnerCommissionRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProductSaleUnit = 'BOTTLE' | 'BOX';
|
||||||
|
|
||||||
|
export interface ProductSpecValueDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSpecAttrDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
values: ProductSpecValueDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSkuDto {
|
||||||
|
id: string;
|
||||||
|
/** 规格值 id 列表(与 specAttrs 轴顺序对应) */
|
||||||
|
specValueIds: string[];
|
||||||
|
specText: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount: number;
|
||||||
|
status: string;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
saleUnit: ProductSaleUnit;
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
/** 规格主图;无则详情页回落商品封面/轮播 */
|
||||||
|
imageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductDto {
|
export interface ProductDto {
|
||||||
id: string;
|
id: string;
|
||||||
skuCode: string;
|
skuCode: string;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
|
/** 拍平价:可售 SKU 最低价 / 默认 SKU 价 */
|
||||||
price: number;
|
price: number;
|
||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
benefitDisplay?: number;
|
benefitDisplay?: number;
|
||||||
aromaType: string;
|
aromaType: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** 规格文案(默认/展示 SKU) */
|
||||||
|
spec?: string;
|
||||||
/** 封面图(common_resource COVER / cover_resource_id) */
|
/** 封面图(common_resource COVER / cover_resource_id) */
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
|
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
|
||||||
@@ -25,12 +59,21 @@ export interface ProductDto {
|
|||||||
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
/** 详情长图(bizType=DETAIL 或 detailContent JSON) */
|
||||||
detailImageUrls?: string[];
|
detailImageUrls?: string[];
|
||||||
detailContent?: ProductDetailContentDto | null;
|
detailContent?: ProductDetailContentDto | null;
|
||||||
/** 是否允许现场取货下单 */
|
/** 是否允许现场取货下单(拍平自展示 SKU) */
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
/** 是否允许配送到址(同城线上购买) */
|
/** 是否允许配送到址(同城线上购买) */
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
/** 是否允许跨城配送 */
|
/** 是否允许跨城配送 */
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
|
/** v3.5.4:是否配置了销售规格(多 SKU) */
|
||||||
|
specEnabled?: boolean;
|
||||||
|
/** 列表轻量:展示 SKU 销售单位 */
|
||||||
|
saleUnit?: ProductSaleUnit;
|
||||||
|
/** 详情:规格轴 */
|
||||||
|
specAttrs?: ProductSpecAttrDto[];
|
||||||
|
/** 详情:可售 SKU(含 OFF_SALE 供前端灰置时可带 status) */
|
||||||
|
skus?: ProductSkuDto[];
|
||||||
|
defaultSkuId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductDetailFeatureDto {
|
export interface ProductDetailFeatureDto {
|
||||||
@@ -68,3 +111,38 @@ export interface ProductDetailTemplateDto {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin 保存规格轴 */
|
||||||
|
export interface AdminProductSpecValueInput {
|
||||||
|
/** 已有值 id;新建可省略 */
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductSpecAttrInput {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
values: AdminProductSpecValueInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductSkuInput {
|
||||||
|
id?: string;
|
||||||
|
/** 规格值 id;无规格时为空数组 */
|
||||||
|
specValueIds?: string[];
|
||||||
|
/** 忽略;服务端自动生成 DK 码 */
|
||||||
|
skuCode?: string;
|
||||||
|
barcode69: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount?: number;
|
||||||
|
status?: string;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
saleUnit?: ProductSaleUnit;
|
||||||
|
bottlesPerUnit?: number;
|
||||||
|
isDefault?: boolean;
|
||||||
|
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;
|
||||||
|
|||||||
@@ -114,6 +114,22 @@ export type PartnerProxyOrderProductOption = {
|
|||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
|
specEnabled?: boolean;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
|
defaultSkuId?: string;
|
||||||
|
skus?: Array<{
|
||||||
|
id: string;
|
||||||
|
specText: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount: number;
|
||||||
|
status: string;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
saleUnit: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderPromoOption = {
|
export type PartnerProxyOrderPromoOption = {
|
||||||
@@ -145,6 +161,7 @@ export type PartnerProxyOrderPreviewRequest = {
|
|||||||
storeId?: string;
|
storeId?: string;
|
||||||
receiverCity?: string;
|
receiverCity?: string;
|
||||||
receiverDistrict?: string;
|
receiverDistrict?: string;
|
||||||
|
skuId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderPreviewResult = {
|
export type PartnerProxyOrderPreviewResult = {
|
||||||
@@ -153,6 +170,11 @@ export type PartnerProxyOrderPreviewResult = {
|
|||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
|
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
skuId?: string;
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit?: number;
|
||||||
|
bottleQuantity?: number;
|
||||||
|
minQuantity?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerProxyOrderCreateRequest = {
|
export type PartnerProxyOrderCreateRequest = {
|
||||||
@@ -169,6 +191,7 @@ export type PartnerProxyOrderCreateRequest = {
|
|||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
promoCodeId?: string;
|
promoCodeId?: string;
|
||||||
|
skuId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
|
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
|
||||||
@@ -203,4 +226,5 @@ export type HqProxyOrderCreateRequest = {
|
|||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
promoCodeId?: string;
|
promoCodeId?: string;
|
||||||
|
skuId?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
|
After Width: | Height: | Size: 921 KiB |
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* v3.5.4:为尚无 SKU 的商品回填默认 SKU(与 migrate-product-sku-v354.sql 一致)
|
||||||
|
* 用法:cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
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() {
|
||||||
|
const products = await prisma.commonProductItem.findMany({
|
||||||
|
include: { skus: { select: { id: true }, take: 1 } },
|
||||||
|
});
|
||||||
|
let created = 0;
|
||||||
|
for (const p of products) {
|
||||||
|
if (p.skus.length > 0) continue;
|
||||||
|
const skuCode = RE.test(p.skuCode) ? p.skuCode : await nextDkCode();
|
||||||
|
await prisma.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId: p.id,
|
||||||
|
skuCode,
|
||||||
|
barcode69: p.barcode69,
|
||||||
|
specKey: '',
|
||||||
|
specText: p.spec,
|
||||||
|
price: p.price,
|
||||||
|
benefitAmount: p.benefitAmount,
|
||||||
|
status: p.status,
|
||||||
|
allowOnSitePickup: p.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: p.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: p.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (p.skuCode !== skuCode) {
|
||||||
|
await prisma.commonProductItem.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { skuCode },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
created += 1;
|
||||||
|
console.log(`created default sku for product ${p.id} ${skuCode}`);
|
||||||
|
}
|
||||||
|
console.log(`done, created=${created}, scanned=${products.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,97 @@
|
|||||||
|
-- v3.5.4 商品规格:SPU + SKU
|
||||||
|
-- 1) 新建规格/SKU 表 2) 订单快照列 3) SPU 去掉 sku/barcode 唯一 4) 存量默认 SKU 回填
|
||||||
|
|
||||||
|
-- 销售单位枚举(Prisma 用字符串枚举映射;MySQL 用 VARCHAR,此处仅建表)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_spec_attr (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(32) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_spec_attr_product (product_id, sort_order),
|
||||||
|
CONSTRAINT fk_spec_attr_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_spec_value (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
attr_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_spec_value_attr (attr_id, sort_order),
|
||||||
|
CONSTRAINT fk_spec_value_attr FOREIGN KEY (attr_id) REFERENCES common_product_spec_attr (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_sku (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
sku_code VARCHAR(32) NOT NULL,
|
||||||
|
barcode_69 VARCHAR(32) NOT NULL,
|
||||||
|
spec_key VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
spec_text VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
price DECIMAL(10, 2) NOT NULL,
|
||||||
|
benefit_amount DECIMAL(10, 2) NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
allow_on_site_pickup TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
allow_online_purchase TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
allow_cross_city_delivery TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE',
|
||||||
|
bottles_per_unit INT NOT NULL DEFAULT 1,
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_product_sku_code (sku_code),
|
||||||
|
UNIQUE KEY uk_product_sku_barcode (barcode_69),
|
||||||
|
UNIQUE KEY uk_product_sku_spec_key (product_id, spec_key),
|
||||||
|
KEY idx_product_sku_status (product_id, status),
|
||||||
|
KEY idx_product_sku_default (product_id, is_default),
|
||||||
|
CONSTRAINT fk_product_sku_product FOREIGN KEY (product_id) REFERENCES common_product_item (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS common_product_sku_spec (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
sku_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
value_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_sku_spec (sku_id, value_id),
|
||||||
|
KEY idx_sku_spec_value (value_id),
|
||||||
|
CONSTRAINT fk_sku_spec_sku FOREIGN KEY (sku_id) REFERENCES common_product_sku (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_sku_spec_value FOREIGN KEY (value_id) REFERENCES common_product_spec_value (id) ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 订单快照列
|
||||||
|
ALTER TABLE user_order
|
||||||
|
ADD COLUMN sku_id BIGINT UNSIGNED NULL AFTER product_id,
|
||||||
|
ADD COLUMN sale_unit VARCHAR(16) NOT NULL DEFAULT 'BOTTLE' AFTER quantity,
|
||||||
|
ADD COLUMN bottles_per_unit INT NOT NULL DEFAULT 1 AFTER sale_unit;
|
||||||
|
|
||||||
|
ALTER TABLE user_order
|
||||||
|
ADD KEY idx_order_sku_id (sku_id);
|
||||||
|
|
||||||
|
-- SPU:去掉 sku_code / barcode_69 唯一(保留普通索引);若索引名不同可手工调整
|
||||||
|
ALTER TABLE common_product_item DROP INDEX sku_code;
|
||||||
|
ALTER TABLE common_product_item DROP INDEX barcode_69;
|
||||||
|
ALTER TABLE common_product_item ADD INDEX idx_product_item_sku_code (sku_code);
|
||||||
|
ALTER TABLE common_product_item ADD INDEX idx_product_item_barcode_69 (barcode_69);
|
||||||
|
|
||||||
|
-- 存量商品回填默认 SKU(无规格,瓶装)
|
||||||
|
INSERT INTO common_product_sku (
|
||||||
|
product_id, sku_code, barcode_69, spec_key, spec_text, price, benefit_amount, status,
|
||||||
|
allow_on_site_pickup, allow_online_purchase, allow_cross_city_delivery,
|
||||||
|
sale_unit, bottles_per_unit, is_default, sort_order, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.id, p.sku_code, p.barcode_69, '', COALESCE(p.spec, ''), p.price, p.benefit_amount, p.status,
|
||||||
|
p.allow_on_site_pickup, p.allow_online_purchase, p.allow_cross_city_delivery,
|
||||||
|
'BOTTLE', 1, 1, 0, NOW(3), NOW(3)
|
||||||
|
FROM common_product_item p
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM common_product_sku s WHERE s.product_id = p.id
|
||||||
|
);
|
||||||
@@ -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
|
||||||
@@ -164,6 +170,12 @@ enum ProductStatus {
|
|||||||
OFF_SALE
|
OFF_SALE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SKU 销售单位:瓶 / 箱(起购与物流按瓶当量)
|
||||||
|
enum ProductSaleUnit {
|
||||||
|
BOTTLE
|
||||||
|
BOX
|
||||||
|
}
|
||||||
|
|
||||||
enum DetailTemplateStatus {
|
enum DetailTemplateStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
DISABLED
|
DISABLED
|
||||||
@@ -791,10 +803,13 @@ model DevPlanTaskDispatch {
|
|||||||
@@map("dev_plan_task_dispatch")
|
@@map("dev_plan_task_dispatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 商品 SPU(详情页实体);价格/码/履约等列为默认 SKU 冗余,供旧客户端拍平读取
|
||||||
model CommonProductItem {
|
model CommonProductItem {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
/// 默认 SKU 冗余;唯一约束已下放到 common_product_sku
|
||||||
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
|
skuCode String @map("sku_code") @db.VarChar(32)
|
||||||
|
/// 默认 SKU 冗余;唯一约束已下放到 common_product_sku
|
||||||
|
barcode69 String @map("barcode_69") @db.VarChar(32)
|
||||||
name String @db.VarChar(128)
|
name String @db.VarChar(128)
|
||||||
subtitle String? @db.VarChar(256)
|
subtitle String? @db.VarChar(256)
|
||||||
aromaType AromaType @map("aroma_type")
|
aromaType AromaType @map("aroma_type")
|
||||||
@@ -818,11 +833,96 @@ model CommonProductItem {
|
|||||||
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||||
orders Order[]
|
orders Order[]
|
||||||
visibilityPhones CommonProductVisibilityPhone[]
|
visibilityPhones CommonProductVisibilityPhone[]
|
||||||
|
specAttrs CommonProductSpecAttr[]
|
||||||
|
skus CommonProductSku[]
|
||||||
|
|
||||||
|
@@index([skuCode])
|
||||||
|
@@index([barcode69])
|
||||||
@@index([status, aromaType])
|
@@index([status, aromaType])
|
||||||
@@map("common_product_item")
|
@@map("common_product_item")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 销售规格轴(如「包装」)
|
||||||
|
model CommonProductSpecAttr {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||||
|
name String @db.VarChar(32)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
values CommonProductSpecValue[]
|
||||||
|
|
||||||
|
@@index([productId, sortOrder])
|
||||||
|
@@map("common_product_spec_attr")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规格值(如「单瓶」「整箱6瓶」)
|
||||||
|
model CommonProductSpecValue {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
attrId BigInt @map("attr_id") @db.UnsignedBigInt
|
||||||
|
name String @db.VarChar(64)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
attr CommonProductSpecAttr @relation(fields: [attrId], references: [id], onDelete: Cascade)
|
||||||
|
skuSpecs CommonProductSkuSpec[]
|
||||||
|
|
||||||
|
@@index([attrId, sortOrder])
|
||||||
|
@@map("common_product_spec_value")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 可售 SKU(规格组合)
|
||||||
|
model CommonProductSku {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||||
|
skuCode String @unique @map("sku_code") @db.VarChar(32)
|
||||||
|
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
|
||||||
|
/// 规格值 id 按 attr.sortOrder 拼接,无规格为 ""
|
||||||
|
specKey String @default("") @map("spec_key") @db.VarChar(128)
|
||||||
|
specText String @default("") @map("spec_text") @db.VarChar(128)
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
benefitAmount Decimal? @map("benefit_amount") @db.Decimal(10, 2)
|
||||||
|
status ProductStatus @default(DRAFT)
|
||||||
|
allowOnSitePickup Boolean @default(false) @map("allow_on_site_pickup")
|
||||||
|
allowOnlinePurchase Boolean @default(true) @map("allow_online_purchase")
|
||||||
|
allowCrossCityDelivery Boolean @default(true) @map("allow_cross_city_delivery")
|
||||||
|
saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit")
|
||||||
|
/// 每销售单位对应瓶数(瓶=1,箱默认=6)
|
||||||
|
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
|
||||||
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
/// 规格主图;空则回落商品封面
|
||||||
|
imageUrl String? @map("image_url") @db.VarChar(512)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
skuSpecs CommonProductSkuSpec[]
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
|
@@unique([productId, specKey])
|
||||||
|
@@index([productId, status])
|
||||||
|
@@index([productId, isDefault])
|
||||||
|
@@map("common_product_sku")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SKU ↔ 规格值
|
||||||
|
model CommonProductSkuSpec {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
skuId BigInt @map("sku_id") @db.UnsignedBigInt
|
||||||
|
valueId BigInt @map("value_id") @db.UnsignedBigInt
|
||||||
|
|
||||||
|
sku CommonProductSku @relation(fields: [skuId], references: [id], onDelete: Cascade)
|
||||||
|
value CommonProductSpecValue @relation(fields: [valueId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([skuId, valueId])
|
||||||
|
@@index([valueId])
|
||||||
|
@@map("common_product_sku_spec")
|
||||||
|
}
|
||||||
|
|
||||||
/// Product visibility whitelist phones (match by bound phone)
|
/// Product visibility whitelist phones (match by bound phone)
|
||||||
model CommonProductVisibilityPhone {
|
model CommonProductVisibilityPhone {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
@@ -1495,11 +1595,16 @@ model Order {
|
|||||||
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
|
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
|
||||||
channelSource String? @map("channel_source") @db.VarChar(128)
|
channelSource String? @map("channel_source") @db.VarChar(128)
|
||||||
productId BigInt @map("product_id") @db.UnsignedBigInt
|
productId BigInt @map("product_id") @db.UnsignedBigInt
|
||||||
|
/// v3.5.4 起可空;历史单无 sku 快照时仍用 product 冗余字段展示
|
||||||
|
skuId BigInt? @map("sku_id") @db.UnsignedBigInt
|
||||||
barcode69 String @map("barcode_69") @db.VarChar(32)
|
barcode69 String @map("barcode_69") @db.VarChar(32)
|
||||||
productName String @map("product_name") @db.VarChar(128)
|
productName String @map("product_name") @db.VarChar(128)
|
||||||
productSpec String @map("product_spec") @db.VarChar(128)
|
productSpec String @map("product_spec") @db.VarChar(128)
|
||||||
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
|
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
|
||||||
|
/// 下单数量(销售单位:瓶或箱)
|
||||||
quantity Int
|
quantity Int
|
||||||
|
saleUnit ProductSaleUnit @default(BOTTLE) @map("sale_unit")
|
||||||
|
bottlesPerUnit Int @default(1) @map("bottles_per_unit")
|
||||||
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
|
listUnitPrice Decimal @map("list_unit_price") @db.Decimal(10, 2)
|
||||||
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
|
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
|
||||||
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
|
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
|
||||||
@@ -1553,6 +1658,7 @@ model Order {
|
|||||||
reshipments Order[] @relation("OrderReshipment")
|
reshipments Order[] @relation("OrderReshipment")
|
||||||
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
|
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
|
||||||
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
|
product CommonProductItem @relation(fields: [productId], references: [id], onDelete: Restrict)
|
||||||
|
sku CommonProductSku? @relation(fields: [skuId], references: [id], onDelete: Restrict)
|
||||||
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
|
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
|
||||||
delivery OrderDelivery?
|
delivery OrderDelivery?
|
||||||
benefitCoupon BenefitCoupon?
|
benefitCoupon BenefitCoupon?
|
||||||
@@ -1561,6 +1667,7 @@ model Order {
|
|||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@index([cityId, createdAt])
|
@@index([cityId, createdAt])
|
||||||
@@index([productId])
|
@@index([productId])
|
||||||
|
@@index([skuId])
|
||||||
@@index([barcode69])
|
@@index([barcode69])
|
||||||
@@index([payExternalNo])
|
@@index([payExternalNo])
|
||||||
@@index([ipCity])
|
@@index([ipCity])
|
||||||
@@ -1579,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)
|
||||||
|
|||||||
@@ -412,6 +412,26 @@ async function main() {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId: product.id,
|
||||||
|
skuCode: def.skuCode,
|
||||||
|
barcode69: `69000000000${i + 1}`,
|
||||||
|
specKey: '',
|
||||||
|
specText: '500ml | 53度',
|
||||||
|
price: def.price,
|
||||||
|
benefitAmount: def.price,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
|
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
|
||||||
|
|
||||||
await prisma.commonProductItem.update({
|
await prisma.commonProductItem.update({
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import {
|
|||||||
normalizeTestPhone,
|
normalizeTestPhone,
|
||||||
} from '../../common/test-whitelist/test-whitelist.service';
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
|
||||||
|
import {
|
||||||
|
flattenSkuOntoProduct,
|
||||||
|
listMinOnSalePrice,
|
||||||
|
mapSkuDto,
|
||||||
|
mapSpecAttrsDto,
|
||||||
|
pickDisplaySku,
|
||||||
|
resolveOrderSale,
|
||||||
|
} from './product-sku.util';
|
||||||
|
|
||||||
export type CatalogViewer = {
|
export type CatalogViewer = {
|
||||||
/** C 端用户手机号;无则无法看到白名单商品 */
|
/** C 端用户手机号;无则无法看到白名单商品 */
|
||||||
@@ -81,6 +89,8 @@ export class CatalogService {
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
|
skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||||
|
specAttrs: { select: { id: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,12 +116,25 @@ export class CatalogService {
|
|||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
visible.map((p) => {
|
visible.map((p) => {
|
||||||
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
const media = mapProductMedia(p, resourceMap.get(p.id.toString()) ?? []);
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = p;
|
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = p;
|
||||||
|
const display = pickDisplaySku(skus);
|
||||||
|
const flat = flattenSkuOntoProduct(p, display);
|
||||||
|
const minPrice = listMinOnSalePrice(skus);
|
||||||
|
const price = minPrice ?? flat.price;
|
||||||
|
const benefitAmount = flat.benefitAmount;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
benefitAmount: p.benefitAmount ?? p.price,
|
skuCode: flat.skuCode,
|
||||||
price: Number(p.price),
|
barcode69: undefined,
|
||||||
benefitDisplay: Number(p.benefitAmount ?? p.price),
|
spec: flat.spec,
|
||||||
|
price,
|
||||||
|
benefitAmount,
|
||||||
|
benefitDisplay: benefitAmount,
|
||||||
|
allowOnSitePickup: flat.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
|
saleUnit: flat.saleUnit,
|
||||||
...media,
|
...media,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -123,6 +146,14 @@ export class CatalogService {
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
|
skus: {
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
include: { skuSpecs: { select: { valueId: true } } },
|
||||||
|
},
|
||||||
|
specAttrs: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { values: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
@@ -144,30 +175,57 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const media = mapProductMedia(product, resources);
|
const media = mapProductMedia(product, resources);
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = product;
|
const { visibilityWhitelistEnabled: _wl, skus, specAttrs, ...rest } = product;
|
||||||
|
const display = pickDisplaySku(skus);
|
||||||
|
const flat = flattenSkuOntoProduct(product, display);
|
||||||
|
const defaultSku = skus.find((s) => s.isDefault) ?? display;
|
||||||
|
const cSkus = skus.map((s) => {
|
||||||
|
const dto = mapSkuDto(s);
|
||||||
|
const { skuCode: _c, barcode69: _b, sortOrder: _o, ...publicSku } = dto;
|
||||||
|
return publicSku;
|
||||||
|
});
|
||||||
|
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...rest,
|
...rest,
|
||||||
benefitAmount: product.benefitAmount ?? product.price,
|
skuCode: flat.skuCode,
|
||||||
price: Number(product.price),
|
spec: flat.spec,
|
||||||
|
price: flat.price,
|
||||||
|
benefitAmount: flat.benefitAmount,
|
||||||
|
benefitDisplay: flat.benefitAmount,
|
||||||
|
allowOnSitePickup: flat.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: flat.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: flat.allowCrossCityDelivery,
|
||||||
|
saleUnit: flat.saleUnit,
|
||||||
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
|
specAttrs: mapSpecAttrsDto(specAttrs),
|
||||||
|
skus: cSkus,
|
||||||
|
defaultSkuId: defaultSku?.id.toString(),
|
||||||
...media,
|
...media,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
|
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;无 SKU 时回落 SPU 字段 */
|
||||||
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
|
async assertPurchasable(
|
||||||
|
productId: bigint,
|
||||||
|
viewerPhone?: string | null,
|
||||||
|
skuId?: string | null,
|
||||||
|
options?: { bypassWhitelist?: boolean },
|
||||||
|
) {
|
||||||
const product = await this.prisma.commonProductItem.findUnique({
|
const product = await this.prisma.commonProductItem.findUnique({
|
||||||
where: { id: productId },
|
where: { id: productId },
|
||||||
|
include: { skus: true },
|
||||||
});
|
});
|
||||||
if (!product || product.status !== 'ON_SALE') {
|
if (!product || product.status !== 'ON_SALE') {
|
||||||
throw new BadRequestException('商品不可购买');
|
throw new BadRequestException('商品不可购买');
|
||||||
}
|
}
|
||||||
if (product.visibilityWhitelistEnabled) {
|
if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) {
|
||||||
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new BadRequestException('该商品暂不对当前账号开放');
|
throw new BadRequestException('该商品暂不对当前账号开放');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return product;
|
const sale = resolveOrderSale(product, product.skus ?? [], skuId);
|
||||||
|
return { product, sale };
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
async resolveUserPhone(userId: bigint): Promise<string | null> {
|
||||||
@@ -177,4 +235,12 @@ export class CatalogService {
|
|||||||
});
|
});
|
||||||
return user?.phone ?? null;
|
return user?.phone ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSkusForProduct(productId: bigint) {
|
||||||
|
return this.prisma.commonProductSku.findMany({
|
||||||
|
where: { productId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
include: { skuSpecs: { select: { valueId: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import type { CommonProductItem, CommonProductSku, ProductSaleUnit } from '@prisma/client';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { BOTTLES_PER_BOX } from '@dukang/domain';
|
||||||
|
|
||||||
|
export type SkuWithSpecs = CommonProductSku & {
|
||||||
|
skuSpecs?: Array<{ valueId: bigint; value?: { id: bigint; name: string; attrId: bigint } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpecAttrWithValues = {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
values: Array<{ id: bigint; name: string; sortOrder: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 可售 SKU */
|
||||||
|
export function isSkuOnSale(sku: { status: string }): boolean {
|
||||||
|
return sku.status === 'ON_SALE';
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSpecText(
|
||||||
|
valueIds: bigint[],
|
||||||
|
valueNameById: Map<string, string>,
|
||||||
|
): string {
|
||||||
|
if (!valueIds.length) return '';
|
||||||
|
return valueIds
|
||||||
|
.map((id) => valueNameById.get(id.toString()) ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 现网兼容:
|
||||||
|
* - 不传 skuId:有可售 SKU 用默认/唯一可售;一个都没有则回落 SPU 字段(未回填也能下单)
|
||||||
|
* - 传入 skuId:按 SKU 校验(新客户端选规格)
|
||||||
|
* 不在旧接口上因「无 SKU」或「多规格未选」打断现网下单。
|
||||||
|
*/
|
||||||
|
export function resolveOrderSale(
|
||||||
|
product: CommonProductItem,
|
||||||
|
skus: CommonProductSku[],
|
||||||
|
skuId?: string | null,
|
||||||
|
): OrderSaleSnapshot {
|
||||||
|
if (skuId) {
|
||||||
|
const found = skus.find((s) => s.id.toString() === String(skuId));
|
||||||
|
if (!found) throw new BadRequestException('规格不存在');
|
||||||
|
if (!isSkuOnSale(found)) throw new BadRequestException('该规格暂不可购买');
|
||||||
|
return saleSnapshotFromSku(found, product);
|
||||||
|
}
|
||||||
|
const onSale = skus.filter(isSkuOnSale);
|
||||||
|
if (onSale.length >= 1) {
|
||||||
|
const picked = onSale.find((s) => s.isDefault) ?? onSale[0];
|
||||||
|
return saleSnapshotFromSku(picked, product);
|
||||||
|
}
|
||||||
|
return saleSnapshotFromProduct(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表/拍平:优先默认可售 → 最低价可售 → 默认任意 → 任意 */
|
||||||
|
export function pickDisplaySku(skus: CommonProductSku[]): CommonProductSku | null {
|
||||||
|
if (!skus.length) return null;
|
||||||
|
const onSale = skus.filter(isSkuOnSale);
|
||||||
|
const pool = onSale.length ? onSale : skus;
|
||||||
|
const def = pool.find((s) => s.isDefault);
|
||||||
|
if (def) return def;
|
||||||
|
return [...pool].sort((a, b) => Number(a.price) - Number(b.price) || a.sortOrder - b.sortOrder)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMinOnSalePrice(skus: CommonProductSku[]): number | null {
|
||||||
|
const onSale = skus.filter(isSkuOnSale);
|
||||||
|
if (!onSale.length) return null;
|
||||||
|
return Math.min(...onSale.map((s) => Number(s.price)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenSkuOntoProduct(
|
||||||
|
product: CommonProductItem,
|
||||||
|
sku: CommonProductSku | null,
|
||||||
|
): {
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
spec: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount: number;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
saleUnit: ProductSaleUnit;
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
} {
|
||||||
|
if (!sku) {
|
||||||
|
return {
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
spec: product.spec,
|
||||||
|
price: Number(product.price),
|
||||||
|
benefitAmount: Number(product.benefitAmount ?? product.price),
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skuCode: sku.skuCode,
|
||||||
|
barcode69: sku.barcode69,
|
||||||
|
spec: sku.specText || product.spec,
|
||||||
|
price: Number(sku.price),
|
||||||
|
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapSkuDto(sku: SkuWithSpecs) {
|
||||||
|
const specValueIds = (sku.skuSpecs ?? []).map((row) => row.valueId.toString());
|
||||||
|
return {
|
||||||
|
id: sku.id.toString(),
|
||||||
|
specValueIds,
|
||||||
|
specText: sku.specText,
|
||||||
|
price: Number(sku.price),
|
||||||
|
benefitAmount: Number(sku.benefitAmount ?? sku.price),
|
||||||
|
status: sku.status,
|
||||||
|
allowOnlinePurchase: sku.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: sku.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: sku.allowOnSitePickup,
|
||||||
|
saleUnit: sku.saleUnit as 'BOTTLE' | 'BOX',
|
||||||
|
bottlesPerUnit: sku.bottlesPerUnit,
|
||||||
|
isDefault: sku.isDefault,
|
||||||
|
skuCode: sku.skuCode,
|
||||||
|
barcode69: sku.barcode69,
|
||||||
|
imageUrl: (sku as { imageUrl?: string | null }).imageUrl || undefined,
|
||||||
|
sortOrder: sku.sortOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapSpecAttrsDto(attrs: SpecAttrWithValues[]) {
|
||||||
|
return attrs
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((attr) => ({
|
||||||
|
id: attr.id.toString(),
|
||||||
|
name: attr.name,
|
||||||
|
values: attr.values
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((v) => ({ id: v.id.toString(), name: v.name })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncDefaultSkuFieldsFromProduct(product: {
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
spec: string;
|
||||||
|
price: unknown;
|
||||||
|
benefitAmount: unknown;
|
||||||
|
status: string;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price as never,
|
||||||
|
benefitAmount: product.benefitAmount as never,
|
||||||
|
status: product.status as never,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -76,11 +76,12 @@ export class FulfillmentService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
|
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
|
||||||
if (shouldHoldAutoCourierDispatch(order.quantity)) {
|
const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1);
|
||||||
const boxes = calcOrderBoxCount(order.quantity);
|
if (shouldHoldAutoCourierDispatch(bottleQty)) {
|
||||||
|
const boxes = calcOrderBoxCount(bottleQty);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity}×${order.bottlesPerUnit} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
||||||
);
|
);
|
||||||
await this.prisma.order.update({
|
await this.prisma.order.update({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
@@ -97,7 +98,7 @@ export class FulfillmentService {
|
|||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
refId: order.id,
|
refId: order.id,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
errorMessage: `大单拦截:${bottleQty}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
||||||
0,
|
0,
|
||||||
512,
|
512,
|
||||||
),
|
),
|
||||||
@@ -155,7 +156,7 @@ export class FulfillmentService {
|
|||||||
addressDetail: order.receiverAddress,
|
addressDetail: order.receiverAddress,
|
||||||
},
|
},
|
||||||
goodsName: order.productName,
|
goodsName: order.productName,
|
||||||
goodsNum: order.quantity,
|
goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1),
|
||||||
weight: 2,
|
weight: 2,
|
||||||
payMode: CourierPayMode.SENDER,
|
payMode: CourierPayMode.SENDER,
|
||||||
remark: `仓配自动发货 ${order.orderNo}`,
|
remark: `仓配自动发货 ${order.orderNo}`,
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ 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 { AdminProductsService } from './admin-products.service';
|
import { AdminProductsService } from './admin-products.service';
|
||||||
import { AdminProductsQueryDto } from './dto/admin-query.dto';
|
import { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||||
import { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
SaveProductSkusDto,
|
||||||
|
SaveProductSpecsDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
@Controller('admin/products')
|
@Controller('admin/products')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
@@ -33,6 +38,18 @@ export class AdminProductsController {
|
|||||||
return this.service.update(BigInt(id), dto);
|
return this.service.update(BigInt(id), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/specs')
|
||||||
|
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
|
||||||
|
saveSpecs(@Param('id') id: string, @Body() dto: SaveProductSpecsDto) {
|
||||||
|
return this.service.saveSpecs(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/skus')
|
||||||
|
@HqOperation({ action: HqOperationAction.PRODUCT_UPDATE, refType: 'PRODUCT', refIdParam: 'id', includeBody: true })
|
||||||
|
saveSkus(@Param('id') id: string, @Body() dto: SaveProductSkusDto) {
|
||||||
|
return this.service.saveSkus(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
|
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { BOTTLES_PER_BOX } from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
|
import { groupResourcesByProductId, mapProductMedia } from '../catalog/catalog.mapper';
|
||||||
|
import {
|
||||||
|
buildSpecKey,
|
||||||
|
buildSpecText,
|
||||||
|
mapSkuDto,
|
||||||
|
mapSpecAttrsDto,
|
||||||
|
} from '../catalog/product-sku.util';
|
||||||
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
import type { AdminProductsQueryDto } from './dto/admin-query.dto';
|
||||||
import type { CreateProductDto, UpdateProductDto } from './dto/admin-mutate.dto';
|
import type {
|
||||||
|
CreateProductDto,
|
||||||
|
SaveProductSkusDto,
|
||||||
|
SaveProductSpecsDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/admin-mutate.dto';
|
||||||
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
import { TestWhitelistService } from '../../common/test-whitelist/test-whitelist.service';
|
||||||
|
|
||||||
function normalizePhones(phones?: string[]): string[] {
|
function normalizePhones(phones?: string[]): string[] {
|
||||||
@@ -27,6 +39,13 @@ 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_VALUES = 10;
|
||||||
|
|
||||||
/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */
|
/** 解析履约开关:无线上则强制不可跨城;须至少线上或现场之一 */
|
||||||
function resolveFulfillmentFlags(input: {
|
function resolveFulfillmentFlags(input: {
|
||||||
@@ -67,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'];
|
||||||
|
|
||||||
@@ -80,6 +111,26 @@ export class AdminProductsService {
|
|||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||||
|
skus: {
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
skuCode: true,
|
||||||
|
barcode69: true,
|
||||||
|
specText: true,
|
||||||
|
price: true,
|
||||||
|
benefitAmount: true,
|
||||||
|
status: true,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: true,
|
||||||
|
allowOnSitePickup: true,
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
saleUnit: true,
|
||||||
|
bottlesPerUnit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
specAttrs: { select: { id: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.commonProductItem.count({ where }),
|
this.prisma.commonProductItem.count({ where }),
|
||||||
@@ -99,8 +150,103 @@ 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) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
|
items: items.map((p) => {
|
||||||
|
const { skus, specAttrs, ...rest } = p;
|
||||||
|
const formatted = this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []);
|
||||||
|
const prices = skus.length
|
||||||
|
? skus.map((s) => Number(s.price))
|
||||||
|
: [Number(p.price)];
|
||||||
|
const priceMin = Math.min(...prices);
|
||||||
|
const priceMax = Math.max(...prices);
|
||||||
|
const pid = p.id.toString();
|
||||||
|
const bySku = soldByProductSku.get(pid);
|
||||||
|
let soldBottles = 0;
|
||||||
|
if (bySku) {
|
||||||
|
for (const n of bySku.values()) soldBottles += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skuRows =
|
||||||
|
skus.length > 0
|
||||||
|
? skus.map((s) => ({
|
||||||
|
id: s.id.toString(),
|
||||||
|
skuCode: s.skuCode,
|
||||||
|
barcode69: s.barcode69,
|
||||||
|
specText: s.specText,
|
||||||
|
price: Number(s.price),
|
||||||
|
benefitAmount: Number(s.benefitAmount ?? s.price),
|
||||||
|
status: s.status,
|
||||||
|
isDefault: s.isDefault,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
allowOnSitePickup: s.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
||||||
|
saleUnit: s.saleUnit,
|
||||||
|
bottlesPerUnit: s.bottlesPerUnit,
|
||||||
|
soldBottles: bySku?.get(s.id.toString()) ?? 0,
|
||||||
|
virtual: false,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: `virtual-${pid}`,
|
||||||
|
skuCode: p.skuCode,
|
||||||
|
barcode69: p.barcode69,
|
||||||
|
specText: p.spec,
|
||||||
|
price: Number(p.price),
|
||||||
|
benefitAmount: Number(p.benefitAmount ?? p.price),
|
||||||
|
status: p.status,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
allowOnSitePickup: p.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: p.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: p.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE' as const,
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
soldBottles,
|
||||||
|
virtual: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...formatted,
|
||||||
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
|
skuCount: skus.length,
|
||||||
|
priceMin,
|
||||||
|
priceMax,
|
||||||
|
soldBottles,
|
||||||
|
skus: skuRows,
|
||||||
|
};
|
||||||
|
}),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -113,6 +259,14 @@ export class AdminProductsService {
|
|||||||
include: {
|
include: {
|
||||||
coverResource: true,
|
coverResource: true,
|
||||||
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
|
||||||
|
skus: {
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
include: { skuSpecs: { select: { valueId: true } } },
|
||||||
|
},
|
||||||
|
specAttrs: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { values: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!product) throw new NotFoundException('商品不存在');
|
if (!product) throw new NotFoundException('商品不存在');
|
||||||
@@ -127,11 +281,18 @@ export class AdminProductsService {
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return serializeBigInt(this.formatProduct(product, resources));
|
const { skus, specAttrs, ...rest } = product;
|
||||||
|
return serializeBigInt({
|
||||||
|
...this.formatProduct(rest as never, resources),
|
||||||
|
specEnabled: specAttrs.length > 0 || skus.length > 1,
|
||||||
|
specAttrs: mapSpecAttrsDto(specAttrs),
|
||||||
|
skus: skus.map(mapSkuDto),
|
||||||
|
defaultSkuId: skus.find((s) => s.isDefault)?.id.toString() ?? skus[0]?.id.toString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateProductDto) {
|
async create(dto: CreateProductDto) {
|
||||||
const barcodeExists = await this.prisma.commonProductItem.findFirst({
|
const barcodeExists = await this.prisma.commonProductSku.findFirst({
|
||||||
where: { barcode69: dto.barcode69 },
|
where: { barcode69: dto.barcode69 },
|
||||||
});
|
});
|
||||||
if (barcodeExists) throw new BadRequestException('69 码已存在');
|
if (barcodeExists) throw new BadRequestException('69 码已存在');
|
||||||
@@ -147,7 +308,6 @@ export class AdminProductsService {
|
|||||||
if (whitelistEnabled) {
|
if (whitelistEnabled) {
|
||||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||||
}
|
}
|
||||||
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
|
|
||||||
void phones;
|
void phones;
|
||||||
|
|
||||||
const product = await this.createWithGeneratedSku({
|
const product = await this.createWithGeneratedSku({
|
||||||
@@ -169,6 +329,26 @@ export class AdminProductsService {
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.prisma.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId: product.id,
|
||||||
|
skuCode: product.skuCode,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specKey: '',
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price,
|
||||||
|
benefitAmount: product.benefitAmount,
|
||||||
|
status: product.status,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
await this.syncCover(product.id, dto.coverUrl);
|
await this.syncCover(product.id, dto.coverUrl);
|
||||||
}
|
}
|
||||||
@@ -184,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,
|
||||||
@@ -230,7 +414,9 @@ export class AdminProductsService {
|
|||||||
if (dto.visibilityWhitelistEnabled) {
|
if (dto.visibilityWhitelistEnabled) {
|
||||||
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
|
||||||
}
|
}
|
||||||
// 分实体手机号已废弃;忽略 dto.visibilityPhones
|
|
||||||
|
// 无规格 payload 时:同步默认 SKU(兼容旧 admin 表单)
|
||||||
|
await this.syncDefaultSkuFromProduct(id);
|
||||||
|
|
||||||
if (dto.coverUrl) {
|
if (dto.coverUrl) {
|
||||||
await this.syncCover(id, dto.coverUrl);
|
await this.syncCover(id, dto.coverUrl);
|
||||||
@@ -243,6 +429,305 @@ export class AdminProductsService {
|
|||||||
return this.detail(id);
|
return this.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async saveSpecs(productId: bigint, dto: SaveProductSpecsDto) {
|
||||||
|
const product = await this.prisma.commonProductItem.findUnique({ where: { id: productId } });
|
||||||
|
if (!product) throw new NotFoundException('商品不存在');
|
||||||
|
|
||||||
|
const attrs = dto.attrs ?? [];
|
||||||
|
if (attrs.length > MAX_SPEC_ATTRS) {
|
||||||
|
throw new BadRequestException(`规格轴最多 ${MAX_SPEC_ATTRS} 个`);
|
||||||
|
}
|
||||||
|
for (const attr of attrs) {
|
||||||
|
if ((attr.values?.length ?? 0) > MAX_SPEC_VALUES) {
|
||||||
|
throw new BadRequestException(`每个规格轴最多 ${MAX_SPEC_VALUES} 个值`);
|
||||||
|
}
|
||||||
|
if (!attr.values?.length) {
|
||||||
|
throw new BadRequestException(`规格「${attr.name}」至少需要一个值`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAttrs = await this.prisma.commonProductSpecAttr.findMany({
|
||||||
|
where: { productId },
|
||||||
|
include: { values: true },
|
||||||
|
});
|
||||||
|
const existingValueIds = existingAttrs.flatMap((a) => a.values.map((v) => v.id));
|
||||||
|
const keepValueIds = new Set(
|
||||||
|
attrs.flatMap((a) => (a.values ?? []).map((v) => v.id).filter(Boolean) as string[]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const vid of existingValueIds) {
|
||||||
|
if (keepValueIds.has(vid.toString())) continue;
|
||||||
|
const used = await this.prisma.commonProductSkuSpec.count({ where: { valueId: vid } });
|
||||||
|
if (used > 0) {
|
||||||
|
const orderCount = await this.prisma.order.count({
|
||||||
|
where: { sku: { skuSpecs: { some: { valueId: vid } } } },
|
||||||
|
});
|
||||||
|
if (orderCount > 0) {
|
||||||
|
throw new BadRequestException('有订单关联的规格值不可删除');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 删除未保留的轴(级联值);先清 sku_spec 中将被删的 value
|
||||||
|
const keepAttrIds = new Set(attrs.map((a) => a.id).filter(Boolean) as string[]);
|
||||||
|
for (const old of existingAttrs) {
|
||||||
|
if (!keepAttrIds.has(old.id.toString())) {
|
||||||
|
await tx.commonProductSkuSpec.deleteMany({
|
||||||
|
where: { valueId: { in: old.values.map((v) => v.id) } },
|
||||||
|
});
|
||||||
|
await tx.commonProductSpecAttr.delete({ where: { id: old.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let ai = 0; ai < attrs.length; ai++) {
|
||||||
|
const attr = attrs[ai];
|
||||||
|
let attrId: bigint;
|
||||||
|
if (attr.id) {
|
||||||
|
attrId = BigInt(attr.id);
|
||||||
|
await tx.commonProductSpecAttr.update({
|
||||||
|
where: { id: attrId },
|
||||||
|
data: { name: attr.name.trim(), sortOrder: attr.sortOrder ?? ai },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const created = await tx.commonProductSpecAttr.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
name: attr.name.trim(),
|
||||||
|
sortOrder: attr.sortOrder ?? ai,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
attrId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValues = await tx.commonProductSpecValue.findMany({ where: { attrId } });
|
||||||
|
const keepVids = new Set((attr.values ?? []).map((v) => v.id).filter(Boolean) as string[]);
|
||||||
|
for (const ov of oldValues) {
|
||||||
|
if (!keepVids.has(ov.id.toString())) {
|
||||||
|
await tx.commonProductSkuSpec.deleteMany({ where: { valueId: ov.id } });
|
||||||
|
await tx.commonProductSpecValue.delete({ where: { id: ov.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let vi = 0; vi < (attr.values ?? []).length; vi++) {
|
||||||
|
const val = attr.values[vi];
|
||||||
|
if (val.id) {
|
||||||
|
await tx.commonProductSpecValue.update({
|
||||||
|
where: { id: BigInt(val.id) },
|
||||||
|
data: { name: val.name.trim(), sortOrder: val.sortOrder ?? vi },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tx.commonProductSpecValue.create({
|
||||||
|
data: {
|
||||||
|
attrId,
|
||||||
|
name: val.name.trim(),
|
||||||
|
sortOrder: val.sortOrder ?? vi,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.detail(productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSkus(productId: bigint, dto: SaveProductSkusDto) {
|
||||||
|
const product = await this.prisma.commonProductItem.findUnique({
|
||||||
|
where: { id: productId },
|
||||||
|
include: {
|
||||||
|
specAttrs: { include: { values: true }, orderBy: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!product) throw new NotFoundException('商品不存在');
|
||||||
|
|
||||||
|
const rows = dto.skus ?? [];
|
||||||
|
if (!rows.length) throw new BadRequestException('至少保留一个 SKU');
|
||||||
|
|
||||||
|
const valueNameById = new Map<string, string>();
|
||||||
|
const attrValueSets = product.specAttrs.map((a) => {
|
||||||
|
const set = new Set(a.values.map((v) => v.id.toString()));
|
||||||
|
for (const v of a.values) valueNameById.set(v.id.toString(), v.name);
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
|
||||||
|
let defaultCount = 0;
|
||||||
|
const seenKeys = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const flags = resolveFulfillmentFlags({
|
||||||
|
allowOnlinePurchase: row.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: row.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: row.allowOnSitePickup,
|
||||||
|
defaults: {
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void flags;
|
||||||
|
if (row.isDefault) defaultCount += 1;
|
||||||
|
const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id));
|
||||||
|
if (attrValueSets.length) {
|
||||||
|
const idSet = new Set(valueIds.map((id) => id.toString()));
|
||||||
|
if (idSet.size !== valueIds.length) {
|
||||||
|
throw new BadRequestException('规格值不可重复');
|
||||||
|
}
|
||||||
|
for (const set of attrValueSets) {
|
||||||
|
const hits = [...set].filter((id) => idSet.has(id));
|
||||||
|
if (hits.length !== 1) {
|
||||||
|
throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (valueIds.length !== attrValueSets.length) {
|
||||||
|
throw new BadRequestException('每个 SKU 须选择每个规格轴的一个值');
|
||||||
|
}
|
||||||
|
} else if (valueIds.length) {
|
||||||
|
throw new BadRequestException('商品尚未配置规格轴');
|
||||||
|
}
|
||||||
|
const key = buildSpecKey(valueIds);
|
||||||
|
if (seenKeys.has(key)) throw new BadRequestException('存在重复规格组合');
|
||||||
|
seenKeys.add(key);
|
||||||
|
}
|
||||||
|
if (defaultCount !== 1) {
|
||||||
|
throw new BadRequestException('请且仅指定一个默认 SKU');
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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) => {
|
||||||
|
const existing = await tx.commonProductSku.findMany({ where: { productId } });
|
||||||
|
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) {
|
||||||
|
if (keepIds.has(old.id.toString())) continue;
|
||||||
|
const orderCount = await tx.order.count({ where: { skuId: old.id } });
|
||||||
|
if (orderCount > 0) {
|
||||||
|
throw new BadRequestException(`SKU ${old.skuCode} 已有订单,无法删除`);
|
||||||
|
}
|
||||||
|
await tx.commonProductSkuSpec.deleteMany({ where: { skuId: old.id } });
|
||||||
|
await tx.commonProductSku.delete({ where: { id: old.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const valueIds = (row.specValueIds ?? []).map((id) => BigInt(id));
|
||||||
|
const specKey = buildSpecKey(valueIds);
|
||||||
|
const specText =
|
||||||
|
buildSpecText(valueIds, valueNameById) || product.spec || row.barcode69;
|
||||||
|
const flags = resolveFulfillmentFlags({
|
||||||
|
allowOnlinePurchase: row.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: row.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: row.allowOnSitePickup,
|
||||||
|
defaults: {
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const saleUnit = row.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE';
|
||||||
|
const bottlesPerUnit =
|
||||||
|
row.bottlesPerUnit && row.bottlesPerUnit > 0
|
||||||
|
? Math.floor(row.bottlesPerUnit)
|
||||||
|
: saleUnit === 'BOX'
|
||||||
|
? BOTTLES_PER_BOX
|
||||||
|
: 1;
|
||||||
|
const status = (row.status ?? 'DRAFT') as 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
|
||||||
|
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;
|
||||||
|
if (row.id) {
|
||||||
|
skuId = BigInt(row.id);
|
||||||
|
await tx.commonProductSku.update({
|
||||||
|
where: { id: skuId },
|
||||||
|
data: {
|
||||||
|
skuCode,
|
||||||
|
barcode69: row.barcode69.trim(),
|
||||||
|
specKey,
|
||||||
|
specText,
|
||||||
|
price: row.price,
|
||||||
|
benefitAmount: row.benefitAmount ?? row.price,
|
||||||
|
status,
|
||||||
|
...flags,
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit,
|
||||||
|
isDefault: !!row.isDefault,
|
||||||
|
sortOrder: row.sortOrder ?? i,
|
||||||
|
imageUrl: row.imageUrl?.trim() || null,
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
await tx.commonProductSkuSpec.deleteMany({ where: { skuId } });
|
||||||
|
} else {
|
||||||
|
const created = await tx.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
skuCode,
|
||||||
|
barcode69: row.barcode69.trim(),
|
||||||
|
specKey,
|
||||||
|
specText,
|
||||||
|
price: row.price,
|
||||||
|
benefitAmount: row.benefitAmount ?? row.price,
|
||||||
|
status,
|
||||||
|
...flags,
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit,
|
||||||
|
isDefault: !!row.isDefault,
|
||||||
|
sortOrder: row.sortOrder ?? i,
|
||||||
|
imageUrl: row.imageUrl?.trim() || null,
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
skuId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valueIds.length) {
|
||||||
|
await tx.commonProductSkuSpec.createMany({
|
||||||
|
data: valueIds.map((valueId) => ({ skuId, valueId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.isDefault) {
|
||||||
|
await tx.commonProductItem.update({
|
||||||
|
where: { id: productId },
|
||||||
|
data: {
|
||||||
|
skuCode,
|
||||||
|
barcode69: row.barcode69.trim(),
|
||||||
|
spec: specText,
|
||||||
|
price: row.price,
|
||||||
|
benefitAmount: row.benefitAmount ?? row.price,
|
||||||
|
...flags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.detail(productId);
|
||||||
|
}
|
||||||
|
|
||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
const product = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
const product = await this.prisma.commonProductItem.findUnique({ where: { id } });
|
||||||
if (!product) throw new NotFoundException('商品不存在');
|
if (!product) throw new NotFoundException('商品不存在');
|
||||||
@@ -260,21 +745,98 @@ export class AdminProductsService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncDefaultSkuFromProduct(productId: bigint) {
|
||||||
|
const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: productId } });
|
||||||
|
const defaultSku =
|
||||||
|
(await this.prisma.commonProductSku.findFirst({
|
||||||
|
where: { productId, isDefault: true },
|
||||||
|
})) ??
|
||||||
|
(await this.prisma.commonProductSku.findFirst({
|
||||||
|
where: { productId },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!defaultSku) {
|
||||||
|
await this.prisma.commonProductSku.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
skuCode: await this.nextAutoSkuCode(),
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specKey: '',
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price,
|
||||||
|
benefitAmount: product.benefitAmount,
|
||||||
|
status: product.status,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅当该商品只有 1 个 SKU 时,旧表单字段同步到默认 SKU(避免误改多规格)
|
||||||
|
const skuCount = await this.prisma.commonProductSku.count({ where: { productId } });
|
||||||
|
if (skuCount > 1) return;
|
||||||
|
|
||||||
|
await this.prisma.commonProductSku.update({
|
||||||
|
where: { id: defaultSku.id },
|
||||||
|
data: {
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
specText: product.spec,
|
||||||
|
price: product.price,
|
||||||
|
benefitAmount: product.benefitAmount,
|
||||||
|
status: product.status,
|
||||||
|
allowOnSitePickup: product.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 生成 DK + 6 位自增 SKU,冲突重试 */
|
/** 生成 DK + 6 位自增 SKU,冲突重试 */
|
||||||
private async nextAutoSkuCode(): Promise<string> {
|
private async nextAutoSkuCode(): Promise<string> {
|
||||||
const rows = await this.prisma.commonProductItem.findMany({
|
const [code] = await this.allocateAutoSkuCodesTx(this.prisma, 1);
|
||||||
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
return code;
|
||||||
select: { skuCode: true },
|
}
|
||||||
});
|
|
||||||
|
private async nextAutoSkuCodeTx(
|
||||||
|
db: Prisma.TransactionClient | PrismaService,
|
||||||
|
): 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([
|
||||||
|
db.commonProductItem.findMany({
|
||||||
|
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
db.commonProductSku.findMany({
|
||||||
|
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
|
||||||
|
select: { skuCode: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
let maxSeq = 0;
|
let maxSeq = 0;
|
||||||
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
|
for (const row of [...fromItem, ...fromSku]) {
|
||||||
for (const row of rows) {
|
const m = SKU_AUTO_RE.exec(row.skuCode);
|
||||||
const m = 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(
|
||||||
@@ -300,16 +862,6 @@ export class AdminProductsService {
|
|||||||
throw new BadRequestException('SKU 生成失败,请重试');
|
throw new BadRequestException('SKU 生成失败,请重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncVisibilityPhones(productId: bigint, phones: string[]) {
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
|
||||||
await tx.commonProductVisibilityPhone.deleteMany({ where: { productId } });
|
|
||||||
if (!phones.length) return;
|
|
||||||
await tx.commonProductVisibilityPhone.createMany({
|
|
||||||
data: phones.map((phone) => ({ productId, phone })),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatProduct(
|
private formatProduct(
|
||||||
product: Prisma.CommonProductItemGetPayload<{
|
product: Prisma.CommonProductItemGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
Max,
|
Max,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateStoreStatusDto {
|
export class UpdateStoreStatusDto {
|
||||||
@@ -1302,6 +1304,118 @@ export class UpdateProductDto {
|
|||||||
detailContent?: Record<string, unknown>;
|
detailContent?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AdminSpecValueDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminSpecAttrDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AdminSpecValueDto)
|
||||||
|
values: AdminSpecValueDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveProductSpecsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AdminSpecAttrDto)
|
||||||
|
attrs: AdminSpecAttrDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminSkuRowDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
specValueIds?: string[];
|
||||||
|
|
||||||
|
/** 忽略;服务端自动生成 DK 码 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
skuCode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
barcode69: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
price: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
benefitAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['DRAFT', 'ON_SALE', 'OFF_SALE'])
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['BOTTLE', 'BOX'])
|
||||||
|
saleUnit?: 'BOTTLE' | 'BOX';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
bottlesPerUnit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
/** 规格主图 URL;空则回落商品封面 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
imageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveProductSkusDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AdminSkuRowDto)
|
||||||
|
skus: AdminSkuRowDto[];
|
||||||
|
}
|
||||||
|
|
||||||
class ProductDetailFeatureInputDto {
|
class ProductDetailFeatureInputDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export class HqProxyOrderPreviewDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
productId: string;
|
productId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
skuId?: string;
|
||||||
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -76,6 +80,10 @@ export class HqProxyOrderCreateDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
productId: string;
|
productId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
skuId?: string;
|
||||||
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export class PartnerProxyOrderPreviewDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
productId: string;
|
productId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
skuId?: string;
|
||||||
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
@@ -84,6 +88,10 @@ export class PartnerProxyOrderCreateDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
productId: string;
|
productId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
skuId?: string;
|
||||||
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -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 必填');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
calcBenefitAmount,
|
calcBenefitAmount,
|
||||||
generateOrderNo,
|
generateOrderNo,
|
||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
|
toMinSaleQuantity,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
|
||||||
@@ -37,6 +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 { ProductSaleUnit } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TradeService {
|
export class TradeService {
|
||||||
@@ -62,22 +64,65 @@ export class TradeService {
|
|||||||
|
|
||||||
private readonly logger = new Logger(TradeService.name);
|
private readonly logger = new Logger(TradeService.name);
|
||||||
|
|
||||||
|
private overlayProductWithSale(
|
||||||
|
productDto: Record<string, unknown>,
|
||||||
|
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> {
|
||||||
|
return {
|
||||||
|
...productDto,
|
||||||
|
skuCode: sale.skuCode,
|
||||||
|
spec: sale.specText || productDto.spec,
|
||||||
|
price: Number(sale.price),
|
||||||
|
benefitAmount: Number(sale.benefitAmount ?? sale.price),
|
||||||
|
benefitDisplay: Number(sale.benefitAmount ?? sale.price),
|
||||||
|
allowOnSitePickup: sale.allowOnSitePickup,
|
||||||
|
allowOnlinePurchase: sale.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: sale.allowCrossCityDelivery,
|
||||||
|
saleUnit: sale.saleUnit,
|
||||||
|
bottlesPerUnit: sale.bottlesPerUnit,
|
||||||
|
...(sale.skuId ? { selectedSkuId: sale.skuId.toString() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async preview(
|
async preview(
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
|
body: {
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
addressId?: string;
|
||||||
|
onSitePickup?: boolean;
|
||||||
|
skuId?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
|
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
|
||||||
const product = await this.catalogService.getProduct(BigInt(body.productId), { phone: viewerPhone });
|
const { product: spu, sale } = await this.catalogService.assertPurchasable(
|
||||||
if (!product || product.status !== 'ON_SALE') {
|
BigInt(body.productId),
|
||||||
|
viewerPhone,
|
||||||
|
body.skuId,
|
||||||
|
);
|
||||||
|
const productDto = await this.catalogService.getProduct(spu.id, { phone: viewerPhone });
|
||||||
|
if (!productDto || productDto.status !== 'ON_SALE') {
|
||||||
throw new BadRequestException('商品不可购买');
|
throw new BadRequestException('商品不可购买');
|
||||||
}
|
}
|
||||||
|
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 && !product.allowOnSitePickup) {
|
if (onSitePickup && !sale.allowOnSitePickup) {
|
||||||
throw new BadRequestException('该商品不支持现场取货');
|
throw new BadRequestException('该规格不支持现场取货');
|
||||||
}
|
}
|
||||||
|
|
||||||
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup
|
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup
|
||||||
@@ -95,43 +140,46 @@ 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 = product.allowOnlinePurchase !== false;
|
const allowOnline = sale.allowOnlinePurchase !== false;
|
||||||
const allowCross = product.allowCrossCityDelivery !== false;
|
const allowCross = sale.allowCrossCityDelivery !== false;
|
||||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||||
addressOk = false;
|
addressOk = false;
|
||||||
addressMessage = '该商品不支持线上购买';
|
addressMessage = '该规格不支持线上购买';
|
||||||
} else if (deliveryType === 'CROSS_CITY') {
|
} else if (deliveryType === 'CROSS_CITY') {
|
||||||
if (!allowOnline) {
|
if (!allowOnline) {
|
||||||
addressOk = false;
|
addressOk = false;
|
||||||
addressMessage = '该商品不支持线上购买';
|
addressMessage = '该规格不支持线上购买';
|
||||||
} else if (!allowCross) {
|
} else if (!allowCross) {
|
||||||
addressOk = false;
|
addressOk = false;
|
||||||
addressMessage = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
|
addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: sale.saleUnit },
|
||||||
);
|
);
|
||||||
|
|
||||||
const unitPrice = Number(product.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: product.benefitAmount ? Number(product.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;
|
||||||
const minQty =
|
const minBottleQty =
|
||||||
deliveryType === 'ON_SITE_PICKUP'
|
deliveryType === 'ON_SITE_PICKUP'
|
||||||
? city.localMinQty
|
? city.localMinQty
|
||||||
: deliveryType === 'LOCAL'
|
: deliveryType === 'LOCAL'
|
||||||
? city.localMinQty
|
? city.localMinQty
|
||||||
: city.crossMinQty;
|
: city.crossMinQty;
|
||||||
|
const minQty = toMinSaleQuantity(minBottleQty, bottlesPerUnit);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
product,
|
product,
|
||||||
@@ -143,16 +191,21 @@ export class TradeService {
|
|||||||
payAmount: productAmount,
|
payAmount: productAmount,
|
||||||
benefitAmount: benefitPerUnit * body.quantity,
|
benefitAmount: benefitPerUnit * body.quantity,
|
||||||
city: serializeBigInt(city),
|
city: serializeBigInt(city),
|
||||||
/** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */
|
|
||||||
quantityOk: check.ok,
|
quantityOk: check.ok,
|
||||||
quantityMessage: check.ok ? null : (check.message ?? null),
|
quantityMessage: check.ok ? null : (check.message ?? null),
|
||||||
/** 地址/履约未满足时仍返回预览,供确认页提示换地址;下单接口仍会硬校验 */
|
|
||||||
addressOk,
|
addressOk,
|
||||||
addressMessage,
|
addressMessage,
|
||||||
minQty,
|
minQty,
|
||||||
onSitePickup,
|
onSitePickup,
|
||||||
allowCrossCityDelivery: product.allowCrossCityDelivery !== false,
|
allowCrossCityDelivery: sale.allowCrossCityDelivery !== false,
|
||||||
allowOnlinePurchase: product.allowOnlinePurchase !== false,
|
allowOnlinePurchase: sale.allowOnlinePurchase !== false,
|
||||||
|
skuId: sale.skuId?.toString(),
|
||||||
|
barcode69: sale.barcode69,
|
||||||
|
productSpec: sale.specText,
|
||||||
|
unitPrice,
|
||||||
|
saleUnit: sale.saleUnit,
|
||||||
|
bottlesPerUnit,
|
||||||
|
bottleQuantity: body.quantity * bottlesPerUnit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +217,7 @@ export class TradeService {
|
|||||||
addressId?: string;
|
addressId?: string;
|
||||||
onSitePickup?: boolean;
|
onSitePickup?: boolean;
|
||||||
clientLocation?: unknown;
|
clientLocation?: unknown;
|
||||||
|
skuId?: string;
|
||||||
},
|
},
|
||||||
req: Request,
|
req: Request,
|
||||||
) {
|
) {
|
||||||
@@ -239,12 +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,
|
||||||
barcode69: product.barcode69,
|
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||||
|
barcode69: preview.barcode69 || product.barcode69,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
productSpec: product.spec,
|
productSpec: preview.productSpec || product.spec,
|
||||||
imageResourceId: product.coverResourceId,
|
imageResourceId: product.coverResourceId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
listUnitPrice: product.price,
|
saleUnit: preview.saleUnit,
|
||||||
|
bottlesPerUnit: preview.bottlesPerUnit,
|
||||||
|
listUnitPrice: preview.unitPrice,
|
||||||
listAmount: preview.productAmount,
|
listAmount: preview.productAmount,
|
||||||
productAmount: preview.productAmount,
|
productAmount: preview.productAmount,
|
||||||
receiverName,
|
receiverName,
|
||||||
@@ -300,6 +357,7 @@ export class TradeService {
|
|||||||
extraJson: {
|
extraJson: {
|
||||||
orderId: order.id.toString(),
|
orderId: order.id.toString(),
|
||||||
productId: body.productId,
|
productId: body.productId,
|
||||||
|
skuId: preview.skuId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
onSitePickup,
|
onSitePickup,
|
||||||
},
|
},
|
||||||
@@ -1033,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;
|
||||||
@@ -1041,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('订单不存在');
|
||||||
@@ -1090,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('专用发票仅支持企业抬头');
|
||||||
@@ -1099,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: {
|
||||||
@@ -1107,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,
|
||||||
@@ -1114,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) {
|
||||||
@@ -1207,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;
|
||||||
@@ -1220,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: {
|
||||||
@@ -1503,18 +1567,28 @@ export class TradeService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
const details = await Promise.all(
|
||||||
|
products.map((p) =>
|
||||||
|
this.catalogService.getProduct(BigInt(String(p.id)), { phone: primary.phone }),
|
||||||
|
),
|
||||||
|
);
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
products: products.map((p) => ({
|
products: details.filter(Boolean).map((p) => ({
|
||||||
id: p.id,
|
id: p!.id,
|
||||||
name: p.name,
|
name: p!.name,
|
||||||
spec: p.spec,
|
spec: p!.spec,
|
||||||
price: Number(p.price),
|
price: Number(p!.price),
|
||||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null,
|
||||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null,
|
||||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup,
|
||||||
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
|
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false,
|
||||||
allowCrossCityDelivery:
|
allowCrossCityDelivery:
|
||||||
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
|
(p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false,
|
||||||
|
specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled,
|
||||||
|
saleUnit: (p as { saleUnit?: string } | null)?.saleUnit,
|
||||||
|
skus: (p as { skus?: unknown[] } | null)?.skus ?? [],
|
||||||
|
defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId,
|
||||||
|
specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [],
|
||||||
})),
|
})),
|
||||||
promoCodes,
|
promoCodes,
|
||||||
stores: stores.map((s) => ({
|
stores: stores.map((s) => ({
|
||||||
@@ -1537,13 +1611,25 @@ export class TradeService {
|
|||||||
storeId?: string;
|
storeId?: string;
|
||||||
receiverCity?: string;
|
receiverCity?: string;
|
||||||
receiverDistrict?: string;
|
receiverDistrict?: string;
|
||||||
|
skuId?: string;
|
||||||
},
|
},
|
||||||
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
|
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
|
||||||
) {
|
) {
|
||||||
const product = await this.catalogService.getProduct(BigInt(body.productId), viewer ?? {});
|
const { product: spu, sale } = await this.catalogService.assertPurchasable(
|
||||||
if (!product || product.status !== 'ON_SALE') {
|
BigInt(body.productId),
|
||||||
|
viewer?.phone,
|
||||||
|
body.skuId,
|
||||||
|
{ bypassWhitelist: !!viewer?.bypassWhitelist },
|
||||||
|
);
|
||||||
|
if (spu.status !== 'ON_SALE') {
|
||||||
throw new BadRequestException('商品不可购买');
|
throw new BadRequestException('商品不可购买');
|
||||||
}
|
}
|
||||||
|
// 非 bypass 时再走可见性(与 getProduct 一致)
|
||||||
|
if (!viewer?.bypassWhitelist) {
|
||||||
|
const dto = await this.catalogService.getProduct(spu.id, viewer ?? {});
|
||||||
|
if (!dto) throw new BadRequestException('商品不可购买');
|
||||||
|
}
|
||||||
|
|
||||||
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('暂无开城城市');
|
||||||
|
|
||||||
@@ -1551,8 +1637,8 @@ 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 (!product.allowOnSitePickup) {
|
if (!sale.allowOnSitePickup) {
|
||||||
throw new BadRequestException('该商品不支持现场提货');
|
throw new BadRequestException('该规格不支持现场提货');
|
||||||
}
|
}
|
||||||
deliveryType = 'ON_SITE_PICKUP';
|
deliveryType = 'ON_SITE_PICKUP';
|
||||||
} else {
|
} else {
|
||||||
@@ -1560,34 +1646,36 @@ export class TradeService {
|
|||||||
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
|
||||||
deliveryType = 'CROSS_CITY';
|
deliveryType = 'CROSS_CITY';
|
||||||
}
|
}
|
||||||
const allowOnline = product.allowOnlinePurchase !== false;
|
const allowOnline = sale.allowOnlinePurchase !== false;
|
||||||
const allowCross = product.allowCrossCityDelivery !== false;
|
const allowCross = sale.allowCrossCityDelivery !== false;
|
||||||
if (deliveryType === 'LOCAL' && !allowOnline) {
|
if (deliveryType === 'LOCAL' && !allowOnline) {
|
||||||
throw new BadRequestException('该商品不支持线上购买');
|
throw new BadRequestException('该规格不支持线上购买');
|
||||||
}
|
}
|
||||||
if (deliveryType === 'CROSS_CITY') {
|
if (deliveryType === 'CROSS_CITY') {
|
||||||
if (!allowOnline) {
|
if (!allowOnline) {
|
||||||
throw new BadRequestException('该商品不支持线上购买');
|
throw new BadRequestException('该规格不支持线上购买');
|
||||||
}
|
}
|
||||||
if (!allowCross) {
|
if (!allowCross) {
|
||||||
throw new BadRequestException('该商品不支持跨城配送');
|
throw new BadRequestException('该规格不支持跨城配送');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
|
||||||
const check = validateMinPurchase(
|
const check = validateMinPurchase(
|
||||||
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : '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: sale.saleUnit },
|
||||||
);
|
);
|
||||||
if (!check.ok) throw new BadRequestException(check.message);
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
|
|
||||||
const unitPrice = Number(product.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: product.benefitAmount ? Number(product.benefitAmount) : null,
|
benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1596,6 +1684,16 @@ export class TradeService {
|
|||||||
benefitAmount: benefitPerUnit * body.quantity,
|
benefitAmount: benefitPerUnit * body.quantity,
|
||||||
deliveryType,
|
deliveryType,
|
||||||
unitPrice,
|
unitPrice,
|
||||||
|
skuId: sale.skuId?.toString(),
|
||||||
|
barcode69: sale.barcode69,
|
||||||
|
productSpec: sale.specText,
|
||||||
|
saleUnit: sale.saleUnit,
|
||||||
|
bottlesPerUnit,
|
||||||
|
bottleQuantity: body.quantity * bottlesPerUnit,
|
||||||
|
minQuantity: toMinSaleQuantity(
|
||||||
|
deliveryType === 'CROSS_CITY' ? city.crossMinQty : city.localMinQty,
|
||||||
|
bottlesPerUnit,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1609,6 +1707,7 @@ export class TradeService {
|
|||||||
storeId?: string;
|
storeId?: string;
|
||||||
receiverCity?: string;
|
receiverCity?: string;
|
||||||
receiverDistrict?: string;
|
receiverDistrict?: string;
|
||||||
|
skuId?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
@@ -1630,6 +1729,7 @@ export class TradeService {
|
|||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
promoCodeId?: string;
|
promoCodeId?: string;
|
||||||
|
skuId?: string;
|
||||||
},
|
},
|
||||||
req: Request,
|
req: Request,
|
||||||
) {
|
) {
|
||||||
@@ -1666,6 +1766,7 @@ export class TradeService {
|
|||||||
storeId: body.storeId,
|
storeId: body.storeId,
|
||||||
receiverCity: body.city,
|
receiverCity: body.city,
|
||||||
receiverDistrict: body.district,
|
receiverDistrict: body.district,
|
||||||
|
skuId: body.skuId,
|
||||||
},
|
},
|
||||||
{ phone: primary.phone },
|
{ phone: primary.phone },
|
||||||
);
|
);
|
||||||
@@ -1730,12 +1831,15 @@ export class TradeService {
|
|||||||
channelSource: 'PROXY_ONLINE',
|
channelSource: 'PROXY_ONLINE',
|
||||||
promoCodeId,
|
promoCodeId,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
barcode69: product.barcode69,
|
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||||
|
barcode69: preview.barcode69 || product.barcode69,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
productSpec: product.spec,
|
productSpec: preview.productSpec || product.spec,
|
||||||
imageResourceId: product.coverResourceId,
|
imageResourceId: product.coverResourceId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
listUnitPrice: product.price,
|
saleUnit: preview.saleUnit,
|
||||||
|
bottlesPerUnit: preview.bottlesPerUnit,
|
||||||
|
listUnitPrice: preview.unitPrice,
|
||||||
listAmount: preview.productAmount,
|
listAmount: preview.productAmount,
|
||||||
productAmount: preview.productAmount,
|
productAmount: preview.productAmount,
|
||||||
payAmount: preview.payAmount,
|
payAmount: preview.payAmount,
|
||||||
@@ -1993,18 +2097,28 @@ export class TradeService {
|
|||||||
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
|
||||||
this.promoCodeService.listActiveOptions(),
|
this.promoCodeService.listActiveOptions(),
|
||||||
]);
|
]);
|
||||||
|
const details = await Promise.all(
|
||||||
|
products.map((p) =>
|
||||||
|
this.catalogService.getProduct(BigInt(String(p.id)), { bypassWhitelist: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
products: products.map((p) => ({
|
products: details.filter(Boolean).map((p) => ({
|
||||||
id: p.id,
|
id: p!.id,
|
||||||
name: p.name,
|
name: p!.name,
|
||||||
spec: p.spec,
|
spec: p!.spec,
|
||||||
price: Number(p.price),
|
price: Number(p!.price),
|
||||||
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
|
benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null,
|
||||||
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
|
coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null,
|
||||||
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
|
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup,
|
||||||
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
|
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false,
|
||||||
allowCrossCityDelivery:
|
allowCrossCityDelivery:
|
||||||
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
|
(p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false,
|
||||||
|
specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled,
|
||||||
|
saleUnit: (p as { saleUnit?: string } | null)?.saleUnit,
|
||||||
|
skus: (p as { skus?: unknown[] } | null)?.skus ?? [],
|
||||||
|
defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId,
|
||||||
|
specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [],
|
||||||
})),
|
})),
|
||||||
promoCodes,
|
promoCodes,
|
||||||
stores: [],
|
stores: [],
|
||||||
@@ -2025,6 +2139,7 @@ export class TradeService {
|
|||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
promoCodeId?: string;
|
promoCodeId?: string;
|
||||||
|
skuId?: string;
|
||||||
},
|
},
|
||||||
req: Request,
|
req: Request,
|
||||||
) {
|
) {
|
||||||
@@ -2064,6 +2179,7 @@ export class TradeService {
|
|||||||
deliveryMode: body.deliveryMode,
|
deliveryMode: body.deliveryMode,
|
||||||
receiverCity: body.city,
|
receiverCity: body.city,
|
||||||
receiverDistrict: body.district,
|
receiverDistrict: body.district,
|
||||||
|
skuId: body.skuId,
|
||||||
},
|
},
|
||||||
{ bypassWhitelist: true },
|
{ bypassWhitelist: true },
|
||||||
);
|
);
|
||||||
@@ -2128,12 +2244,15 @@ export class TradeService {
|
|||||||
channelSource: 'PROXY_ONLINE',
|
channelSource: 'PROXY_ONLINE',
|
||||||
promoCodeId,
|
promoCodeId,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
barcode69: product.barcode69,
|
skuId: preview.skuId ? BigInt(preview.skuId) : null,
|
||||||
|
barcode69: preview.barcode69 || product.barcode69,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
productSpec: product.spec,
|
productSpec: preview.productSpec || product.spec,
|
||||||
imageResourceId: product.coverResourceId,
|
imageResourceId: product.coverResourceId,
|
||||||
quantity: body.quantity,
|
quantity: body.quantity,
|
||||||
listUnitPrice: product.price,
|
saleUnit: preview.saleUnit,
|
||||||
|
bottlesPerUnit: preview.bottlesPerUnit,
|
||||||
|
listUnitPrice: preview.unitPrice,
|
||||||
listAmount: preview.productAmount,
|
listAmount: preview.productAmount,
|
||||||
productAmount: preview.productAmount,
|
productAmount: preview.productAmount,
|
||||||
payAmount: preview.payAmount,
|
payAmount: preview.payAmount,
|
||||||
|
|||||||