fix(admin): contract preview, package audit detail, empty packages, winery status
Allow HQ to save stores with zero packages, preview contract images/PDFs, inspect package audit images/content, and show gray 无需打款 when winery payable is 0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,7 +59,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
sortOrder: i,
|
sortOrder: i,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [emptyRow()],
|
: [],
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
@@ -124,7 +124,13 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
|
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
|
||||||
|
.filter((item) => {
|
||||||
|
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
|
||||||
|
const hasImages = item.imageUrls.length > 0;
|
||||||
|
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
|
||||||
|
return hasText || hasImages || hasNonZeroPrice;
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 0; i < filled.length; i++) {
|
for (let i = 0; i < filled.length; i++) {
|
||||||
const item = filled[i];
|
const item = filled[i];
|
||||||
@@ -168,7 +174,7 @@ const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId
|
|||||||
sortOrder: i,
|
sortOrder: i,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [emptyRow()],
|
: [],
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||||
|
|
||||||
@@ -15,6 +15,14 @@ type OssUploadProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isImageUrl(url: string) {
|
||||||
|
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfUrl(url: string) {
|
||||||
|
return /\.pdf(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OssUpload({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -25,6 +33,7 @@ export default function OssUpload({
|
|||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||||
@@ -47,6 +56,57 @@ export default function OssUpload({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filePreview =
|
||||||
|
value && mediaType === 'FILE' ? (
|
||||||
|
isImageUrl(value) ? (
|
||||||
|
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
) : isPdfUrl(value) ? (
|
||||||
|
<Space direction="vertical" size={8}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
color: '#cf1322',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 36 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF 合同</span>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
|
||||||
|
新窗口打开
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Modal
|
||||||
|
title="签约合同预览"
|
||||||
|
open={pdfPreviewOpen}
|
||||||
|
onCancel={() => setPdfPreviewOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width="90vw"
|
||||||
|
styles={{ body: { height: '75vh', padding: 0 } }}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
|
||||||
|
</Modal>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
|
||||||
|
打开已上传文件
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
{value && mediaType === 'IMAGE' && (
|
{value && mediaType === 'IMAGE' && (
|
||||||
@@ -55,6 +115,7 @@ export default function OssUpload({
|
|||||||
{value && mediaType === 'VIDEO' && (
|
{value && mediaType === 'VIDEO' && (
|
||||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||||
)}
|
)}
|
||||||
|
{filePreview}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Upload
|
<Upload
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Image,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
@@ -17,7 +18,10 @@ import type {
|
|||||||
StorePackageItemDto,
|
StorePackageItemDto,
|
||||||
StorePackageViewDto,
|
StorePackageViewDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
import {
|
||||||
|
STORE_PACKAGE_CHANGE_STATUS_LABELS,
|
||||||
|
normalizeStorePackageImageUrls,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
@@ -26,6 +30,10 @@ function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: numbe
|
|||||||
return name ? `name:${name}` : `idx:${index}`;
|
return name ? `name:${name}` : `idx:${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||||
|
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||||
@@ -49,7 +57,8 @@ function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto
|
|||||||
l.price !== p.price ||
|
l.price !== p.price ||
|
||||||
l.dishes !== p.dishes ||
|
l.dishes !== p.dishes ||
|
||||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
|
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||||
|
imageSignature(l) !== imageSignature(p);
|
||||||
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +72,108 @@ const CHANGE_LABELS = {
|
|||||||
unchanged: { text: '未变', color: 'default' },
|
unchanged: { text: '未变', color: 'default' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
function PackageDetailCard({
|
||||||
|
title,
|
||||||
|
pkg,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||||
|
}) {
|
||||||
|
const images = normalizeStorePackageImageUrls(pkg);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 12,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{title}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>{pkg.name}</strong>
|
||||||
|
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||||
|
</div>
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 8, whiteSpace: 'pre-wrap' }}>
|
||||||
|
{pkg.dishes || '—'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{pkg.usableTime ? (
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<Typography.Text type="secondary">可用时间:{pkg.usableTime}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{pkg.otherNotes ? (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Typography.Text type="secondary">其他说明:{pkg.otherNotes}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{images.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{images.map((url) => (
|
||||||
|
<Image
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageSummaryCell({ pkg }: { pkg?: StorePackageItemDto | StorePackageViewDto }) {
|
||||||
|
if (!pkg) return <>—</>;
|
||||||
|
const images = normalizeStorePackageImageUrls(pkg);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<strong>{pkg.name}</strong> · ¥{pkg.price}
|
||||||
|
</div>
|
||||||
|
<Typography.Text type="secondary">{pkg.dishes}</Typography.Text>
|
||||||
|
{pkg.usableTime ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">可用:{pkg.usableTime}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{pkg.otherNotes ? (
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">备注:{pkg.otherNotes}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{images.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={4} style={{ marginTop: 8 }}>
|
||||||
|
{images.slice(0, 4).map((url) => (
|
||||||
|
<Image
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{images.length > 4 ? (
|
||||||
|
<Typography.Text type="secondary">+{images.length - 4}</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function StorePackageAuditsPage() {
|
export default function StorePackageAuditsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||||
@@ -75,6 +186,11 @@ export default function StorePackageAuditsPage() {
|
|||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||||
|
const [pkgDetailOpen, setPkgDetailOpen] = useState(false);
|
||||||
|
const [pkgDetailTitle, setPkgDetailTitle] = useState('');
|
||||||
|
const [pkgDetailList, setPkgDetailList] = useState<Array<StorePackageItemDto | StorePackageViewDto>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
async function reload(nextPage = page, nextStatus = status) {
|
async function reload(nextPage = page, nextStatus = status) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -116,6 +232,15 @@ export default function StorePackageAuditsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openPackageDetails(
|
||||||
|
title: string,
|
||||||
|
list: Array<StorePackageItemDto | StorePackageViewDto>,
|
||||||
|
) {
|
||||||
|
setPkgDetailTitle(title);
|
||||||
|
setPkgDetailList(list);
|
||||||
|
setPkgDetailOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
try {
|
try {
|
||||||
await request(`/admin/store-package-audits/${id}/audit`, {
|
await request(`/admin/store-package-audits/${id}/audit`, {
|
||||||
@@ -146,39 +271,11 @@ export default function StorePackageAuditsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '当前线上',
|
title: '当前线上',
|
||||||
render: (_, row) =>
|
render: (_, row) => <PackageSummaryCell pkg={row.live} />,
|
||||||
row.live ? (
|
|
||||||
<div>
|
|
||||||
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
|
|
||||||
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
|
|
||||||
{row.live.usableTime ? (
|
|
||||||
<div><Typography.Text type="secondary">可用:{row.live.usableTime}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
{row.live.otherNotes ? (
|
|
||||||
<div><Typography.Text type="secondary">备注:{row.live.otherNotes}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '申请变更',
|
title: '申请变更',
|
||||||
render: (_, row) =>
|
render: (_, row) => <PackageSummaryCell pkg={row.proposed} />,
|
||||||
row.proposed ? (
|
|
||||||
<div>
|
|
||||||
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
|
|
||||||
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
|
|
||||||
{row.proposed.usableTime ? (
|
|
||||||
<div><Typography.Text type="secondary">可用:{row.proposed.usableTime}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
{row.proposed.otherNotes ? (
|
|
||||||
<div><Typography.Text type="secondary">备注:{row.proposed.otherNotes}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -257,7 +354,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||||
width={720}
|
width={880}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -282,7 +379,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<>
|
<>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||||
@@ -291,9 +388,27 @@ export default function StorePackageAuditsPage() {
|
|||||||
{detail.rejectReason ? (
|
{detail.rejectReason ? (
|
||||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography.Paragraph type="secondary">
|
<Space style={{ marginBottom: 12 }} wrap>
|
||||||
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
<Typography.Text type="secondary">
|
||||||
</Typography.Paragraph>
|
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
||||||
|
</Typography.Text>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
openPackageDetails('申请套餐详情', detail.packages ?? [])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
查看申请套餐详情
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
openPackageDetails('线上套餐详情', detail.livePackages ?? [])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
查看线上套餐详情
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
rowKey="key"
|
rowKey="key"
|
||||||
@@ -305,6 +420,23 @@ export default function StorePackageAuditsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={pkgDetailTitle || '套餐详情'}
|
||||||
|
open={pkgDetailOpen}
|
||||||
|
onCancel={() => setPkgDetailOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{pkgDetailList.length ? (
|
||||||
|
pkgDetailList.map((pkg, index) => (
|
||||||
|
<PackageDetailCard key={`${pkg.name}-${index}`} title={`套餐 ${index + 1}`} pkg={pkg} />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="驳回套餐变更"
|
title="驳回套餐变更"
|
||||||
open={rejectOpen}
|
open={rejectOpen}
|
||||||
|
|||||||
@@ -51,13 +51,35 @@ type BillItem = {
|
|||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
UNPAID: '未打款',
|
UNPAID: '未打款',
|
||||||
PAID: '已打款',
|
PAID: '已打款',
|
||||||
|
NO_PAYMENT_NEEDED: '无需打款',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
UNPAID: 'red',
|
UNPAID: 'red',
|
||||||
PAID: 'green',
|
PAID: 'green',
|
||||||
|
NO_PAYMENT_NEEDED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isZeroPayable(amount: number | string | null | undefined) {
|
||||||
|
return Number(amount ?? 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
|
||||||
|
function displayWineryStatus(status: string, wineryAmount: number | string) {
|
||||||
|
if (isZeroPayable(wineryAmount)) {
|
||||||
|
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: status,
|
||||||
|
label: STATUS_LABELS[status] || status,
|
||||||
|
color: STATUS_COLORS[status] || 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
|
||||||
|
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
|
||||||
|
}
|
||||||
|
|
||||||
const DELIVERY_LABELS: Record<string, string> = {
|
const DELIVERY_LABELS: Record<string, string> = {
|
||||||
LOCAL: '同城',
|
LOCAL: '同城',
|
||||||
CROSS_CITY: '跨城',
|
CROSS_CITY: '跨城',
|
||||||
@@ -212,7 +234,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒厂应付',
|
title: '应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
@@ -220,8 +242,11 @@ export default function WineryBillsPage() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 100,
|
||||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
render: (s, row) => {
|
||||||
|
const d = displayWineryStatus(s, row.wineryAmount);
|
||||||
|
return <Tag color={d.color}>{d.label}</Tag>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -232,7 +257,7 @@ export default function WineryBillsPage() {
|
|||||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
明细
|
明细
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'UNPAID' && (
|
{canConfirmWineryPay(row) && (
|
||||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||||
确认打款
|
确认打款
|
||||||
</Button>
|
</Button>
|
||||||
@@ -258,7 +283,8 @@ export default function WineryBillsPage() {
|
|||||||
酒厂对账单
|
酒厂对账单
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||||||
|
无需打款(灰),可展开订单明细
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
{canEditWineryBank ? (
|
{canEditWineryBank ? (
|
||||||
@@ -273,7 +299,7 @@ export default function WineryBillsPage() {
|
|||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
<Statistic title="账单数" value={summary.count} />
|
<Statistic title="账单数" value={summary.count} />
|
||||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -296,7 +322,7 @@ export default function WineryBillsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 130 }}
|
||||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -347,7 +373,7 @@ export default function WineryBillsPage() {
|
|||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
onChange: setSelectedKeys,
|
onChange: setSelectedKeys,
|
||||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -368,8 +394,10 @@ export default function WineryBillsPage() {
|
|||||||
<Descriptions column={1} size="small" bordered>
|
<Descriptions column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
<Descriptions.Item label="状态">
|
||||||
|
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
@@ -395,7 +423,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒厂应付',
|
title: '应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
|||||||
@@ -1708,6 +1708,9 @@ export class SettlementService {
|
|||||||
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
|
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
|
||||||
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
if (!bill) throw new NotFoundException('酒厂对账单不存在');
|
||||||
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
|
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
|
||||||
|
if (Number(bill.wineryAmount) === 0) {
|
||||||
|
throw new BadRequestException('应付为 0,无需打款');
|
||||||
|
}
|
||||||
const updated = await this.prisma.wineryBill.update({
|
const updated = await this.prisma.wineryBill.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: 'PAID', paidAt: new Date() },
|
data: { status: 'PAID', paidAt: new Date() },
|
||||||
@@ -1749,11 +1752,17 @@ export class SettlementService {
|
|||||||
'配送类型',
|
'配送类型',
|
||||||
'实付金额',
|
'实付金额',
|
||||||
'酒厂比例',
|
'酒厂比例',
|
||||||
'酒厂应付',
|
'应付',
|
||||||
'支付时间',
|
'支付时间',
|
||||||
'账单状态',
|
'账单状态',
|
||||||
].join(',');
|
].join(',');
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
|
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
|
||||||
|
if (Number(b.wineryAmount) === 0) return '无需打款';
|
||||||
|
if (b.status === 'PAID') return '已打款';
|
||||||
|
if (b.status === 'UNPAID') return '未打款';
|
||||||
|
return b.status;
|
||||||
|
};
|
||||||
for (const b of bills) {
|
for (const b of bills) {
|
||||||
if (b.items.length === 0) {
|
if (b.items.length === 0) {
|
||||||
rows.push(
|
rows.push(
|
||||||
@@ -1766,7 +1775,7 @@ export class SettlementService {
|
|||||||
Number(b.wineryRate),
|
Number(b.wineryRate),
|
||||||
Number(b.wineryAmount),
|
Number(b.wineryAmount),
|
||||||
'',
|
'',
|
||||||
b.status,
|
statusLabel(b),
|
||||||
].join(','),
|
].join(','),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -1782,7 +1791,7 @@ export class SettlementService {
|
|||||||
Number(b.wineryRate),
|
Number(b.wineryRate),
|
||||||
Number(item.wineryAmount),
|
Number(item.wineryAmount),
|
||||||
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
|
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
|
||||||
b.status,
|
statusLabel(b),
|
||||||
].join(','),
|
].join(','),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1798,7 +1807,15 @@ export class SettlementService {
|
|||||||
month?: number;
|
month?: number;
|
||||||
}): Prisma.WineryBillWhereInput {
|
}): Prisma.WineryBillWhereInput {
|
||||||
const where: Prisma.WineryBillWhereInput = {};
|
const where: Prisma.WineryBillWhereInput = {};
|
||||||
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
|
if (query.status === 'NO_PAYMENT_NEEDED') {
|
||||||
|
where.wineryAmount = 0;
|
||||||
|
} else if (query.status === 'UNPAID') {
|
||||||
|
where.status = 'UNPAID';
|
||||||
|
where.wineryAmount = { gt: 0 };
|
||||||
|
} else if (query.status === 'PAID') {
|
||||||
|
where.status = 'PAID';
|
||||||
|
where.wineryAmount = { gt: 0 };
|
||||||
|
}
|
||||||
if (query.year && query.month) {
|
if (query.year && query.month) {
|
||||||
const start = new Date(query.year, query.month - 1, 1);
|
const start = new Date(query.year, query.month - 1, 1);
|
||||||
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
|
||||||
|
|||||||
Reference in New Issue
Block a user