15 Commits

Author SHA1 Message Date
jacy 7d2e4ef57e merge(dev_jacy): v3.5.4 规格主图与门店使用规则 2026-08-21 17:37:09 +08:00
jacy 4d434c9c67 v3.5.4 规格主图、门店使用规则与收银台 logo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 17:36:53 +08:00
jacy 73fd27bdf6 Merge pull request 'v3.5.4版本提交,修改api' (#34) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/34
2026-08-21 16:23:54 +08:00
jacy 62e61a9e63 v3.5.4版本提交,修改api
CI / verify (pull_request) Waiting to run
2026-08-21 16:22:53 +08:00
jacy cd9696bc1c Merge pull request 'v3.5.4版本提交' (#32) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/32
2026-08-21 15:50:07 +08:00
jacy a01217539c v3.5.4版本提交
CI / verify (pull_request) Waiting to run
2026-08-21 15:48:15 +08:00
jacy b5d4f8b27c Merge pull request 'v3.5.3版本更新2' (#30) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/30
2026-08-20 20:23:03 +08:00
jacy 26334ed072 v3.5.3版本更新2
CI / verify (pull_request) Waiting to run
2026-08-20 20:09:05 +08:00
jacy 9a80f0f8d6 Merge pull request 'v3.5.3版本更新1' (#29) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/29
2026-08-20 18:55:43 +08:00
jacy e8b585e623 Merge pull request '增加音效逻辑' (#26) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/26
2026-08-20 15:06:07 +08:00
jacy 5702266276 Merge pull request 'Dev jacy' (#25) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/25
2026-08-20 13:43:29 +08:00
jacy a8a37fbc4a Merge pull request 'v3.5.2 大屏调整' (#22) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/22
2026-08-20 12:00:57 +08:00
jacy e34416a9b3 Merge pull request 'Dev jacy mock 支付调整' (#20) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/20
2026-08-20 00:54:17 +08:00
jacy 6c44e9c56f Merge pull request 'v3.5.1 版本更新' (#18) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/18
2026-08-19 16:00:56 +08:00
jacy 905e145af3 Merge pull request 'v3.4.18' (#16) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/16
2026-08-17 23:45:00 +08:00
68 changed files with 4263 additions and 331 deletions
@@ -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 [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>();
const [skuId, setSkuId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
}, [open]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const selectedSku =
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(() => {
if (!open || !selectedProduct) return;
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId,
quantity,
deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
.finally(() => setPreviewLoading(false));
}, 300);
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(), []);
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setProductId(undefined);
setSkuId(undefined);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
productId: productId!,
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
};
setSubmitting(true);
@@ -317,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
loading={loadingOptions}
placeholder="选择商品"
value={productId}
onChange={setProductId}
onChange={(id) => {
setProductId(id);
setSkuId(undefined);
}}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
@@ -325,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
/>
</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
min={1}
value={quantity}
+66 -3
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -16,9 +17,11 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_CATEGORY_LABELS,
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceCategory,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
@@ -34,6 +37,7 @@ type Row = {
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
@@ -53,6 +57,7 @@ type CreateFormValues = {
orderNo: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string;
addressPhone?: string;
@@ -63,12 +68,22 @@ type CreateFormValues = {
};
export default function InvoicesPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('status')?.trim() || '';
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
const [filterForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {};
if (initialStatus) init.status = initialStatus;
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
return qs;
},
[filters],
@@ -81,12 +96,29 @@ export default function InvoicesPage() {
const [createForm] = Form.useForm<CreateFormValues>();
const invoiceKind = Form.useWatch('invoiceKind', createForm);
const titleType = Form.useWatch('titleType', createForm);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
filterForm.setFieldsValue({
status: filters.status || undefined,
invoiceNo: filters.invoiceNo || undefined,
});
}, [filterForm, filters.invoiceNo, filters.status]);
async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true);
}
useEffect(() => {
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.invoiceNo) === initialInvoiceNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialInvoiceNo, loading]);
async function issueWithFile(file: File) {
if (!detail) return false;
setUploading(true);
@@ -128,6 +160,7 @@ export default function InvoicesPage() {
orderNo: values.orderNo.trim(),
titleType: values.titleType,
invoiceKind: values.invoiceKind,
invoiceCategory: values.invoiceCategory,
titleName: values.titleName.trim(),
taxNo: values.taxNo?.trim() || undefined,
addressPhone: values.addressPhone?.trim() || undefined,
@@ -158,9 +191,17 @@ export default function InvoicesPage() {
},
{
title: '票种',
width: 120,
width: 140,
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: '状态',
@@ -205,6 +246,7 @@ export default function InvoicesPage() {
createForm.setFieldsValue({
titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
invoiceCategory: 'LIQUOR',
});
setCreateOpen(true);
}}
@@ -213,6 +255,7 @@ export default function InvoicesPage() {
</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
@@ -231,6 +274,9 @@ export default function InvoicesPage() {
]}
/>
</Form.Item>
<Form.Item name="invoiceNo" label="申请单号">
<Input allowClear placeholder="发票申请单号" />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
@@ -292,6 +338,11 @@ export default function InvoicesPage() {
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</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.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
@@ -350,6 +401,18 @@ export default function InvoicesPage() {
}}
/>
</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
name="titleType"
label="抬头类型"
+20 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
@@ -162,6 +162,8 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
}
export default function OrdersPage() {
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const [form] = Form.useForm();
const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
@@ -172,6 +174,7 @@ export default function OrdersPage() {
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<OrderDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const deepLinkOpenedRef = useRef(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
@@ -201,6 +204,21 @@ export default function OrdersPage() {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
if (initialOrderNo) {
form.setFieldsValue({ orderNo: initialOrderNo });
}
}, [form, initialOrderNo]);
useEffect(() => {
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.orderNo) === initialOrderNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialOrderNo, loading]);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
+28 -5
View File
@@ -12,6 +12,7 @@ import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form';
type ProductDetailContentDto = {
@@ -221,8 +222,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
<Form.Item
name="barcode69"
label="69码"
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
>
<Input placeholder="默认规格 69 码" />
</Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -402,7 +408,7 @@ export default function ProductsPage() {
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
@@ -420,8 +426,8 @@ export default function ProductsPage() {
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
@@ -437,6 +443,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>
</>
+23 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
@@ -26,8 +27,14 @@ function maskPhone(phone: string | null | undefined) {
}
export default function RedeemRecordsPage() {
const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
@@ -43,6 +50,11 @@ export default function RedeemRecordsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
useEffect(() => {
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
}, [form, initialRedeemNo]);
async function openDetail(id: string) {
setDetailLoading(true);
@@ -54,6 +66,15 @@ export default function RedeemRecordsPage() {
}
}
useEffect(() => {
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.redeemNo) === initialRedeemNo) {
deepLinkOpenedRef.current = true;
void openDetail(first.id);
}
}, [data, initialRedeemNo, loading]);
const columns: ColumnsType<Row> = [
{
title: '核销号',
+4 -1
View File
@@ -64,10 +64,13 @@ export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || '';
const initialStatus =
searchParams.get('status')?.trim() ||
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
status: initialStatus,
storeId: initialStoreId,
});
const [stores, setStores] = useState<StoreOption[]>([]);
@@ -24,7 +24,10 @@ import type {
StorePackageChangeRequestDto,
StorePackageChangeStatus,
} from '@dukang/shared-types';
import { STORE_INFO_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
import {
STORE_INFO_CHANGE_STATUS_LABELS,
STORE_INFO_CHANGEABLE_FIELD_LABELS,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
@@ -36,23 +39,6 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
REJECTED: '已驳回',
};
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
coverUrl: '门头照',
envPhotoUrls: '环境照片',
};
function fmtFieldValue(field: string, v: unknown): string {
if (v == null || String(v).trim() === '') return '(空)';
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
@@ -222,7 +208,7 @@ function InfoChangeAuditPanel({
render: (_, row) =>
row.changedFields?.length
? row.changedFields.map((f) => (
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
))
: '—',
},
@@ -339,7 +325,7 @@ function InfoChangeAuditPanel({
{detail.diffs.map((d) => (
<Descriptions.Item
key={d.field}
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
+23
View File
@@ -246,6 +246,8 @@ export default function StoresPage() {
const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? '';
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
const initialStoreId = searchParams.get('storeId') ?? '';
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
@@ -253,6 +255,7 @@ export default function StoresPage() {
const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
@@ -294,6 +297,7 @@ export default function StoresPage() {
const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
const deepLinkStoreOpenedRef = useRef(false);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -304,6 +308,12 @@ export default function StoresPage() {
.catch(() => {});
}, []);
useEffect(() => {
if (initialAuditStatus) {
form.setFieldsValue({ auditStatus: initialAuditStatus });
}
}, [form, initialAuditStatus]);
useEffect(() => {
const cityId = searchParams.get('cityId') ?? '';
const partnerId = searchParams.get('partnerId') ?? '';
@@ -454,6 +464,19 @@ export default function StoresPage() {
setDrawerOpen(true);
}
useEffect(() => {
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
if (row) {
deepLinkStoreOpenedRef.current = true;
void openStoreDetail(row);
} else if (data && (data.items?.length ?? 0) >= 0) {
// 列表无该店时仍尝试直拉详情
deepLinkStoreOpenedRef.current = true;
void openStoreDetail({ id: initialStoreId } as StoreRow);
}
}, [data, initialStoreId, loading]);
async function saveStoreDetail() {
if (!detail) return;
setSaving(true);
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Avatar,
Button,
@@ -13,6 +13,7 @@ import {
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
message,
@@ -21,8 +22,13 @@ import type { ColumnsType } from 'antd/es/table';
import {
WECOM_PUSH_CONDITION_GROUPS,
WECOM_PUSH_CONDITION_LABELS,
WECOM_TEMPLATE_EVENT_KEYS,
WECOM_TEMPLATE_EVENT_LABELS,
WECOM_TEMPLATE_PLACEHOLDERS,
type WecomMessagePushDto,
type WecomPushCondition,
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
@@ -78,7 +84,7 @@ function WecomPushConditionPicker({
);
}
export default function WecomMessagePushesPage() {
function PushRoutesTab() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -257,11 +263,9 @@ export default function WecomMessagePushesPage() {
];
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<>
<Typography.Paragraph type="secondary">
Webhook
.env Webhook URL
Webhook / .env Webhook URL
</Typography.Paragraph>
<Form
@@ -332,10 +336,10 @@ export default function WecomMessagePushesPage() {
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:运营告警" />
<Input placeholder="如:业务待办通知群" />
</Form.Item>
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
<OssUpload />
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item
name="webhookUrl"
@@ -400,6 +404,208 @@ export default function WecomMessagePushesPage() {
</Descriptions>
) : null}
</Modal>
</>
);
}
function TemplatesTab() {
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
const [loading, setLoading] = useState(false);
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [preview, setPreview] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
setTemplates(list);
setEventKey((prev) => {
const current = list.find((t) => t.eventKey === prev) ?? list[0];
if (current) {
form.setFieldsValue({
title: current.title,
body: current.body,
handleLabel: current.handleLabel,
});
return current.eventKey;
}
return prev;
});
} catch (e) {
message.error(e instanceof Error ? e.message : '加载模板失败');
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
void load();
}, [load]);
function selectEvent(key: WecomTemplateEventKey) {
setEventKey(key);
const row = templates.find((t) => t.eventKey === key);
if (row) {
form.setFieldsValue({
title: row.title,
body: row.body,
handleLabel: row.handleLabel,
});
setPreview('');
}
}
async function save() {
const values = await form.validateFields();
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}`,
{
method: 'PUT',
body: JSON.stringify(values),
},
);
message.success('模板已保存');
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function reset() {
setSaving(true);
try {
const updated = await request<WecomPushTemplateDto>(
`/admin/wecom-push-templates/${eventKey}/reset`,
{ method: 'POST', body: '{}' },
);
message.success('已恢复默认文案');
form.setFieldsValue({
title: updated.title,
body: updated.body,
handleLabel: updated.handleLabel,
});
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
setPreview('');
} catch (e) {
message.error(e instanceof Error ? e.message : '恢复失败');
} finally {
setSaving(false);
}
}
async function testSend() {
setTesting(true);
try {
const res = await request<{ ok: boolean; message: string; preview: string }>(
`/admin/wecom-push-templates/${eventKey}/test`,
{ method: 'POST', body: '{}' },
);
setPreview(res.preview || '');
message.success(res.message || '已发送');
} catch (e) {
message.error(e instanceof Error ? e.message : '测试失败');
} finally {
setTesting(false);
}
}
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
return (
<>
<Typography.Paragraph type="secondary">
使 {'{{orderNo}}'} {'{{handleUrl}}'}
Webhook
</Typography.Paragraph>
<Space align="start" style={{ width: '100%' }} size={24} wrap>
<div style={{ minWidth: 200 }}>
<Typography.Text strong></Typography.Text>
<div style={{ marginTop: 8 }}>
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
<div key={k} style={{ marginBottom: 4 }}>
<Button
type={k === eventKey ? 'primary' : 'text'}
size="small"
onClick={() => selectEvent(k)}
block
style={{ textAlign: 'left' }}
>
{WECOM_TEMPLATE_EVENT_LABELS[k]}
</Button>
</div>
))}
</div>
</div>
<div style={{ flex: 1, minWidth: 360 }}>
<Form form={form} layout="vertical" disabled={loading}>
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item
name="body"
label="正文(企微 markdown"
rules={[{ required: true }]}
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
>
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
</Form.Item>
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
<Input placeholder="去处理" />
</Form.Item>
<Space wrap>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
<Button loading={saving}></Button>
</Popconfirm>
<Button loading={testing} onClick={() => void testSend()}>
</Button>
</Space>
</Form>
{preview ? (
<div style={{ marginTop: 16 }}>
<Typography.Text strong></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
whiteSpace: 'pre-wrap',
borderRadius: 8,
}}
>
{preview}
</pre>
</div>
) : null}
</div>
</Space>
</>
);
}
export default function WecomMessagePushesPage() {
return (
<div>
<Typography.Title level={4}> · </Typography.Title>
<Tabs
items={[
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
]}
/>
</div>
);
}
+63 -5
View File
@@ -58,6 +58,7 @@ export default function ProxyOrderPage() {
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
const [productId, setProductId] = useState(draft0.productId);
const [skuId, setSkuId] = useState('');
const [quantity, setQuantity] = useState(draft0.quantity);
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
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 allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
const selectedSku =
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(() => {
if (!selectedProduct) return;
@@ -184,6 +213,7 @@ export default function ProxyOrderPage() {
productId,
quantity,
deliveryMode,
skuId: skuId || undefined,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
@@ -200,7 +230,7 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
@@ -359,6 +389,7 @@ export default function ProxyOrderPage() {
productId,
quantity,
promoCodeId: promoCodeId || undefined,
skuId: skuId || undefined,
};
setSubmitting(true);
@@ -524,8 +555,35 @@ export default function ProxyOrderPage() {
</button>
</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">
<label className="partner-form-label"></label>
<label className="partner-form-label">
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
</label>
<div className="partner-input-wrap">
<input
className="partner-input"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,19 +1,20 @@
import { Text, View } from '@tarojs/components';
import BenefitFigure from './BenefitFigure';
type CouponBadgeProps = {
amount: number | string;
label?: string;
};
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge */
/** 首页商品角标:纯文案「享{amount}好客权益」(无门店图标 */
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
const n = Number(amount);
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
return (
<View className="coupon-badge">
<Text></Text>
<BenefitFigure value={`${display} ${label}`} size="sm" />
<Text>
{display}
{label}
</Text>
</View>
);
}
+5
View File
@@ -1,5 +1,6 @@
export type CheckoutContext = {
productId?: string;
skuId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
@@ -9,6 +10,7 @@ export type CheckoutContext = {
export function buildQuery(ctx: CheckoutContext): string {
const parts: string[] = [];
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.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
if (ctx.cross) parts.push('cross=1');
@@ -36,12 +38,14 @@ export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext
export function buildPayUrl(params: {
orderId: string;
productId?: string;
skuId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
}): string {
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
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.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
if (params.cross) parts.push('cross=1');
@@ -51,6 +55,7 @@ export function buildPayUrl(params: {
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
return {
productId: params.productId,
skuId: params.skuId,
qty: params.qty,
addressId: params.addressId,
cross: params.cross === '1',
@@ -7,9 +7,9 @@ import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { usePageView } from '../../lib/usePageView';
import {
INVOICE_KIND_LABELS,
INVOICE_CATEGORY_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceCategory,
type UserInvoiceTitleDto,
} from '@dukang/shared-types';
@@ -42,7 +42,8 @@ export default function InvoiceApplyPage() {
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
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 [phoneOverride, setPhoneOverride] = useState('');
const [loading, setLoading] = useState(true);
@@ -103,19 +104,17 @@ export default function InvoiceApplyPage() {
toast('请填写联系电话');
return;
}
if (invoiceKind === 'SPECIAL' && selected.titleType !== 'ENTERPRISE') {
toast('专用发票仅支持企业抬头');
return;
}
setSubmitting(true);
try {
await request(`/trade/orders/${orderId}/invoices`, {
method: 'POST',
data: {
titleId: selectedId,
invoiceKind,
invoiceKind: 'NORMAL',
invoiceCategory,
email,
phone,
remark: remark.trim() || undefined,
},
});
toast('发票申请已提交', 'success');
@@ -192,20 +191,26 @@ export default function InvoiceApplyPage() {
) : null}
{canApply ? (
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<View className="invoice-title-type-row">
{(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => (
<Text
key={k}
className={`invoice-title-type-chip${invoiceKind === k ? ' active' : ''}`}
onClick={() => setInvoiceKind(k)}
>
{INVOICE_KIND_LABELS[k]}
</Text>
))}
<>
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<Text className="invoice-kind-static"></Text>
</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}
{canApply ? (
@@ -258,7 +263,11 @@ export default function InvoiceApplyPage() {
{canApply && selected && !selected.email ? (
<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
className="invoice-title-input"
placeholder="电子发票将发送至此邮箱"
@@ -279,6 +288,19 @@ export default function InvoiceApplyPage() {
/>
</View>
) : 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>
{canApply && titles.length > 0 ? (
@@ -26,6 +26,24 @@ const ACTION_ICONS = {
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({
kind,
label,
@@ -120,6 +138,15 @@ export default function InvoiceTitlesPage() {
toast('企业抬头须填写税号');
return;
}
const email = draft.email?.trim() || '';
if (!email) {
toast('请填写接收邮箱(必填)');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
toast('邮箱格式不正确');
return;
}
setSaving(true);
try {
const payload: UpsertInvoiceTitleRequest = {
@@ -152,6 +179,10 @@ export default function InvoiceTitlesPage() {
}
async function setDefault(t: UserInvoiceTitleDto) {
if (!t.email?.trim()) {
toast('请先补全接收邮箱后再设为默认');
return;
}
try {
await request(`/trade/invoice-titles/${t.id}`, {
method: 'PUT',
@@ -241,7 +272,7 @@ export default function InvoiceTitlesPage() {
</Text>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<View className="invoice-title-type-row">
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
<Text
@@ -258,7 +289,7 @@ export default function InvoiceTitlesPage() {
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<Input
className="invoice-title-input"
maxlength={128}
@@ -270,7 +301,7 @@ export default function InvoiceTitlesPage() {
{draft.titleType === 'ENTERPRISE' ? (
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<Input
className="invoice-title-input"
maxlength={32}
@@ -282,11 +313,13 @@ export default function InvoiceTitlesPage() {
) : null}
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required hint="必填,电子发票将发送至此">
</FieldLabel>
<Input
className="invoice-title-input"
maxlength={128}
placeholder="电子发票将发送至此邮箱"
placeholder="请填写邮箱(必填)"
value={draft.email || ''}
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
/>
@@ -311,7 +344,7 @@ export default function InvoiceTitlesPage() {
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
placeholder="选填"
value={draft.addressPhone || ''}
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
/>
@@ -321,7 +354,7 @@ export default function InvoiceTitlesPage() {
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
placeholder="选填"
value={draft.bankAccount || ''}
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
/>
@@ -32,12 +32,14 @@ type OrderPreview = {
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
saleUnit?: 'BOTTLE' | 'BOX';
};
export default function OrderConfirmPickupPage() {
const router = useRouter();
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 [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
@@ -50,7 +52,7 @@ export default function OrderConfirmPickupPage() {
setPreviewLoading(true);
request<OrderPreview>('/trade/orders/preview', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
})
.then((data) => {
if (!cancelled) {
@@ -70,15 +72,16 @@ export default function OrderConfirmPickupPage() {
return () => {
cancelled = true;
};
}, [productId, quantity]);
}, [productId, skuId, quantity]);
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
const minQty = preview?.minQty ?? 2;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < minQty) {
const tip = `现场提货至少购买 ${minQty}`;
const tip = `现场提货至少购买 ${minQty}${unitLabel}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
@@ -91,12 +94,13 @@ export default function OrderConfirmPickupPage() {
async function doSubmit() {
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
data: { productId, quantity, onSitePickup: true, ...(skuId ? { skuId } : {}) },
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
skuId: skuId || undefined,
qty: String(quantity),
}),
});
@@ -54,6 +54,9 @@ type OrderPreview = {
minQty?: number;
allowCrossCityDelivery?: boolean;
allowOnlinePurchase?: boolean;
skuId?: string;
saleUnit?: 'BOTTLE' | 'BOX';
bottlesPerUnit?: number;
};
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
@@ -66,6 +69,7 @@ export default function OrderConfirmPage() {
const router = useRouter();
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const skuId = checkoutCtx.skuId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState<Address[]>([]);
@@ -96,11 +100,12 @@ export default function OrderConfirmPage() {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
const body: { productId: string; quantity: number; addressId?: string; skuId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
if (skuId) body.skuId = skuId;
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
.then((data) => {
@@ -139,7 +144,7 @@ export default function OrderConfirmPage() {
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
}, [productId, skuId, quantity, addressId]);
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
@@ -157,6 +162,7 @@ export default function OrderConfirmPage() {
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
const crossBlocked = isCross && !allowCross;
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
const unitLabel = preview?.saleUnit === 'BOX' ? '箱' : '瓶';
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
@@ -171,8 +177,8 @@ export default function OrderConfirmPage() {
function updateQuantity(next: number) {
if (next < minQty) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
? `跨城配送至少购买 ${minQty}${unitLabel}`
: `同城配送至少购买 ${minQty}${unitLabel}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
@@ -195,6 +201,7 @@ export default function OrderConfirmPage() {
productId,
quantity,
addressId,
...(skuId ? { skuId } : {}),
...(clientLocation ? { clientLocation } : {}),
},
});
@@ -202,6 +209,7 @@ export default function OrderConfirmPage() {
url: buildPayUrl({
orderId: order.id,
productId,
skuId: skuId || undefined,
qty: String(quantity),
addressId,
cross: forceCross,
@@ -223,15 +231,15 @@ export default function OrderConfirmPage() {
}
if (!quantityOk) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
? `跨城配送至少购买 ${minQty}${unitLabel}`
: `同城配送至少购买 ${minQty}${unitLabel}`;
setMsg(tip);
toast(tip);
}
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) {
try {
@@ -371,8 +379,8 @@ export default function OrderConfirmPage() {
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty},请调整数量`}
? `跨城配送至少购买 ${minQty}${unitLabel},请调整数量`
: `同城配送至少购买 ${minQty}${unitLabel},请调整数量`}
</Text>
) : null}
</View>
+3 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Image } from '@tarojs/components';
import '../../styles/order.css';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
@@ -19,13 +19,13 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
import { isWechatEnv } from '../../lib/weixin';
import { goLogin } from '../../lib/auth-nav';
import { request, toast } from '../../lib/api';
import payLogo from '../../assets/logo2.png';
export default function PayPage() {
const router = useRouter();
const orderId = router.params.orderId ?? '';
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const [orderNo, setOrderNo] = useState('');
@@ -39,7 +39,6 @@ export default function PayPage() {
const refreshPayReadiness = useCallback(async () => {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch {
@@ -174,7 +173,7 @@ export default function PayPage() {
<View className="sub-page-body">
<View className="pay-status">
<View className="pay-status-icon">
<Text>¥</Text>
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
</View>
<Text className="pay-status-title">
{needsWechatAuth ? '需完成微信授权' : '待支付'}
@@ -204,12 +203,6 @@ export default function PayPage() {
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
</Text>
</View>
</View>
{msg ? (
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
+146 -13
View File
@@ -8,7 +8,7 @@ import Taro, {
useShareAppMessage,
useShareTimeline,
} 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 PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
@@ -47,8 +47,39 @@ type Product = ProductImageSource & {
allowOnSitePickup?: boolean;
allowOnlinePurchase?: 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() {
const router = useRouter();
const productId = router.params.id ?? '';
@@ -58,6 +89,7 @@ export default function ProductDetailPage() {
);
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
const [selected, setSelected] = useState<Record<string, string>>({});
usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100);
@@ -72,7 +104,25 @@ export default function ProductDetailPage() {
toast('商品不存在或暂未开放');
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) => {
setProduct(null);
@@ -84,20 +134,53 @@ export default function ProductDetailPage() {
loadProduct();
}, [loadProduct]);
// 登录后返回详情须带 token 重拉,否则白名单商品会一直空白
useDidShow(() => {
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(
() =>
buildSceneSharePayload('productDetail', {
path: `/pages/product-detail/index?id=${productId}`,
dynamicTitle: product?.name,
dynamicDesc: product?.subtitle,
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
dynamicImageUrl: (activeSku?.imageUrl?.trim() || (product ? getProductMainImage(product) : undefined)),
}),
[product, productId],
[product, productId, activeSku],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
@@ -118,9 +201,23 @@ export default function ProductDetailPage() {
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() {
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()) {
goLogin(returnPath);
return;
@@ -132,7 +229,12 @@ export default function ProductDetailPage() {
async function goOnSitePickup() {
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()) {
goLogin(returnPath);
return;
@@ -151,10 +253,8 @@ export default function ProductDetailPage() {
);
}
const allowOnline = canBuyOnline(product);
const allowOnSite = canPickupOnSite(product);
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
const carouselImages = getProductCarouselImages(product);
const allowOnline = canBuyOnline(fulfillment ?? {});
const allowOnSite = canPickupOnSite(fulfillment ?? {});
const detailImages = getProductDetailImages(product);
const detail = product.detailContent ?? {};
const features = detail.features ?? [];
@@ -177,12 +277,45 @@ export default function ProductDetailPage() {
<View className="product-detail-info">
<View className="product-detail-price">
<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>
<Text className="product-detail-name">{product.name}</Text>
{product.subtitle ? (
<Text className="product-detail-subtitle">{product.subtitle}</Text>
) : 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-glow" />
@@ -192,7 +325,7 @@ export default function ProductDetailPage() {
</View>
<View className="product-detail-promo-title">
<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>
<Text className="product-detail-promo-desc">
@@ -1,5 +1,5 @@
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 Taro, {
useDidShow,
@@ -444,17 +444,19 @@ export default function StoreDetailPage() {
</View>
) : null}
{intro ? (
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<Text className="store-detail-intro">{intro}</Text>
<Text className="store-detail-section-title store-detail-section-title--rule">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{benefitRule ? (
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
<Text className="store-detail-section-title"></Text>
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
<Text className="store-detail-intro">{intro}</Text>
</ScrollView>
</View>
) : null}
+24
View File
@@ -153,6 +153,30 @@
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 {
width: 100%;
height: 44px;
+9 -5
View File
@@ -488,16 +488,20 @@
}
.pay-status-icon {
width: 72px;
height: 72px;
width: 88px;
height: 88px;
border-radius: 50%;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 36px;
background: #f7f4ee;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
overflow: hidden;
}
.pay-status-brand {
width: 72px;
height: 72px;
}
.pay-status-title {
@@ -130,6 +130,7 @@
.product-detail-promo {
position: relative;
margin-top: 8px;
padding: 16px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
@@ -375,3 +376,53 @@
font-size: 16px;
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);
}
.store-detail-section-title--rule {
color: var(--color-heritage-red, #a61d24);
}
.store-detail-intro-scroll {
max-height: 168px;
height: 168px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.store-detail-env-grid {
display: flex;
flex-direction: column;
+49 -2
View File
@@ -1,7 +1,7 @@
# 杜康好客 · v3.5.3 版本更新
> **2026-08-20** · admin-web / mini-user / h5-partner / h5-shop / API
> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**。
> 目标:门店封面/环境图体验、企微门店审核通知、总部抽屉内审套餐、小程序分享用业务标题主图、系统设置分享配置按场景折叠;**补修核销金额为 0 / 无法提现、测试流水计结算、核销浮动提示、好客权益金额图标**;**企微业务通知可编辑模板 + 处理快链**
## 范围
@@ -19,8 +19,9 @@
| 8 | 测试流水计结算 | **取消**「测试流水不计结算」;核销一律建 payout;账单/佣金不再因 `isTest` 跳过 |
| 9 | 核销校验浮动提示 | 门店 H5 / 小程序:超可用余额等提示改为页面中上部浮动气泡 |
| 10 | 好客权益金额图标 | 小程序权益金额前去掉 ¥,改为门店核销语义图标(商品售价/实付仍用 ¥) |
| 11 | 企微业务通知扩展 | 订单支付/核销成功/信息变更/提现/发票;`wecom_push_template` 可编辑;处理快链 |
**配置进库**`wecom_message_push` upsert「门店审核通知群」(无 webhook 时占位 URL + `enabled=false`)。无新 Prisma 表
**配置进库**`wecom_message_push` upsert「门店审核通知群 / 业务待办通知群 / 成交播报群」(无 webhook 时占位 URL + `enabled=false`)。**新表** `wecom_push_template`(发版不可 skip-db
---
@@ -138,6 +139,48 @@
---
## 11. 企微业务通知 + 可编辑模板 + 处理快链
### 事件条件 key
| key | 触发 | 测试单 |
|-----|------|--------|
| `order.paid` | `afterOrderPaid` | 不推 |
| `redeem.success` | `executeRedeem` 成功后 | 不推 |
| `store.audit_pending` | 入驻/重提 PENDING | 推 |
| `store.package_audit_pending` | 套餐变更 PENDING | 推 |
| `store.info_change_pending` | 信息变更 PENDING | 推 |
| `store.withdraw_pending` | 手动提现 PENDING_REVIEW | 推 |
| `invoice.pending` | 发票申请 PENDING | 不推 |
默认推送行:`业务待办通知群``成交播报群`(占位 webhook + 禁用,直至 HQ 配置)。
### 模板表 `wecom_push_template`
-`eventKey` 全站一份;启动 `ensureTemplates` **仅插入缺行**,不覆盖 HQ 已改
- HQ「企微机器人 → 消息推送 → 通知模板」:编辑 / 恢复默认 / 示例测试推送
- API`GET/PUT /admin/wecom-push-templates/:eventKey``POST .../reset``POST .../test`
- 渲染:`WecomMessagePushService.dispatchEvent``{{var}}` 插值 → 条件路由
### 处理快链
环境变量 `HQ_ADMIN_PUBLIC_URL`staging `https://admin-test.dukanghaoke.com`,生产 `https://admin.dukanghaoke.com`)。
未配置时按 `WECOM_ALERT_ENV_LABEL` 回退(prod→生产域名,staging→测试域名,local→`http://localhost:5175`),**禁止**只发相对路径(企微会解析成 `http://orders/...`)。
| 事件 | 路径 |
|------|------|
| 订单 | `/orders?orderNo=` |
| 核销 | `/redeem-records?redeemNo=` |
| 入驻 | `/stores?auditStatus=PENDING&storeId=` |
| 套餐 | `/store-package-audits?requestId=` |
| 信息变更 | `/store-package-audits?tab=info&infoRequestId=` |
| 提现 | `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=` |
| 发票 | `/invoices?status=PENDING&invoiceNo=` |
提现待审改走 `store.withdraw_pending`,不再误用 `AlertService` `category: finance``alert.system`
---
## 验收
- [ ] 合伙人可改门头/环境图(PENDING 除外)
@@ -151,3 +194,7 @@
- [ ] 历史无 payout 的核销:打开「结算提现」后可用余额出现;测试核销同样计结算
- [ ] 门店 H5 / 小程序超额核销提示为中上部浮动气泡
- [ ] 小程序好客权益金额前为店铺图标,商品价仍为 ¥
- [ ] HQ 可编辑企微通知模板;勾选条件 + 配置 webhook 后订单/核销/提现/发票/审核有推送
- [ ] 企微消息「去处理」可打开 HQ 对应列表并尽量定位到该单(需登录)
- [ ] 测试订单/核销不推成交播报;发票申请测试单不推
- [ ] 发版含 `wecom_push_template` 表与 `HQ_ADMIN_PUBLIC_URL`
+101
View File
@@ -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 印章
+26
View File
@@ -7,6 +7,8 @@ import {
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
validateMinPurchase,
toBottleQuantity,
toMinSaleQuantity,
validateBusinessHours,
formatBusinessHours,
validateRedeemAmount,
@@ -47,6 +49,30 @@ describe('validateMinPurchase', () => {
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
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', () => {
+41 -7
View File
@@ -7,27 +7,61 @@ export function calcBenefitAmount(product: ProductPricing): number {
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(
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
quantity: number,
localMinQty: number,
crossMinQty: number,
options?: { bottlesPerUnit?: number; saleUnit?: ProductSaleUnit },
): { 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') {
const min = localMinQty > 0 ? localMinQty : 2;
if (quantity < min) {
return { ok: false, message: `现场提货至少购买 ${min}` };
const minBottles = localMinQty > 0 ? localMinQty : 2;
if (bottleQty < minBottles) {
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
return { ok: false, message: `现场提货至少购买 ${minSale}${unit}` };
}
return { ok: true };
}
const min = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
if (quantity < min) {
const minBottles = deliveryType === 'LOCAL' ? localMinQty : crossMinQty;
if (bottleQty < minBottles) {
const minSale = toMinSaleQuantity(minBottles, bottlesPerUnit);
return {
ok: false,
message:
deliveryType === 'LOCAL'
? `同城配送至少购买 ${min}`
: `跨城配送至少购买 ${min} 瓶(1箱)`,
? `同城配送至少购买 ${minSale}${unit}`
: `跨城配送至少购买 ${minSale}${unit}`,
};
}
return { ok: true };
+79 -1
View File
@@ -8,16 +8,50 @@ export interface CityDto {
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 {
id: string;
skuCode: string;
name: string;
subtitle?: string | null;
/** 拍平价:可售 SKU 最低价 / 默认 SKU 价 */
price: number;
benefitAmount: number;
benefitDisplay?: number;
aromaType: string;
status: string;
/** 规格文案(默认/展示 SKU) */
spec?: string;
/** 封面图(common_resource COVER / cover_resource_id */
mainImageUrl?: string | null;
/** 轮播图(bizType=CAROUSEL;无则回退封面) */
@@ -25,12 +59,21 @@ export interface ProductDto {
/** 详情长图(bizType=DETAIL 或 detailContent JSON */
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
/** 是否允许现场取货下单 */
/** 是否允许现场取货下单(拍平自展示 SKU */
allowOnSitePickup?: boolean;
/** 是否允许配送到址(同城线上购买) */
allowOnlinePurchase?: 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 {
@@ -68,3 +111,38 @@ export interface ProductDetailTemplateDto {
createdAt: 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;
}
+9
View File
@@ -1,5 +1,7 @@
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
/** C 端开票品类:酒水类 / 餐饮类(票种固定增值税普通发票) */
export type InvoiceCategory = 'LIQUOR' | 'CATERING';
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
@@ -12,6 +14,11 @@ export const INVOICE_KIND_LABELS: Record<InvoiceKind, string> = {
SPECIAL: '增值税专用发票',
};
export const INVOICE_CATEGORY_LABELS: Record<InvoiceCategory, string> = {
LIQUOR: '酒水类',
CATERING: '餐饮类',
};
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
PENDING: '待开票',
ISSUED: '已开票',
@@ -21,6 +28,7 @@ export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
export interface CreateInvoiceRequest {
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string;
addressPhone?: string;
@@ -37,6 +45,7 @@ export interface InvoiceDto {
userId: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string | null;
addressPhone?: string | null;
@@ -32,6 +32,34 @@ export const STORE_INFO_CHANGEABLE_FIELDS = [
export type StoreInfoChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
/** 可变字段中文名(企微推送 / HQ 审核列表共用) */
export const STORE_INFO_CHANGEABLE_FIELD_LABELS: Record<StoreInfoChangeableField, string> = {
name: '门店名称',
contactPhone: '联系电话',
address: '详细地址',
intro: '门店简介',
benefitUsageRule: '权益券使用规则',
latitude: '纬度',
longitude: '经度',
openTime: '营业开始',
closeTime: '营业结束',
openTime2: '第二段开始',
closeTime2: '第二段结束',
avgPrice: '人均费用',
coverUrl: '门头照',
envPhotoUrls: '环境照片',
};
/** 将字段 key 列表格式化为中文,如「门店名称、详细地址」 */
export function formatStoreInfoChangeFieldLabels(
fields: readonly string[],
separator = '、',
): string {
return fields
.map((f) => STORE_INFO_CHANGEABLE_FIELD_LABELS[f as StoreInfoChangeableField] ?? f)
.join(separator);
}
/** 单条记录的字段详情(用于审核页 diff 对比) */
export interface StoreInfoChangeFieldDiff {
field: StoreInfoChangeableField;
+24
View File
@@ -114,6 +114,22 @@ export type PartnerProxyOrderProductOption = {
allowOnSitePickup?: boolean;
allowOnlinePurchase?: 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 = {
@@ -145,6 +161,7 @@ export type PartnerProxyOrderPreviewRequest = {
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
skuId?: string;
};
export type PartnerProxyOrderPreviewResult = {
@@ -153,6 +170,11 @@ export type PartnerProxyOrderPreviewResult = {
benefitAmount: number;
deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP';
unitPrice: number;
skuId?: string;
saleUnit?: 'BOTTLE' | 'BOX';
bottlesPerUnit?: number;
bottleQuantity?: number;
minQuantity?: number;
};
export type PartnerProxyOrderCreateRequest = {
@@ -169,6 +191,7 @@ export type PartnerProxyOrderCreateRequest = {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
};
/** 合伙人代下单列表项(与 OrderDto 兼容,附带收货信息) */
@@ -203,4 +226,5 @@ export type HqProxyOrderCreateRequest = {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
};
+130 -7
View File
@@ -1,4 +1,4 @@
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 门店审核 */
/** 企微群机器人 Webhook · 推送条件(v3.4.11 + v3.5.3 业务通知 */
export const WECOM_PUSH_CONDITIONS = [
'alert.ops',
'support_ticket.created',
@@ -9,6 +9,11 @@ export const WECOM_PUSH_CONDITIONS = [
'dev_plan.task_dispatch',
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'order.paid',
'redeem.success',
'invoice.pending',
] as const;
export type WecomPushCondition = (typeof WECOM_PUSH_CONDITIONS)[number];
@@ -23,6 +28,11 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
'dev_plan.task_dispatch': '开发任务评审派发',
'store.audit_pending': '门店提交/重提待审',
'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审',
'order.paid': '订单支付成功',
'redeem.success': '门店核销成功',
'invoice.pending': '发票申请待开票',
};
export const WECOM_PUSH_CONDITION_GROUPS: Array<{
@@ -37,9 +47,25 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
},
{
key: 'pay_redeem',
label: '支付与核销',
label: '支付与核销异常',
conditions: ['alert.pay', 'alert.redeem'],
},
{
key: 'deal_broadcast',
label: '成交播报',
conditions: ['order.paid', 'redeem.success'],
},
{
key: 'biz_todo',
label: '业务待办',
conditions: [
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
],
},
{
key: 'system',
label: '系统与结算',
@@ -50,11 +76,6 @@ export const WECOM_PUSH_CONDITION_GROUPS: Array<{
label: '开发计划',
conditions: ['dev_plan.task_dispatch'],
},
{
key: 'store_audit',
label: '门店审核',
conditions: ['store.audit_pending', 'store.package_audit_pending'],
},
];
/** 默认「运营告警」推送条件 */
@@ -79,12 +100,114 @@ export const WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS: WecomPushCondition[] = [
'store.package_audit_pending',
];
/** 默认「业务待办通知群」 */
export const WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS: WecomPushCondition[] = [
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
];
/** 默认「成交播报群」 */
export const WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS: WecomPushCondition[] = [
'order.paid',
'redeem.success',
];
export const WECOM_STORE_AUDIT_PUSH_NAME = '门店审核通知群';
export const WECOM_BIZ_TODO_PUSH_NAME = '业务待办通知群';
export const WECOM_DEAL_BROADCAST_PUSH_NAME = '成交播报群';
/** 占位 webhook(未配置真实 key 时禁用,避免误推) */
export const WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK =
'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=PENDING';
/** 有 HQ 可编辑模板的业务事件(含入驻/套餐,不含 alert.*) */
export const WECOM_TEMPLATE_EVENT_KEYS = [
'order.paid',
'redeem.success',
'store.audit_pending',
'store.package_audit_pending',
'store.info_change_pending',
'store.withdraw_pending',
'invoice.pending',
] as const;
export type WecomTemplateEventKey = (typeof WECOM_TEMPLATE_EVENT_KEYS)[number];
export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string> = {
'order.paid': '订单支付成功',
'redeem.success': '门店核销成功',
'store.audit_pending': '门店提交/重提待审',
'store.package_audit_pending': '套餐变更待审',
'store.info_change_pending': '门店信息变更待审',
'store.withdraw_pending': '门店手动提现待审',
'invoice.pending': '发票申请待开票',
};
/** 各事件可用占位符说明(HQ 模板编辑) */
export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]> = {
'order.paid': ['orderNo', 'payAmount', 'cityName', 'skuSummary', 'phoneMasked', 'time', 'handleUrl', 'handleLabel'],
'redeem.success': ['redeemNo', 'amount', 'storeName', 'channel', 'time', 'handleUrl', 'handleLabel'],
'store.audit_pending': ['storeName', 'cityName', 'partnerLabel', 'action', 'time', 'handleUrl', 'handleLabel', 'storeId'],
'store.package_audit_pending': [
'storeName',
'cityName',
'submitter',
'packageCount',
'time',
'handleUrl',
'handleLabel',
'requestId',
],
'store.info_change_pending': [
'storeName',
'cityName',
'submitter',
'changedFields',
'time',
'handleUrl',
'handleLabel',
'requestId',
],
'store.withdraw_pending': [
'storeName',
'withdrawNo',
'amount',
'payoutCount',
'time',
'handleUrl',
'handleLabel',
'storeId',
],
'invoice.pending': [
'invoiceNo',
'orderNo',
'payAmount',
'titleName',
'phoneMasked',
'time',
'handleUrl',
'handleLabel',
],
};
export type WecomPushTemplateDto = {
id: string;
eventKey: WecomTemplateEventKey;
title: string;
body: string;
handleLabel: string;
updatedAt: string;
};
export type UpdateWecomPushTemplateRequest = {
title?: string;
body?: string;
handleLabel?: string;
};
export function parseWecomPushConditions(
raw?: string | string[] | null,
): WecomPushCondition[] {
+6
View File
@@ -69,6 +69,12 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=local
# HQ 企微业务通知「去处理」快链根地址(无尾斜杠;未配时按 WECOM_ALERT_ENV_LABEL 回退生产/测试/本机)
# HQ_ADMIN_PUBLIC_URL=http://localhost:5175
# 生产请设:https://admin.dukanghaoke.com ;测试:https://admin-test.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
# 腾讯位置服务(地理编码 / 逆地理 / 地点搜索选点,服务端调用)
# 控制台须开启 WebServiceAPI;推荐开启「签名校验」并配置下方 SK(服务端自动附 sig)
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=production
# HQ 企微业务通知「去处理」快链根地址
HQ_ADMIN_PUBLIC_URL=https://admin.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
+5
View File
@@ -57,6 +57,11 @@ WECOM_AIBOT_ENABLED=false
# WECOM_ALERT_ENABLED=false
# WECOM_ALERT_WEBHOOK_URL=
WECOM_ALERT_ENV_LABEL=staging
# HQ 企微业务通知「去处理」快链根地址
HQ_ADMIN_PUBLIC_URL=https://admin-test.dukanghaoke.com
# WECOM_STORE_AUDIT_WEBHOOK_URL=
# WECOM_BIZ_TODO_WEBHOOK_URL=
# WECOM_DEAL_BROADCAST_WEBHOOK_URL=
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
@@ -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());
+124 -2
View File
@@ -146,6 +146,12 @@ enum InvoiceKind {
SPECIAL
}
/// C 端开票品类(增值税票种固定普通发票)
enum InvoiceCategory {
LIQUOR
CATERING
}
enum InvoiceStatus {
PENDING
ISSUED
@@ -164,6 +170,12 @@ enum ProductStatus {
OFF_SALE
}
/// SKU 销售单位:瓶 / 箱(起购与物流按瓶当量)
enum ProductSaleUnit {
BOTTLE
BOX
}
enum DetailTemplateStatus {
ACTIVE
DISABLED
@@ -504,6 +516,19 @@ model WecomMessagePush {
@@map("wecom_message_push")
}
/// 企微业务通知文案模板(v3.5.3 · 按事件全站一份,HQ 可编辑)
model WecomPushTemplate {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
eventKey String @unique @map("event_key") @db.VarChar(64)
title String @db.VarChar(128)
body String @db.Text
handleLabel String @default("去处理") @map("handle_label") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@map("wecom_push_template")
}
/// HQ 语言模型 API 配置(非超管仅可见/可开关自己创建的)
model LlmApiConfig {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
@@ -778,10 +803,13 @@ model DevPlanTaskDispatch {
@@map("dev_plan_task_dispatch")
}
/// 商品 SPU(详情页实体);价格/码/履约等列为默认 SKU 冗余,供旧客户端拍平读取
model CommonProductItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
skuCode String @unique @map("sku_code") @db.VarChar(32)
barcode69 String @unique @map("barcode_69") @db.VarChar(32)
/// 默认 SKU 冗余;唯一约束已下放到 common_product_sku
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)
subtitle String? @db.VarChar(256)
aromaType AromaType @map("aroma_type")
@@ -805,11 +833,96 @@ model CommonProductItem {
coverResource CommonResource? @relation("ProductCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
orders Order[]
visibilityPhones CommonProductVisibilityPhone[]
specAttrs CommonProductSpecAttr[]
skus CommonProductSku[]
@@index([skuCode])
@@index([barcode69])
@@index([status, aromaType])
@@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)
model CommonProductVisibilityPhone {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
@@ -1482,11 +1595,16 @@ model Order {
promoCodeId BigInt? @map("promo_code_id") @db.UnsignedBigInt
channelSource String? @map("channel_source") @db.VarChar(128)
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)
productName String @map("product_name") @db.VarChar(128)
productSpec String @map("product_spec") @db.VarChar(128)
imageResourceId BigInt? @map("image_resource_id") @db.UnsignedBigInt
/// 下单数量(销售单位:瓶或箱)
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)
listAmount Decimal @map("list_amount") @db.Decimal(10, 2)
discountAmount Decimal @default(0) @map("discount_amount") @db.Decimal(10, 2)
@@ -1540,6 +1658,7 @@ model Order {
reshipments Order[] @relation("OrderReshipment")
promoCode CommonPromoCode? @relation(fields: [promoCodeId], references: [id], onDelete: SetNull)
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)
delivery OrderDelivery?
benefitCoupon BenefitCoupon?
@@ -1548,6 +1667,7 @@ model Order {
@@index([userId, status])
@@index([cityId, createdAt])
@@index([productId])
@@index([skuId])
@@index([barcode69])
@@index([payExternalNo])
@@index([ipCity])
@@ -1566,6 +1686,8 @@ model UserInvoice {
userId BigInt @map("user_id") @db.UnsignedBigInt
titleType InvoiceTitleType @map("title_type")
invoiceKind InvoiceKind @map("invoice_kind")
/// 酒水类 / 餐饮类;历史单默认酒水类
invoiceCategory InvoiceCategory @default(LIQUOR) @map("invoice_category")
titleName String @map("title_name") @db.VarChar(128)
taxNo String? @map("tax_no") @db.VarChar(32)
addressPhone String? @map("address_phone") @db.VarChar(256)
+20
View File
@@ -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);
await prisma.commonProductItem.update({
@@ -1,18 +1,31 @@
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import {
WECOM_BIZ_TODO_PUSH_NAME,
WECOM_DEAL_BROADCAST_PUSH_NAME,
WECOM_PUSH_CONDITIONS,
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
WECOM_STORE_AUDIT_PUSH_NAME,
WECOM_TEMPLATE_EVENT_KEYS,
maskWecomWebhookUrl,
parseWecomPushConditions,
type WecomMessagePushDto,
type WecomPushCondition,
type WecomPushTemplateDto,
type WecomTemplateEventKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
import {
WECOM_PUSH_TEMPLATE_DEFAULTS,
buildHqHandleUrl,
getDefaultTemplate,
renderWecomTemplate,
} from './wecom-push-template.defaults';
type PushRow = {
id: bigint;
@@ -27,6 +40,16 @@ type PushRow = {
updatedAt: Date;
};
type TemplateRow = {
id: bigint;
eventKey: string;
title: string;
body: string;
handleLabel: string;
createdAt: Date;
updatedAt: Date;
};
@Injectable()
export class WecomMessagePushService implements OnModuleInit {
private readonly logger = new Logger(WecomMessagePushService.name);
@@ -43,7 +66,7 @@ export class WecomMessagePushService implements OnModuleInit {
}
}
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送;并按名称 upsert「门店审核通知群」(v3.5.3) */
/** 表空时从 .env / 旧设置迁移;并确保门店审核 / 业务待办 / 成交播报 / 模板缺行 */
async ensureDefaults(): Promise<void> {
const count = await this.prisma.wecomMessagePush.count();
if (count === 0) {
@@ -100,45 +123,81 @@ export class WecomMessagePushService implements OnModuleInit {
}
}
await this.ensureStoreAuditPush();
await this.ensureNamedPush(
WECOM_STORE_AUDIT_PUSH_NAME,
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
20,
process.env.WECOM_STORE_AUDIT_WEBHOOK_URL,
);
await this.ensureNamedPush(
WECOM_BIZ_TODO_PUSH_NAME,
WECOM_PUSH_DEFAULT_BIZ_TODO_CONDITIONS,
25,
process.env.WECOM_BIZ_TODO_WEBHOOK_URL,
);
await this.ensureNamedPush(
WECOM_DEAL_BROADCAST_PUSH_NAME,
WECOM_PUSH_DEFAULT_DEAL_BROADCAST_CONDITIONS,
30,
process.env.WECOM_DEAL_BROADCAST_WEBHOOK_URL,
);
await this.ensureTemplates();
}
/** 按名称 upsert「门店审核通知群」:已有行保留 webhook;无行则 env 或占位 URL */
private async ensureStoreAuditPush(): Promise<void> {
const existing = await this.prisma.wecomMessagePush.findFirst({
where: { name: WECOM_STORE_AUDIT_PUSH_NAME },
});
private async ensureNamedPush(
name: string,
conditions: WecomPushCondition[],
sortOrder: number,
envUrl?: string,
): Promise<void> {
const existing = await this.prisma.wecomMessagePush.findFirst({ where: { name } });
if (existing) return;
const conditionsJson = JSON.stringify(WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS);
const envUrl = (process.env.WECOM_STORE_AUDIT_WEBHOOK_URL || '').trim();
if (envUrl) {
const conditionsJson = JSON.stringify(conditions);
const url = (envUrl || '').trim();
if (url) {
await this.prisma.wecomMessagePush.create({
data: {
name: WECOM_STORE_AUDIT_PUSH_NAME,
webhookUrl: envUrl,
name,
webhookUrl: url,
enabled: true,
pushConditions: conditionsJson,
sortOrder: 20,
sortOrder,
},
});
this.logger.log(`seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (from env)`);
this.logger.log(`seeded wecom message push: ${name} (from env)`);
return;
}
await this.prisma.wecomMessagePush.create({
data: {
name: WECOM_STORE_AUDIT_PUSH_NAME,
name,
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
enabled: false,
pushConditions: conditionsJson,
sortOrder: 20,
sortOrder,
},
});
this.logger.log(
`seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (placeholder, disabled)`,
);
this.logger.log(`seeded wecom message push: ${name} (placeholder, disabled)`);
}
/** 仅插入缺失 eventKey,不覆盖已有文案 */
async ensureTemplates(): Promise<void> {
for (const def of WECOM_PUSH_TEMPLATE_DEFAULTS) {
const existing = await this.prisma.wecomPushTemplate.findUnique({
where: { eventKey: def.eventKey },
});
if (existing) continue;
await this.prisma.wecomPushTemplate.create({
data: {
eventKey: def.eventKey,
title: def.title,
body: def.body,
handleLabel: def.handleLabel,
},
});
this.logger.log(`seeded wecom push template: ${def.eventKey}`);
}
}
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
@@ -154,6 +213,58 @@ export class WecomMessagePushService implements OnModuleInit {
return pushes.length > 0;
}
/**
* 读 HQ 模板 → 插值 → 补快链 → 按条件路由推送。
* 失败只打日志,不抛给业务。
*/
async dispatchEvent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
options?: { applyMention?: boolean; handlePath?: string },
): Promise<number> {
try {
const content = await this.renderEventContent(eventKey, vars, options?.handlePath);
return await this.dispatchMarkdown(eventKey, content, {
applyMention: options?.applyMention ?? false,
});
} catch (e) {
this.logger.warn(
`dispatchEvent(${eventKey}) failed: ${e instanceof Error ? e.message : String(e)}`,
);
return 0;
}
}
async renderEventContent(
eventKey: WecomTemplateEventKey,
vars: Record<string, string | number | null | undefined>,
handlePath?: string,
): Promise<string> {
const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
const def = getDefaultTemplate(eventKey);
const body = row?.body || def?.body || `**${eventKey}**`;
const handleLabel = row?.handleLabel || def?.handleLabel || '去处理';
const merged: Record<string, string | number | null | undefined> = {
...vars,
handleLabel: vars.handleLabel ?? handleLabel,
time:
vars.time ??
new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
};
if (handlePath && !merged.handleUrl) {
merged.handleUrl = buildHqHandleUrl(handlePath);
}
let content = renderWecomTemplate(body, merged).trim();
const url = String(merged.handleUrl || '').trim();
if (url && !content.includes(url) && !/\{\{handleUrl\}\}/.test(body)) {
content = `${content}\n[${handleLabel}](${url})`;
}
return content;
}
/** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */
async dispatchMarkdown(
eventKey: WecomPushCondition,
@@ -239,6 +350,188 @@ export class WecomMessagePushService implements OnModuleInit {
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
}
// ── templates CRUD ──
async listTemplates(): Promise<WecomPushTemplateDto[]> {
await this.ensureTemplates();
const rows = await this.prisma.wecomPushTemplate.findMany({
orderBy: { eventKey: 'asc' },
});
return rows.map((r) => this.templateToDto(r));
}
async getTemplate(eventKey: string): Promise<WecomPushTemplateDto> {
this.assertTemplateKey(eventKey);
await this.ensureTemplates();
const row = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
if (!row) throw new BadRequestException('模板不存在');
return this.templateToDto(row);
}
async updateTemplate(
eventKey: string,
data: { title?: string; body?: string; handleLabel?: string },
): Promise<WecomPushTemplateDto> {
this.assertTemplateKey(eventKey);
await this.ensureTemplates();
const existing = await this.prisma.wecomPushTemplate.findUnique({ where: { eventKey } });
if (!existing) throw new BadRequestException('模板不存在');
const title = data.title != null ? String(data.title).trim() : undefined;
const body = data.body != null ? String(data.body).trim() : undefined;
const handleLabel =
data.handleLabel != null ? String(data.handleLabel).trim() || '去处理' : undefined;
if (title !== undefined && !title) throw new BadRequestException('标题不能为空');
if (body !== undefined && !body) throw new BadRequestException('正文不能为空');
const row = await this.prisma.wecomPushTemplate.update({
where: { eventKey },
data: {
...(title !== undefined ? { title } : {}),
...(body !== undefined ? { body } : {}),
...(handleLabel !== undefined ? { handleLabel } : {}),
},
});
return this.templateToDto(row);
}
async resetTemplate(eventKey: string): Promise<WecomPushTemplateDto> {
this.assertTemplateKey(eventKey);
const def = getDefaultTemplate(eventKey);
if (!def) throw new BadRequestException('无默认模板');
await this.ensureTemplates();
const row = await this.prisma.wecomPushTemplate.upsert({
where: { eventKey },
create: {
eventKey: def.eventKey,
title: def.title,
body: def.body,
handleLabel: def.handleLabel,
},
update: {
title: def.title,
body: def.body,
handleLabel: def.handleLabel,
},
});
return this.templateToDto(row);
}
/** 用示例变量渲染并推到勾选了该事件的启用群 */
async testTemplate(eventKey: string): Promise<{ ok: boolean; message: string; preview: string }> {
this.assertTemplateKey(eventKey);
const sample = this.sampleVars(eventKey);
const preview = await this.renderEventContent(
eventKey,
sample.vars,
sample.handlePath,
);
const sent = await this.dispatchMarkdown(eventKey, preview, { applyMention: false });
if (sent === 0) {
return {
ok: false,
message: '没有已启用且勾选该事件的消息推送,请先配置 webhook',
preview,
};
}
return { ok: true, message: `已发送至 ${sent} 个推送`, preview };
}
private sampleVars(eventKey: WecomTemplateEventKey): {
vars: Record<string, string>;
handlePath: string;
} {
const samples: Record<WecomTemplateEventKey, { vars: Record<string, string>; handlePath: string }> = {
'order.paid': {
vars: {
orderNo: 'DK202608200001',
payAmount: '199.00',
cityName: '郑州',
skuSummary: '杜康原浆 ×2',
phoneMasked: '138****8000',
},
handlePath: '/orders?orderNo=DK202608200001',
},
'redeem.success': {
vars: {
redeemNo: 'RD202608200001',
amount: '88.00',
storeName: '示例门店',
channel: '扫码',
},
handlePath: '/redeem-records?redeemNo=RD202608200001',
},
'store.audit_pending': {
vars: {
storeName: '示例门店',
cityName: '郑州',
partnerLabel: '示例合伙人',
action: '新建',
storeId: '1',
},
handlePath: '/stores?auditStatus=PENDING&storeId=1',
},
'store.package_audit_pending': {
vars: {
storeName: '示例门店',
cityName: '郑州',
submitter: '合伙人',
packageCount: '3',
requestId: '1',
},
handlePath: '/store-package-audits?requestId=1',
},
'store.info_change_pending': {
vars: {
storeName: '示例门店',
cityName: '郑州',
submitter: '门店',
changedFields: '门店名称、详细地址',
requestId: '1',
},
handlePath: '/store-package-audits?tab=info&infoRequestId=1',
},
'store.withdraw_pending': {
vars: {
storeName: '示例门店',
withdrawNo: 'SW202608200001',
amount: '500.00',
payoutCount: '5',
storeId: '1',
},
handlePath: '/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=1',
},
'invoice.pending': {
vars: {
invoiceNo: 'IV202608200001',
orderNo: 'DK202608200001',
payAmount: '199.00',
titleName: '示例公司',
phoneMasked: '138****8000',
},
handlePath: '/invoices?status=PENDING&invoiceNo=IV202608200001',
},
};
return samples[eventKey];
}
private assertTemplateKey(eventKey: string): asserts eventKey is WecomTemplateEventKey {
if (!(WECOM_TEMPLATE_EVENT_KEYS as readonly string[]).includes(eventKey)) {
throw new BadRequestException(`无效模板事件:${eventKey}`);
}
}
templateToDto(row: TemplateRow): WecomPushTemplateDto {
return {
id: row.id.toString(),
eventKey: row.eventKey as WecomTemplateEventKey,
title: row.title,
body: row.body,
handleLabel: row.handleLabel,
updatedAt: row.updatedAt.toISOString(),
};
}
toDto(row: PushRow): WecomMessagePushDto {
return {
id: row.id.toString(),
@@ -0,0 +1,157 @@
import type { WecomTemplateEventKey } from '@dukang/shared-types';
export type WecomTemplateDefault = {
eventKey: WecomTemplateEventKey;
title: string;
body: string;
handleLabel: string;
};
/** 代码内默认文案;ensureDefaults 仅在库中无行时写入,不覆盖 HQ 已改 */
export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
{
eventKey: 'order.paid',
title: '订单支付成功',
body: [
'**订单支付成功**',
'订单号:{{orderNo}}',
'实付:¥{{payAmount}}',
'城市:{{cityName}}',
'商品:{{skuSummary}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'redeem.success',
title: '门店核销成功',
body: [
'**门店核销成功**',
'核销单号:{{redeemNo}}',
'金额:¥{{amount}}',
'门店:{{storeName}}',
'渠道:{{channel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.audit_pending',
title: '门店审核待处理',
body: [
'**门店审核待处理 · {{action}}**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'合伙人:{{partnerLabel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.package_audit_pending',
title: '套餐变更待审核',
body: [
'**套餐变更待审核**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'套餐条数:{{packageCount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.info_change_pending',
title: '门店信息变更待审',
body: [
'**门店信息变更待审**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'变更字段:{{changedFields}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'store.withdraw_pending',
title: '门店提现待审',
body: [
'**门店提现待审**',
'门店:{{storeName}}',
'提现单号:{{withdrawNo}}',
'金额:¥{{amount}}',
'明细笔数:{{payoutCount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
{
eventKey: 'invoice.pending',
title: '发票申请待开票',
body: [
'**发票申请待开票**',
'申请单号:{{invoiceNo}}',
'订单号:{{orderNo}}',
'金额:¥{{payAmount}}',
'抬头:{{titleName}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
];
export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
return WECOM_PUSH_TEMPLATE_DEFAULTS.find((t) => t.eventKey === eventKey);
}
/** 将 body 中的 {{key}} 替换为 vars;缺失置空 */
export function renderWecomTemplate(
body: string,
vars: Record<string, string | number | null | undefined>,
): string {
return body.replace(/\{\{(\w+)\}\}/g, (_m, key: string) => {
const v = vars[key];
if (v == null) return '';
return String(v);
});
}
/**
* HQ 后台公网根地址(无尾斜杠)。
* 优先 HQ_ADMIN_PUBLIC_URL;未配时按 WECOM_ALERT_ENV_LABEL / NODE_ENV 回退,
* 避免相对路径被企微解析成 http://orders/... 这类无效链接。
*/
export function resolveHqAdminPublicBase(): string {
const configured = (process.env.HQ_ADMIN_PUBLIC_URL || '').trim().replace(/\/$/, '');
if (configured) return configured;
const label = (process.env.WECOM_ALERT_ENV_LABEL || process.env.NODE_ENV || '')
.trim()
.toLowerCase();
if (label === 'production' || label === 'prod') {
return 'https://admin.dukanghaoke.com';
}
if (label === 'staging' || label === 'test' || label === 'testing') {
return 'https://admin-test.dukanghaoke.com';
}
// local / development / 未识别:本机 admin-web 默认端口
return 'http://localhost:5175';
}
export function buildHqHandleUrl(pathWithQuery: string): string {
const base = resolveHqAdminPublicBase();
let path = (pathWithQuery || '').trim();
if (!path) return base;
if (!path.startsWith('/')) path = `/${path}`;
return `${base}${path}`;
}
@@ -6,6 +6,14 @@ import {
normalizeTestPhone,
} from '../../common/test-whitelist/test-whitelist.service';
import { groupResourcesByProductId, mapProductMedia } from './catalog.mapper';
import {
flattenSkuOntoProduct,
listMinOnSalePrice,
mapSkuDto,
mapSpecAttrsDto,
pickDisplaySku,
resolveOrderSale,
} from './product-sku.util';
export type CatalogViewer = {
/** C 端用户手机号;无则无法看到白名单商品 */
@@ -81,6 +89,8 @@ export class CatalogService {
orderBy: { sortOrder: 'asc' },
include: {
coverResource: true,
skus: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
specAttrs: { select: { id: true } },
},
});
@@ -106,12 +116,25 @@ export class CatalogService {
return serializeBigInt(
visible.map((p) => {
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 {
...rest,
benefitAmount: p.benefitAmount ?? p.price,
price: Number(p.price),
benefitDisplay: Number(p.benefitAmount ?? p.price),
skuCode: flat.skuCode,
barcode69: undefined,
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,
};
}),
@@ -123,6 +146,14 @@ export class CatalogService {
where: { id },
include: {
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;
@@ -144,30 +175,57 @@ export class CatalogService {
});
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({
...rest,
benefitAmount: product.benefitAmount ?? product.price,
price: Number(product.price),
skuCode: flat.skuCode,
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,
});
}
/** 下单前校验:白名单商品仅全局测试白名单手机号可买 */
async assertPurchasable(productId: bigint, viewerPhone?: string | null) {
/** 下单前校验:白名单商品仅全局测试白名单手机号可买;无 SKU 时回落 SPU 字段 */
async assertPurchasable(
productId: bigint,
viewerPhone?: string | null,
skuId?: string | null,
options?: { bypassWhitelist?: boolean },
) {
const product = await this.prisma.commonProductItem.findUnique({
where: { id: productId },
include: { skus: true },
});
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
if (product.visibilityWhitelistEnabled) {
if (!options?.bypassWhitelist && product.visibilityWhitelistEnabled) {
const ok = await this.testWhitelist.isPhoneInWhitelist(viewerPhone);
if (!ok) {
throw new BadRequestException('该商品暂不对当前账号开放');
}
}
return product;
const sale = resolveOrderSale(product, product.skus ?? [], skuId);
return { product, sale };
}
async resolveUserPhone(userId: bigint): Promise<string | null> {
@@ -177,4 +235,12 @@ export class CatalogService {
});
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;
}
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
if (shouldHoldAutoCourierDispatch(order.quantity)) {
const boxes = calcOrderBoxCount(order.quantity);
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送(按瓶当量)
const bottleQty = order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1);
if (shouldHoldAutoCourierDispatch(bottleQty)) {
const boxes = calcOrderBoxCount(bottleQty);
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({
where: { id: orderId },
@@ -97,7 +98,7 @@ export class FulfillmentService {
refType: 'ORDER',
refId: order.id,
status: 'PENDING',
errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
errorMessage: `大单拦截:${bottleQty}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
0,
512,
),
@@ -155,7 +156,7 @@ export class FulfillmentService {
addressDetail: order.receiverAddress,
},
goodsName: order.productName,
goodsNum: order.quantity,
goodsNum: order.quantity * (order.bottlesPerUnit > 0 ? order.bottlesPerUnit : 1),
weight: 2,
payMode: CourierPayMode.SENDER,
remark: `仓配自动发货 ${order.orderNo}`,
@@ -19,11 +19,13 @@ export class AdminInvoicesController {
@Get()
list(
@Query('status') status?: string,
@Query('invoiceNo') invoiceNo?: string,
@Query('page') page = '1',
@Query('pageSize') pageSize = '20',
) {
return this.tradeService.adminListInvoices({
status,
invoiceNo,
page: Number(page),
pageSize: Number(pageSize),
});
@@ -4,7 +4,12 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminProductsService } from './admin-products.service';
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')
@UseGuards(HqAuthGuard)
@@ -33,6 +38,18 @@ export class AdminProductsController {
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')
@HqOperation({ action: HqOperationAction.PRODUCT_DELETE, refType: 'PRODUCT', refIdParam: 'id' })
remove(@Param('id') id: string) {
@@ -1,10 +1,22 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { BOTTLES_PER_BOX } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
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 { 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';
function normalizePhones(phones?: string[]): string[] {
@@ -27,6 +39,13 @@ function normalizePhones(phones?: string[]): string[] {
const SKU_AUTO_PREFIX = 'DK';
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: {
@@ -80,6 +99,8 @@ export class AdminProductsService {
include: {
coverResource: true,
visibilityPhones: { select: { phone: true }, orderBy: { phone: 'asc' } },
skus: { select: { id: true }, take: 2 },
specAttrs: { select: { id: true } },
},
}),
this.prisma.commonProductItem.count({ where }),
@@ -100,7 +121,14 @@ export class AdminProductsService {
const resourceMap = groupResourcesByProductId(resources);
return serializeBigInt({
items: items.map((p) => this.formatProduct(p, resourceMap.get(p.id.toString()) ?? [])),
items: items.map((p) => {
const { skus, specAttrs, ...rest } = p;
return {
...this.formatProduct(rest as never, resourceMap.get(p.id.toString()) ?? []),
specEnabled: specAttrs.length > 0 || skus.length > 1,
skuCount: skus.length,
};
}),
total,
page,
pageSize,
@@ -113,6 +141,14 @@ export class AdminProductsService {
include: {
coverResource: true,
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('商品不存在');
@@ -127,11 +163,18 @@ export class AdminProductsService {
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) {
const barcodeExists = await this.prisma.commonProductItem.findFirst({
const barcodeExists = await this.prisma.commonProductSku.findFirst({
where: { barcode69: dto.barcode69 },
});
if (barcodeExists) throw new BadRequestException('69 码已存在');
@@ -147,7 +190,6 @@ export class AdminProductsService {
if (whitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 手机号统一在「白名单管理」维护;此处忽略分实体 phones(兼容旧客户端传参)
void phones;
const product = await this.createWithGeneratedSku({
@@ -169,6 +211,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) {
await this.syncCover(product.id, dto.coverUrl);
}
@@ -230,7 +292,9 @@ export class AdminProductsService {
if (dto.visibilityWhitelistEnabled) {
await this.testWhitelist.assertGlobalWhitelistNotEmpty();
}
// 分实体手机号已废弃;忽略 dto.visibilityPhones
// 无规格 payload 时:同步默认 SKU(兼容旧 admin 表单)
await this.syncDefaultSkuFromProduct(id);
if (dto.coverUrl) {
await this.syncCover(id, dto.coverUrl);
@@ -243,6 +307,305 @@ export class AdminProductsService {
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) {
const product = await this.prisma.commonProductItem.findUnique({ where: { id } });
if (!product) throw new NotFoundException('商品不存在');
@@ -260,21 +623,98 @@ export class AdminProductsService {
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,冲突重试 */
private async nextAutoSkuCode(): Promise<string> {
const rows = await this.prisma.commonProductItem.findMany({
where: { skuCode: { startsWith: SKU_AUTO_PREFIX } },
select: { skuCode: true },
});
const [code] = await this.allocateAutoSkuCodesTx(this.prisma, 1);
return code;
}
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;
const re = new RegExp(`^${SKU_AUTO_PREFIX}(\\d+)$`);
for (const row of rows) {
const m = re.exec(row.skuCode);
for (const row of [...fromItem, ...fromSku]) {
const m = SKU_AUTO_RE.exec(row.skuCode);
if (!m) continue;
const n = Number(m[1]);
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(
@@ -300,16 +740,6 @@ export class AdminProductsService {
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(
product: Prisma.CommonProductItemGetPayload<{
include: {
@@ -0,0 +1,69 @@
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import type { UpdateWecomPushTemplateRequest } from '@dukang/shared-types';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import {
HqPermissionGuard,
RequireHqPermissions,
} from '../../common/guards/hq-permission.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
@Controller('admin/wecom-push-templates')
@UseGuards(HqAuthGuard, HqPermissionGuard)
@RequireHqPermissions('wecom_bots')
export class AdminWecomPushTemplatesController {
constructor(private readonly wecomPush: WecomMessagePushService) {}
@Get()
list() {
return this.wecomPush.listTemplates();
}
@Get(':eventKey')
detail(@Param('eventKey') eventKey: string) {
return this.wecomPush.getTemplate(eventKey);
}
@Put(':eventKey')
@HqOperation({
action: HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE,
refType: 'WECOM_PUSH_TEMPLATE',
refIdField: 'eventKey',
includeBody: true,
})
update(@Param('eventKey') eventKey: string, @Body() body: UpdateWecomPushTemplateRequest) {
return this.wecomPush.updateTemplate(eventKey, body);
}
@Post(':eventKey/reset')
@HqOperation({
action: HqOperationAction.WECOM_MESSAGE_PUSH_UPDATE,
refType: 'WECOM_PUSH_TEMPLATE',
refIdField: 'eventKey',
})
reset(@Param('eventKey') eventKey: string) {
return this.wecomPush.resetTemplate(eventKey);
}
@Post(':eventKey/test')
@HqOperation({
action: HqOperationAction.WECOM_MESSAGE_PUSH_TEST,
refType: 'WECOM_PUSH_TEMPLATE',
refIdField: 'eventKey',
})
async test(@Param('eventKey') eventKey: string) {
const result = await this.wecomPush.testTemplate(eventKey);
if (!result.ok) throw new BadRequestException(result.message);
return result;
}
}
@@ -9,10 +9,12 @@ import {
IsObject,
IsOptional,
IsString,
MaxLength,
Min,
Max,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
export class UpdateStoreStatusDto {
@@ -1302,6 +1304,118 @@ export class UpdateProductDto {
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 {
@IsString()
icon: string;
@@ -17,6 +17,10 @@ export class HqProxyOrderPreviewDto {
@IsNotEmpty()
productId: string;
@IsOptional()
@IsString()
skuId?: string;
@Type(() => Number)
@IsInt()
@Min(1)
@@ -76,6 +80,10 @@ export class HqProxyOrderCreateDto {
@IsNotEmpty()
productId: string;
@IsOptional()
@IsString()
skuId?: string;
@Type(() => Number)
@IsInt()
@Min(1)
@@ -69,6 +69,7 @@ import { AdminWecomBotsController } from './admin-wecom-bots.controller';
import { AdminWecomBotsService } from './admin-wecom-bots.service';
import { AdminWecomMessagePushesController } from './admin-wecom-message-pushes.controller';
import { AdminWecomMessagePushesService } from './admin-wecom-message-pushes.service';
import { AdminWecomPushTemplatesController } from './admin-wecom-push-templates.controller';
import { AdminWecomBotLogsController } from './admin-wecom-bot-logs.controller';
import { AdminWecomBotLogsService } from './admin-wecom-bot-logs.service';
import { AdminLlmConfigsController } from './admin-llm-configs.controller';
@@ -124,6 +125,7 @@ import { AdminTestWhitelistController } from './admin-test-whitelist.controller'
AdminSystemConfigController,
AdminWecomBotsController,
AdminWecomMessagePushesController,
AdminWecomPushTemplatesController,
AdminWecomBotLogsController,
AdminLlmConfigsController,
AdminKnowledgeBasesController,
@@ -30,6 +30,7 @@ import { SettlementService } from '../settlement/settlement.service';
import { BenefitService } from '../benefit/benefit.service';
import { AuthService } from '../iam/auth.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
type TokenPayload = {
userId: string;
@@ -82,6 +83,7 @@ export class RedeemService {
private readonly analyticsService: AnalyticsService,
private readonly authService: AuthService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly wecomPush: WecomMessagePushService,
) {}
private maskPhoneForStore(phone: string) {
@@ -276,6 +278,22 @@ export class RedeemService {
extraJson: redeemExtra,
});
if (!isTest) {
const channelLabel = redeemChannel === 'PHONE' ? '手机号' : '扫码';
void this.wecomPush.dispatchEvent(
'redeem.success',
{
redeemNo: record.redeemNo,
amount: amountNum.toFixed(2),
storeName: account.store.name || String(account.storeId),
channel: channelLabel,
},
{
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
},
);
}
return {
...record,
amount: amountNum,
@@ -18,6 +18,7 @@ import {
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
@@ -86,6 +87,7 @@ export class SettlementService {
private readonly partnerCityService: PartnerCityService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
private readonly alert: AlertService,
private readonly wecomPush: WecomMessagePushService,
) {}
// ─── Store payout (line) ─────────────────────────────
@@ -386,20 +388,19 @@ export class SettlementService {
},
});
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现待审',
detail: [
`门店:${store.name}${store.cityName || '-'} / ${store.phone || '-'}`,
`单号:${created.withdrawNo}`,
`金额:¥${Number(created.amount).toFixed(2)}`,
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
].join('\n'),
dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
dedupeTtlSec: 3600,
});
void this.wecomPush.dispatchEvent(
'store.withdraw_pending',
{
storeName: store.name,
withdrawNo: created.withdrawNo,
amount: Number(created.amount).toFixed(2),
payoutCount: String(created.payoutCount),
storeId: storeId.toString(),
},
{
handlePath: `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=${storeId.toString()}`,
},
);
return serializeBigInt(created);
}
@@ -10,6 +10,7 @@ import {
} from '@dukang/domain';
import {
STORE_INFO_CHANGEABLE_FIELDS,
formatStoreInfoChangeFieldLabels,
type StoreInfoChangeFieldDiff,
type StoreInfoChangeRequestDto,
type StoreInfoChangeStatus,
@@ -19,6 +20,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { StoreService } from './store.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
@@ -142,6 +144,7 @@ export class StoreInfoChangeService {
private readonly prisma: PrismaService,
private readonly storeService: StoreService,
private readonly partnerCityService: PartnerCityService,
private readonly wecomPush: WecomMessagePushService,
) {}
private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) {
@@ -329,6 +332,22 @@ export class StoreInfoChangeService {
this.logger.log(
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
);
const submitterLabel = input.submitterType === 'PARTNER' ? '合伙人' : '门店';
void this.wecomPush.dispatchEvent(
'store.info_change_pending',
{
storeName: String(store.name ?? input.storeId),
cityName: String(store.cityName ?? '—'),
submitter: submitterLabel,
changedFields: formatStoreInfoChangeFieldLabels(changedFields),
requestId: created.id.toString(),
},
{
handlePath: `/store-package-audits?tab=info&infoRequestId=${created.id.toString()}`,
},
);
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
}
@@ -191,28 +191,18 @@ export class StorePackageService {
where: { id: storeId },
select: { name: true, cityName: true },
});
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店';
void this.wecomPush
.dispatchMarkdown(
'store.package_audit_pending',
[
'**套餐变更待审核**',
`门店:${store?.name ?? storeId}`,
store?.cityName ? `城市:${store.cityName}` : null,
`提交端:${submitterLabel}`,
`套餐条数:${packages.length}`,
`时间:${now}`,
]
.filter(Boolean)
.join('\n'),
{ applyMention: false },
)
.catch((e) =>
this.logger.warn(
`store.package_audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
),
);
void this.wecomPush.dispatchEvent(
'store.package_audit_pending',
{
storeName: store?.name ?? String(storeId),
cityName: store?.cityName || '—',
submitter: submitterLabel,
packageCount: String(packages.length),
requestId: req.id.toString(),
},
{ handlePath: `/store-package-audits?requestId=${req.id.toString()}` },
);
return serializeBigInt({
id: req.id.toString(),
@@ -78,26 +78,25 @@ export class StoreService {
/** 门店进入 PENDING 时通知企微(失败不挡业务) */
private notifyStoreAuditPending(opts: {
storeId: bigint;
storeName: string;
cityName?: string | null;
partnerLabel?: string | null;
submitType: '新建' | '重提';
}) {
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const lines = [
`**门店审核待处理 · ${opts.submitType}**`,
`门店:${opts.storeName}`,
opts.cityName ? `城市:${opts.cityName}` : null,
opts.partnerLabel ? `合伙人:${opts.partnerLabel}` : null,
`时间:${now}`,
].filter(Boolean);
void this.wecomPush
.dispatchMarkdown('store.audit_pending', lines.join('\n'), { applyMention: false })
.catch((e) =>
this.logger.warn(
`store.audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
),
);
void this.wecomPush.dispatchEvent(
'store.audit_pending',
{
storeName: opts.storeName,
cityName: opts.cityName || '—',
partnerLabel: opts.partnerLabel || '—',
action: opts.submitType,
storeId: opts.storeId.toString(),
},
{
handlePath: `/stores?auditStatus=PENDING&storeId=${opts.storeId.toString()}`,
},
);
}
private async whitelistPhoneSet(): Promise<Set<string>> {
@@ -593,6 +592,7 @@ export class StoreService {
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeId: store.id,
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
@@ -762,6 +762,7 @@ export class StoreService {
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeId: updated.id,
storeName: updated.name,
cityName: updated.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
@@ -886,6 +887,7 @@ export class StoreService {
select: { name: true, phone: true },
});
this.notifyStoreAuditPending({
storeId: store.id,
storeName: store.name,
cityName: store.cityName,
partnerLabel: partner?.name || partner?.phone || String(primaryId),
@@ -53,6 +53,12 @@ export class CreateInvoiceDto {
@IsIn(['NORMAL', 'SPECIAL'])
invoiceKind?: string;
/** 酒水类 / 餐饮类;缺省酒水类。C 端票种固定普通发票 */
@IsOptional()
@IsString()
@IsIn(['LIQUOR', 'CATERING'])
invoiceCategory?: string;
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
@IsString()
@IsNotEmpty()
@@ -17,6 +17,10 @@ export class PartnerProxyOrderPreviewDto {
@IsNotEmpty()
productId: string;
@IsOptional()
@IsString()
skuId?: string;
@Type(() => Number)
@IsInt()
@Min(1)
@@ -84,6 +88,10 @@ export class PartnerProxyOrderCreateDto {
@IsNotEmpty()
productId: string;
@IsOptional()
@IsString()
skuId?: string;
@Type(() => Number)
@IsInt()
@Min(1)
@@ -40,7 +40,7 @@ export class InvoiceTitleService {
titleType: body.titleType,
titleName: body.titleName.trim(),
taxNo: body.taxNo?.trim() || null,
email: body.email?.trim() || null,
email: body.email!.trim(),
phone: body.phone?.trim() || null,
addressPhone: body.addressPhone?.trim() || null,
bankAccount: body.bankAccount?.trim() || null,
@@ -73,7 +73,7 @@ export class InvoiceTitleService {
titleType: body.titleType,
titleName: body.titleName.trim(),
taxNo: body.taxNo?.trim() || null,
email: body.email?.trim() || null,
email: body.email!.trim(),
phone: body.phone?.trim() || null,
addressPhone: body.addressPhone?.trim() || null,
bankAccount: body.bankAccount?.trim() || null,
@@ -101,6 +101,13 @@ export class InvoiceTitleService {
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
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) {
throw new BadRequestException('titleType 必填');
}
@@ -11,6 +11,7 @@ import {
calcBenefitAmount,
generateOrderNo,
orderTabToStatuses,
toMinSaleQuantity,
validateMinPurchase,
} from '@dukang/domain';
import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types';
@@ -35,7 +36,9 @@ import { FulfillmentService } from '../fulfillment/fulfillment.service';
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
import { AlertService } from '../../common/alert/alert.service';
import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service';
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import type { Request } from 'express';
import type { ProductSaleUnit } from '@prisma/client';
@Injectable()
export class TradeService {
@@ -56,26 +59,70 @@ export class TradeService {
private readonly wechatOrderShipping: WechatOrderShippingService,
private readonly payRedeemAnomaly: PayRedeemAnomalyService,
private readonly alert: AlertService,
private readonly wecomPush: WecomMessagePushService,
) {}
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(
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 product = await this.catalogService.getProduct(BigInt(body.productId), { phone: viewerPhone });
if (!product || product.status !== 'ON_SALE') {
const { product: spu, sale } = await this.catalogService.assertPurchasable(
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('商品不可购买');
}
const product = this.overlayProductWithSale(productDto as Record<string, unknown>, sale);
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
const onSitePickup = !!body.onSitePickup;
if (onSitePickup && !product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场取货');
if (onSitePickup && !sale.allowOnSitePickup) {
throw new BadRequestException('该规格不支持现场取货');
}
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup
@@ -93,43 +140,46 @@ export class TradeService {
let addressOk = true;
let addressMessage: string | null = null;
if (!onSitePickup) {
const allowOnline = product.allowOnlinePurchase !== false;
const allowCross = product.allowCrossCityDelivery !== false;
const allowOnline = sale.allowOnlinePurchase !== false;
const allowCross = sale.allowCrossCityDelivery !== false;
if (deliveryType === 'LOCAL' && !allowOnline) {
addressOk = false;
addressMessage = '该商品不支持线上购买';
addressMessage = '该规格不支持线上购买';
} else if (deliveryType === 'CROSS_CITY') {
if (!allowOnline) {
addressOk = false;
addressMessage = '该商品不支持线上购买';
addressMessage = '该规格不支持线上购买';
} else if (!allowCross) {
addressOk = false;
addressMessage = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址';
}
}
}
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
const check = validateMinPurchase(
deliveryType,
body.quantity,
city.localMinQty,
city.crossMinQty,
{ bottlesPerUnit, saleUnit: sale.saleUnit },
);
const unitPrice = Number(product.price);
const unitPrice = Number(sale.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
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 minQty =
const minBottleQty =
deliveryType === 'ON_SITE_PICKUP'
? city.localMinQty
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
const minQty = toMinSaleQuantity(minBottleQty, bottlesPerUnit);
return {
product,
@@ -141,16 +191,21 @@ export class TradeService {
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
city: serializeBigInt(city),
/** 起购未满足时仍返回预览,供确认页改数量;下单接口仍会硬校验 */
quantityOk: check.ok,
quantityMessage: check.ok ? null : (check.message ?? null),
/** 地址/履约未满足时仍返回预览,供确认页提示换地址;下单接口仍会硬校验 */
addressOk,
addressMessage,
minQty,
onSitePickup,
allowCrossCityDelivery: product.allowCrossCityDelivery !== false,
allowOnlinePurchase: product.allowOnlinePurchase !== false,
allowCrossCityDelivery: sale.allowCrossCityDelivery !== false,
allowOnlinePurchase: sale.allowOnlinePurchase !== false,
skuId: sale.skuId?.toString(),
barcode69: sale.barcode69,
productSpec: sale.specText,
unitPrice,
saleUnit: sale.saleUnit,
bottlesPerUnit,
bottleQuantity: body.quantity * bottlesPerUnit,
};
}
@@ -162,6 +217,7 @@ export class TradeService {
addressId?: string;
onSitePickup?: boolean;
clientLocation?: unknown;
skuId?: string;
},
req: Request,
) {
@@ -237,12 +293,15 @@ export class TradeService {
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP',
productId: product.id,
barcode69: product.barcode69,
skuId: preview.skuId ? BigInt(preview.skuId) : null,
barcode69: preview.barcode69 || product.barcode69,
productName: product.name,
productSpec: product.spec,
productSpec: preview.productSpec || product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
saleUnit: preview.saleUnit,
bottlesPerUnit: preview.bottlesPerUnit,
listUnitPrice: preview.unitPrice,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
receiverName,
@@ -298,6 +357,7 @@ export class TradeService {
extraJson: {
orderId: order.id.toString(),
productId: body.productId,
skuId: preview.skuId,
quantity: body.quantity,
onSitePickup,
},
@@ -429,9 +489,33 @@ export class TradeService {
private async afterOrderPaid(orderId: bigint) {
await this.benefitService.grantOnOrderPaid(orderId);
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: {
city: { select: { name: true } },
user: { select: { phone: true } },
},
});
if (!order) return;
if (!order.isTest) {
const skuSummary = `${order.productName}×${order.quantity}`;
const phone = order.user?.phone || '';
const phoneMasked =
phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—';
void this.wecomPush.dispatchEvent(
'order.paid',
{
orderNo: order.orderNo,
payAmount: Number(order.payAmount).toFixed(2),
cityName: order.city?.name || '—',
skuSummary,
phoneMasked,
},
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
);
}
const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
if (!delivery) {
await this.prisma.orderDelivery.create({
@@ -1007,6 +1091,7 @@ export class TradeService {
titleId?: string;
titleType?: string;
invoiceKind?: string;
invoiceCategory?: string;
titleName?: string;
taxNo?: string | null;
addressPhone?: string | null;
@@ -1015,6 +1100,7 @@ export class TradeService {
phone?: string;
remark?: string;
},
opts?: { allowSpecialKind?: boolean },
) {
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
if (!order) throw new NotFoundException('订单不存在');
@@ -1064,7 +1150,8 @@ export class TradeService {
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
throw new BadRequestException('企业抬头须填写税号');
}
const invoiceKind = body.invoiceKind || 'NORMAL';
const invoiceKind =
opts?.allowSpecialKind && body.invoiceKind === 'SPECIAL' ? 'SPECIAL' : 'NORMAL';
if (invoiceKind === 'SPECIAL') {
if (resolved.titleType !== 'ENTERPRISE') {
throw new BadRequestException('专用发票仅支持企业抬头');
@@ -1073,6 +1160,7 @@ export class TradeService {
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
}
}
const invoiceCategory = body.invoiceCategory === 'CATERING' ? 'CATERING' : 'LIQUOR';
const invoice = await this.prisma.userInvoice.create({
data: {
@@ -1081,6 +1169,7 @@ export class TradeService {
userId,
titleType: resolved.titleType as never,
invoiceKind: invoiceKind as never,
invoiceCategory: invoiceCategory as never,
titleName: resolved.titleName.trim(),
taxNo: resolved.taxNo?.trim() || null,
addressPhone: resolved.addressPhone?.trim() || null,
@@ -1088,8 +1177,28 @@ export class TradeService {
email: resolved.email.trim(),
phone: resolved.phone.trim(),
remark: body.remark?.trim() || null,
},
} as never,
});
if (!order.isTest) {
const phone = resolved.phone.trim();
const phoneMasked =
phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—';
void this.wecomPush.dispatchEvent(
'invoice.pending',
{
invoiceNo: invoice.invoiceNo,
orderNo: order.orderNo,
payAmount: Number(order.payAmount).toFixed(2),
titleName: invoice.titleName,
phoneMasked,
},
{
handlePath: `/invoices?status=PENDING&invoiceNo=${encodeURIComponent(invoice.invoiceNo)}`,
},
);
}
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
}
@@ -1161,6 +1270,7 @@ export class TradeService {
titleId?: string;
titleType?: string;
invoiceKind?: string;
invoiceCategory?: string;
titleName?: string;
taxNo?: string;
addressPhone?: string;
@@ -1174,14 +1284,20 @@ export class TradeService {
if (!orderNo) throw new BadRequestException('请填写订单号');
const order = await this.prisma.order.findFirst({ where: { orderNo } });
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: { status?: string; page?: number; pageSize?: number }) {
async adminListInvoices(query: {
status?: string;
invoiceNo?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: { status?: never } = {};
const where: { status?: never; invoiceNo?: { contains: string } } = {};
if (query.status) where.status = query.status as never;
if (query.invoiceNo?.trim()) where.invoiceNo = { contains: query.invoiceNo.trim() };
const [items, total] = await Promise.all([
this.prisma.userInvoice.findMany({
where,
@@ -1451,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({
products: products.map((p) => ({
id: p.id,
name: p.name,
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
products: details.filter(Boolean).map((p) => ({
id: p!.id,
name: p!.name,
spec: p!.spec,
price: Number(p!.price),
benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false,
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,
stores: stores.map((s) => ({
@@ -1485,13 +1611,25 @@ export class TradeService {
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
skuId?: string;
},
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
) {
const product = await this.catalogService.getProduct(BigInt(body.productId), viewer ?? {});
if (!product || product.status !== 'ON_SALE') {
const { product: spu, sale } = await this.catalogService.assertPurchasable(
BigInt(body.productId),
viewer?.phone,
body.skuId,
{ bypassWhitelist: !!viewer?.bypassWhitelist },
);
if (spu.status !== 'ON_SALE') {
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' } });
if (!city) throw new BadRequestException('暂无开城城市');
@@ -1499,8 +1637,8 @@ export class TradeService {
let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL';
if (deliveryMode === 'ON_SITE_PICKUP') {
if (!product.allowOnSitePickup) {
throw new BadRequestException('该商品不支持现场提货');
if (!sale.allowOnSitePickup) {
throw new BadRequestException('该规格不支持现场提货');
}
deliveryType = 'ON_SITE_PICKUP';
} else {
@@ -1508,34 +1646,36 @@ export class TradeService {
if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') {
deliveryType = 'CROSS_CITY';
}
const allowOnline = product.allowOnlinePurchase !== false;
const allowCross = product.allowCrossCityDelivery !== false;
const allowOnline = sale.allowOnlinePurchase !== false;
const allowCross = sale.allowCrossCityDelivery !== false;
if (deliveryType === 'LOCAL' && !allowOnline) {
throw new BadRequestException('该商品不支持线上购买');
throw new BadRequestException('该规格不支持线上购买');
}
if (deliveryType === 'CROSS_CITY') {
if (!allowOnline) {
throw new BadRequestException('该商品不支持线上购买');
throw new BadRequestException('该规格不支持线上购买');
}
if (!allowCross) {
throw new BadRequestException('该商品不支持跨城配送');
throw new BadRequestException('该规格不支持跨城配送');
}
}
}
const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1;
const check = validateMinPurchase(
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL',
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL',
body.quantity,
city.localMinQty,
city.crossMinQty,
{ bottlesPerUnit, saleUnit: sale.saleUnit },
);
if (!check.ok) throw new BadRequestException(check.message);
const unitPrice = Number(product.price);
const unitPrice = Number(sale.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null,
});
return {
@@ -1544,6 +1684,16 @@ export class TradeService {
benefitAmount: benefitPerUnit * body.quantity,
deliveryType,
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,
),
};
}
@@ -1557,6 +1707,7 @@ export class TradeService {
storeId?: string;
receiverCity?: string;
receiverDistrict?: string;
skuId?: string;
},
) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
@@ -1578,6 +1729,7 @@ export class TradeService {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
},
req: Request,
) {
@@ -1614,6 +1766,7 @@ export class TradeService {
storeId: body.storeId,
receiverCity: body.city,
receiverDistrict: body.district,
skuId: body.skuId,
},
{ phone: primary.phone },
);
@@ -1678,12 +1831,15 @@ export class TradeService {
channelSource: 'PROXY_ONLINE',
promoCodeId,
productId: product.id,
barcode69: product.barcode69,
skuId: preview.skuId ? BigInt(preview.skuId) : null,
barcode69: preview.barcode69 || product.barcode69,
productName: product.name,
productSpec: product.spec,
productSpec: preview.productSpec || product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
saleUnit: preview.saleUnit,
bottlesPerUnit: preview.bottlesPerUnit,
listUnitPrice: preview.unitPrice,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
payAmount: preview.payAmount,
@@ -1941,18 +2097,28 @@ export class TradeService {
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
this.promoCodeService.listActiveOptions(),
]);
const details = await Promise.all(
products.map((p) =>
this.catalogService.getProduct(BigInt(String(p.id)), { bypassWhitelist: true }),
),
);
return serializeBigInt({
products: products.map((p) => ({
id: p.id,
name: p.name,
spec: p.spec,
price: Number(p.price),
benefitAmount: p.benefitAmount != null ? Number(p.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
products: details.filter(Boolean).map((p) => ({
id: p!.id,
name: p!.name,
spec: p!.spec,
price: Number(p!.price),
benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null,
coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false,
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,
stores: [],
@@ -1973,6 +2139,7 @@ export class TradeService {
productId: string;
quantity: number;
promoCodeId?: string;
skuId?: string;
},
req: Request,
) {
@@ -2012,6 +2179,7 @@ export class TradeService {
deliveryMode: body.deliveryMode,
receiverCity: body.city,
receiverDistrict: body.district,
skuId: body.skuId,
},
{ bypassWhitelist: true },
);
@@ -2076,12 +2244,15 @@ export class TradeService {
channelSource: 'PROXY_ONLINE',
promoCodeId,
productId: product.id,
barcode69: product.barcode69,
skuId: preview.skuId ? BigInt(preview.skuId) : null,
barcode69: preview.barcode69 || product.barcode69,
productName: product.name,
productSpec: product.spec,
productSpec: preview.productSpec || product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
saleUnit: preview.saleUnit,
bottlesPerUnit: preview.bottlesPerUnit,
listUnitPrice: preview.unitPrice,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
payAmount: preview.payAmount,