fix;提交、
This commit is contained in:
@@ -31,6 +31,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
|
|||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import WineryBillsPage from './pages/WineryBillsPage';
|
import WineryBillsPage from './pages/WineryBillsPage';
|
||||||
import TicketsPage from './pages/TicketsPage';
|
import TicketsPage from './pages/TicketsPage';
|
||||||
|
import InvoicesPage from './pages/InvoicesPage';
|
||||||
import UserLogsPage from './pages/UserLogsPage';
|
import UserLogsPage from './pages/UserLogsPage';
|
||||||
import HqLogsPage from './pages/HqLogsPage';
|
import HqLogsPage from './pages/HqLogsPage';
|
||||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||||
@@ -89,6 +90,7 @@ export default function App() {
|
|||||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
|
<Route path="/invoices" element={<InvoicesPage />} />
|
||||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||||
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||||
|
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
key: 'logs-group',
|
key: 'logs-group',
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
INVOICE_KIND_LABELS,
|
||||||
|
INVOICE_STATUS_LABELS,
|
||||||
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceKind,
|
||||||
|
type InvoiceStatus,
|
||||||
|
type InvoiceTitleType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
invoiceNo: string;
|
||||||
|
orderNo?: string;
|
||||||
|
titleType: InvoiceTitleType;
|
||||||
|
invoiceKind: InvoiceKind;
|
||||||
|
titleName: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
overdue?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
fileUrl?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
payAmount?: string;
|
||||||
|
userPhone?: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoicesPage() {
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/invoices',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
setDetail(await request(`/admin/invoices/${id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueWithFile(file: File) {
|
||||||
|
if (!detail) return false;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
|
||||||
|
await request(`/admin/invoices/${detail.id}/issue`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ fileUrl: uploaded.url }),
|
||||||
|
});
|
||||||
|
message.success('已开票回传');
|
||||||
|
reload();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '开票失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject() {
|
||||||
|
if (!detail) return;
|
||||||
|
await request(`/admin/invoices/${detail.id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ remark: '驳回' }),
|
||||||
|
});
|
||||||
|
message.success('已驳回');
|
||||||
|
reload();
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||||
|
{
|
||||||
|
title: '抬头',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '票种',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||||
|
},
|
||||||
|
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, r) => (
|
||||||
|
<>
|
||||||
|
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
|
||||||
|
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
|
||||||
|
</Tag>
|
||||||
|
{r.overdue ? <Tag color="red">超时</Tag> : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>发票管理</Typography.Title>
|
||||||
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'PENDING', label: '待开票' },
|
||||||
|
{ value: 'ISSUED', label: '已开票' },
|
||||||
|
{ value: 'REJECTED', label: '已驳回' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer
|
||||||
|
title="发票详情"
|
||||||
|
width={520}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={
|
||||||
|
detail?.status === 'PENDING' ? (
|
||||||
|
<>
|
||||||
|
<Upload
|
||||||
|
accept="image/*,.pdf"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
void issueWithFile(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}>
|
||||||
|
上传并开票
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
<Button danger onClick={() => void reject()}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="抬头类型">
|
||||||
|
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="发票类型">
|
||||||
|
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{INVOICE_STATUS_LABELS[detail.status]}
|
||||||
|
{detail.overdue ? '(超 2 工作日)' : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="发票文件">
|
||||||
|
{detail.fileUrl ? (
|
||||||
|
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
|
||||||
|
查看/下载
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
|
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -8,11 +9,12 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
ticketNo: string;
|
ticketNo: string;
|
||||||
ticketType: string;
|
ticketType: TicketTypeDto;
|
||||||
status: string;
|
status: string;
|
||||||
refType: string;
|
refType: string;
|
||||||
refId: string;
|
refId: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
|
extraJson?: { evidenceUrls?: string[] };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ export default function TicketsPage() {
|
|||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
async function approve(id: string) {
|
async function approve(id: string) {
|
||||||
@@ -50,52 +52,124 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||||
{ title: '类型', dataIndex: 'ticketType', width: 100 },
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'ticketType',
|
||||||
|
width: 110,
|
||||||
|
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
||||||
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 80,
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Button
|
||||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
type="link"
|
||||||
setDrawerOpen(true);
|
size="small"
|
||||||
}}>详情</Button>
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Form.Item name="ticketType" label="类型">
|
<Form.Item name="ticketType" label="类型">
|
||||||
<Select allowClear style={{ width: 120 }} options={[
|
<Select
|
||||||
{ value: 'REFUND', label: '退款' },
|
allowClear
|
||||||
{ value: 'RESHIPMENT', label: '补发' },
|
style={{ width: 140 }}
|
||||||
{ value: 'ALERT', label: '异常' },
|
options={[
|
||||||
]} />
|
{ value: 'REFUND', label: '仅退款' },
|
||||||
|
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||||
|
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||||
|
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||||
|
{ value: 'ALERT', label: '异常' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
|
<Form.Item name="status" label="状态">
|
||||||
<Button type="primary" htmlType="submit">筛选</Button>
|
<Input allowClear placeholder="PENDING" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
rowKey="id"
|
||||||
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
loading={loading}
|
||||||
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
columns={columns}
|
||||||
<>
|
dataSource={data?.items ?? []}
|
||||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>通过</Button>
|
scroll={{ x: 900 }}
|
||||||
<Button danger onClick={() => reject(String(detail.id))}>驳回</Button>
|
pagination={{
|
||||||
</>
|
current: page,
|
||||||
) : null}>
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer
|
||||||
|
title="工单详情"
|
||||||
|
width={480}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
extra={
|
||||||
|
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||||
|
<>
|
||||||
|
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button danger onClick={() => reject(String(detail.id))}>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
|
<Descriptions.Item label="类型">
|
||||||
|
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
|
<Descriptions.Item label="关联">
|
||||||
|
{String(detail.refType)} #{String(detail.refId)}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="凭证">
|
||||||
|
{evidenceUrls.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
{evidenceUrls.map((url) => (
|
||||||
|
<Image key={url} src={url} width={72} style={{ marginRight: 8 }} />
|
||||||
|
))}
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# 企业微信客服链接(覆盖 shared-types 默认值)
|
||||||
|
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
|
||||||
@@ -20,6 +20,10 @@ import RedeemCodePage from './pages/RedeemCodePage';
|
|||||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||||
import PayPage from './pages/PayPage';
|
import PayPage from './pages/PayPage';
|
||||||
import CustomerServicePage from './pages/CustomerServicePage';
|
import CustomerServicePage from './pages/CustomerServicePage';
|
||||||
|
import AfterSalePage from './pages/AfterSalePage';
|
||||||
|
import AfterSaleListPage from './pages/AfterSaleListPage';
|
||||||
|
import InvoiceApplyPage from './pages/InvoiceApplyPage';
|
||||||
|
import InvoiceListPage from './pages/InvoiceListPage';
|
||||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||||
import { capturePromoFromUrl } from './lib/promo';
|
import { capturePromoFromUrl } from './lib/promo';
|
||||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||||
@@ -51,6 +55,10 @@ export default function App() {
|
|||||||
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
||||||
<Route path="/pay" element={<PayPage />} />
|
<Route path="/pay" element={<PayPage />} />
|
||||||
<Route path="/customer-service" element={<CustomerServicePage />} />
|
<Route path="/customer-service" element={<CustomerServicePage />} />
|
||||||
|
<Route path="/after-sale" element={<AfterSalePage />} />
|
||||||
|
<Route path="/after-sale/list" element={<AfterSaleListPage />} />
|
||||||
|
<Route path="/invoices" element={<InvoiceListPage />} />
|
||||||
|
<Route path="/invoices/apply" element={<InvoiceApplyPage />} />
|
||||||
<Route path="/addresses" element={<AddressListPage />} />
|
<Route path="/addresses" element={<AddressListPage />} />
|
||||||
<Route path="/addresses/new" element={<AddressEditPage />} />
|
<Route path="/addresses/new" element={<AddressEditPage />} />
|
||||||
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
|
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||||
import { track } from '../lib/analytics';
|
import { track } from '../lib/analytics';
|
||||||
|
import { openWecomCustomerService } from '../lib/customer-service';
|
||||||
|
|
||||||
type ContactCustomerSheetProps = {
|
type ContactCustomerSheetProps = {
|
||||||
orderId?: string;
|
orderId?: string;
|
||||||
@@ -8,8 +8,7 @@ type ContactCustomerSheetProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) {
|
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||||
|
|
||||||
function openPhone() {
|
function openPhone() {
|
||||||
@@ -18,13 +17,11 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openChat() {
|
function openOnline() {
|
||||||
track('cs_contact', { type: 'chat', orderId });
|
track('cs_contact', { type: 'wecom_kf', orderId });
|
||||||
const qs = new URLSearchParams();
|
if (openWecomCustomerService()) {
|
||||||
if (orderId) qs.set('orderId', orderId);
|
onClose();
|
||||||
if (orderNo) qs.set('orderNo', orderNo);
|
}
|
||||||
onClose();
|
|
||||||
navigate(`/customer-service?${qs.toString()}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +46,7 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont
|
|||||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="contact-customer-option" onClick={openChat}>
|
<button type="button" className="contact-customer-option" onClick={openOnline}>
|
||||||
<div className="contact-customer-option-icon">
|
<div className="contact-customer-option-icon">
|
||||||
<span className="material-symbols-outlined">chat</span>
|
<span className="material-symbols-outlined">chat</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||||
|
import { isWechatEnv } from './weixin';
|
||||||
|
|
||||||
|
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||||
|
export function getCustomerServiceWecomUrl(): string {
|
||||||
|
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||||
|
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||||
|
* @returns true 已跳转;false 非微信环境已提示
|
||||||
|
*/
|
||||||
|
export function openWecomCustomerService(): boolean {
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
window.alert('请在微信中打开以联系在线客服');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
window.location.href = getCustomerServiceWecomUrl();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CUSTOMER_SERVICE_PHONE };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { apiBase } from './api';
|
||||||
|
|
||||||
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
|
export type UploadFileResult = {
|
||||||
|
url: string;
|
||||||
|
ossKey: string;
|
||||||
|
bucket: string;
|
||||||
|
mock: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 经 API 服务端转存 OSS */
|
||||||
|
export async function uploadFileToOss(
|
||||||
|
file: File,
|
||||||
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
|
): Promise<UploadFileResult> {
|
||||||
|
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('bizType', options.bizType);
|
||||||
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-Client-App': 'USER_H5',
|
||||||
|
};
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) throw new Error(json.message || '上传失败');
|
||||||
|
return json.data as UploadFileResult;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type TicketRow = {
|
||||||
|
id: string;
|
||||||
|
ticketNo: string;
|
||||||
|
ticketType: TicketTypeDto;
|
||||||
|
status: string;
|
||||||
|
orderNo?: string;
|
||||||
|
remark?: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
PENDING: '待处理',
|
||||||
|
OPEN: '处理中',
|
||||||
|
RESOLVED: '已完成',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
CLOSED: '已关闭',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AfterSaleListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<TicketRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<{ items: TicketRow[] }>('USER_H5', '/trade/after-sale-tickets?pageSize=50')
|
||||||
|
.then((res) => setItems(res.items ?? []))
|
||||||
|
.catch(() => setItems([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="我的售后" onBack={() => navigate(-1)} />
|
||||||
|
<main className="after-sale-body">
|
||||||
|
{loading ? (
|
||||||
|
<p className="after-sale-empty">加载中…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="after-sale-empty-box">
|
||||||
|
<p className="after-sale-empty">暂无售后工单</p>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||||
|
申请售后
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="after-sale-order-list">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div key={t.id} className="after-sale-order-item after-sale-ticket-card">
|
||||||
|
<div className="after-sale-ticket-head">
|
||||||
|
<span>{TICKET_TYPE_LABELS[t.ticketType] ?? t.ticketType}</span>
|
||||||
|
<span className="after-sale-ticket-status">{STATUS_LABEL[t.status] ?? t.status}</span>
|
||||||
|
</div>
|
||||||
|
<p className="after-sale-order-no">{t.ticketNo}</p>
|
||||||
|
<p className="after-sale-order-meta">订单 {t.orderNo ?? '—'} · {new Date(t.createdAt).toLocaleString()}</p>
|
||||||
|
{t.remark ? <p className="after-sale-order-meta">{t.remark}</p> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||||
|
新建售后
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AFTER_SALE_TICKET_TYPES,
|
||||||
|
TICKET_TYPE_LABELS,
|
||||||
|
type AfterSaleTicketType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
|
||||||
|
type OrderRow = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
payAmount: number | string;
|
||||||
|
productName?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
|
||||||
|
|
||||||
|
export default function AfterSalePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const presetOrderId = params.get('orderId') || '';
|
||||||
|
const presetType = (params.get('type') as AfterSaleTicketType | null) || null;
|
||||||
|
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [ticketType, setTicketType] = useState<AfterSaleTicketType | null>(
|
||||||
|
presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null,
|
||||||
|
);
|
||||||
|
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||||
|
const [orderId, setOrderId] = useState(presetOrderId);
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const [evidenceUrls, setEvidenceUrls] = useState<string[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [ticketNo, setTicketNo] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'),
|
||||||
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'),
|
||||||
|
])
|
||||||
|
.then(([paid, completed]) => {
|
||||||
|
const map = new Map<string, OrderRow>();
|
||||||
|
[...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o));
|
||||||
|
setOrders([...map.values()]);
|
||||||
|
})
|
||||||
|
.catch(() => setOrders([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]);
|
||||||
|
|
||||||
|
async function onPickFiles(files: FileList | null) {
|
||||||
|
if (!files?.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const uploaded: string[] = [];
|
||||||
|
for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) {
|
||||||
|
const res = await uploadFileToOss(file, { bizType: 'after-sale' });
|
||||||
|
uploaded.push(res.url);
|
||||||
|
}
|
||||||
|
setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6));
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : '上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!ticketType || !orderId) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticketType,
|
||||||
|
remark: remark.trim() || undefined,
|
||||||
|
evidenceUrls,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setTicketNo(ticket.ticketNo);
|
||||||
|
setStep(3);
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : '提交失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFromType() {
|
||||||
|
if (!ticketType) {
|
||||||
|
window.alert('请选择售后类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextFromOrder() {
|
||||||
|
if (!orderId) {
|
||||||
|
window.alert('请选择订单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="申请售后" onBack={() => navigate(-1)} />
|
||||||
|
|
||||||
|
<div className="after-sale-steps">
|
||||||
|
{STEPS.map((label, i) => (
|
||||||
|
<span key={label} className={`after-sale-step${i === step ? ' is-active' : i < step ? ' is-done' : ''}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="after-sale-body">
|
||||||
|
{step === 0 && (
|
||||||
|
<div className="after-sale-type-list">
|
||||||
|
{AFTER_SALE_TICKET_TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
className={`after-sale-type-item${ticketType === t ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setTicketType(t)}
|
||||||
|
>
|
||||||
|
<span>{TICKET_TYPE_LABELS[t]}</span>
|
||||||
|
<span className="material-symbols-outlined">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="after-sale-primary" onClick={nextFromType}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="after-sale-order-list">
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="after-sale-empty">暂无可售后订单</p>
|
||||||
|
) : (
|
||||||
|
orders.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setOrderId(o.id)}
|
||||||
|
>
|
||||||
|
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||||
|
<p className="after-sale-order-meta">
|
||||||
|
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div className="after-sale-actions">
|
||||||
|
<button type="button" className="after-sale-secondary" onClick={() => setStep(0)}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={nextFromOrder}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="after-sale-form">
|
||||||
|
<p className="after-sale-summary">
|
||||||
|
{ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId}
|
||||||
|
</p>
|
||||||
|
<label className="after-sale-label">问题描述</label>
|
||||||
|
<textarea
|
||||||
|
className="after-sale-textarea"
|
||||||
|
rows={4}
|
||||||
|
placeholder="请描述问题(选填)"
|
||||||
|
value={remark}
|
||||||
|
onChange={(e) => setRemark(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="after-sale-label">凭证图片(破损类建议上传)</label>
|
||||||
|
<div className="after-sale-evidence">
|
||||||
|
{evidenceUrls.map((url) => (
|
||||||
|
<img key={url} src={url} alt="" className="after-sale-evidence-img" />
|
||||||
|
))}
|
||||||
|
{evidenceUrls.length < 6 && (
|
||||||
|
<label className="after-sale-evidence-add">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(e) => void onPickFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
{uploading ? '上传中' : '+'}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="after-sale-actions">
|
||||||
|
<button type="button" className="after-sale-secondary" onClick={() => setStep(1)}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||||
|
{submitting ? '提交中…' : '提交工单'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="after-sale-success">
|
||||||
|
<span className="material-symbols-outlined after-sale-success-icon">check_circle</span>
|
||||||
|
<p className="after-sale-success-title">售后已提交</p>
|
||||||
|
<p className="after-sale-success-no">工单号 {ticketNo}</p>
|
||||||
|
<p className="after-sale-success-hint">总部将尽快审核,请留意处理进度</p>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale/list')}>
|
||||||
|
查看我的售后
|
||||||
|
</button>
|
||||||
|
<button type="button" className="after-sale-secondary" onClick={() => navigate('/orders')}>
|
||||||
|
返回订单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,186 +1,35 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
||||||
import SubPageHeader from '../components/SubPageHeader';
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
import { request } from '../lib/api';
|
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service';
|
||||||
|
import { track } from '../lib/analytics';
|
||||||
type ChatMessage = {
|
|
||||||
id: string;
|
|
||||||
role: 'user' | 'agent' | 'system';
|
|
||||||
text: string;
|
|
||||||
time?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const QUICK_QUESTIONS = [
|
|
||||||
{ key: 'logistics', label: '物流查询' },
|
|
||||||
{ key: 'damage', label: '破损补发' },
|
|
||||||
{ key: 'refund', label: '申请退款' },
|
|
||||||
{ key: 'address', label: '修改地址' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function nowLabel() {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentReply(userText: string, orderNo?: string) {
|
|
||||||
if (/订单|DK\d+/i.test(userText) || orderNo) {
|
|
||||||
return '已收到您的订单信息,客服将在工作时间 9:00-18:00 内为您处理,请保持电话畅通。';
|
|
||||||
}
|
|
||||||
if (userText.includes('破损') || userText.includes('补发')) {
|
|
||||||
return '非常抱歉给您带来不便。请提供订单号并描述破损情况,我们将尽快安排补发。';
|
|
||||||
}
|
|
||||||
if (userText.includes('退款')) {
|
|
||||||
return '请提供订单号与退款原因,客服将为您核实订单状态并协助办理。';
|
|
||||||
}
|
|
||||||
if (userText.includes('地址')) {
|
|
||||||
return '待发货/出库中的订单可在订单详情修改收货地址;已发货订单请联系客服协助处理。';
|
|
||||||
}
|
|
||||||
if (userText.includes('物流')) {
|
|
||||||
return '您可在订单详情查看配送进度;如有异常请提供订单号,我们为您查询。';
|
|
||||||
}
|
|
||||||
return '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CustomerServicePage() {
|
export default function CustomerServicePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [params] = useSearchParams();
|
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||||
const orderId = params.get('orderId') || '';
|
|
||||||
const orderNo = params.get('orderNo') || '';
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
function openOnline() {
|
||||||
const welcome: ChatMessage[] = [
|
track('cs_contact', { type: 'wecom_kf' });
|
||||||
{ id: 'sys-1', role: 'system', text: nowLabel(), time: nowLabel() },
|
openWecomCustomerService();
|
||||||
{
|
|
||||||
id: 'agent-welcome',
|
|
||||||
role: 'agent',
|
|
||||||
text: orderNo
|
|
||||||
? `您好,我是杜康好客客服。已为您关联订单 ${orderNo},请问有什么可以帮您?`
|
|
||||||
: '您好,我是杜康好客客服。请问有什么可以帮您?',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
setMessages(welcome);
|
|
||||||
}, [orderNo]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
function pushMessage(role: ChatMessage['role'], text: string) {
|
|
||||||
setMessages((prev) => [...prev, { id: `${Date.now()}-${prev.length}`, role, text }]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendText(text: string) {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed || sending) return;
|
|
||||||
setSending(true);
|
|
||||||
pushMessage('user', trimmed);
|
|
||||||
setInput('');
|
|
||||||
window.setTimeout(() => {
|
|
||||||
pushMessage('agent', agentReply(trimmed, orderNo));
|
|
||||||
setSending(false);
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOrderContext() {
|
|
||||||
if (!orderId) return null;
|
|
||||||
try {
|
|
||||||
return await request<Record<string, unknown>>('USER_H5', `/trade/orders/${orderId}`);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!orderId) return;
|
|
||||||
loadOrderContext().then((order) => {
|
|
||||||
if (!order) return;
|
|
||||||
const no = String(order.orderNo || orderNo);
|
|
||||||
if (no && !orderNo) {
|
|
||||||
pushMessage('system', `已关联订单 ${no}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [orderId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="customer-service-page">
|
<div className="customer-service-page customer-service-page--oa">
|
||||||
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
|
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
|
||||||
|
|
||||||
{orderNo && (
|
<div className="customer-service-oa-body">
|
||||||
<div className="customer-service-order-card">
|
<p className="customer-service-wecom-title">杜康好客客服</p>
|
||||||
<span className="material-symbols-outlined">receipt_long</span>
|
<p className="customer-service-wecom-hint">点击下方按钮,在微信内进入在线客服会话</p>
|
||||||
<div>
|
|
||||||
<p className="customer-service-order-label">当前咨询订单</p>
|
|
||||||
<p className="customer-service-order-no">{orderNo}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="customer-service-chat" ref={listRef}>
|
<button type="button" className="customer-service-wecom-btn" onClick={openOnline}>
|
||||||
{messages.map((m) => {
|
<span className="material-symbols-outlined">headset_mic</span>
|
||||||
if (m.role === 'system') {
|
联系在线客服
|
||||||
return (
|
|
||||||
<div key={m.id} className="customer-service-time">
|
|
||||||
<span>{m.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const isUser = m.role === 'user';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={m.id}
|
|
||||||
className={`customer-service-bubble-row${isUser ? ' is-user' : ' is-agent'}`}
|
|
||||||
>
|
|
||||||
{!isUser && (
|
|
||||||
<div className="customer-service-avatar" aria-hidden>
|
|
||||||
<span className="material-symbols-outlined">support_agent</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`customer-service-bubble${isUser ? ' is-user' : ''}`}>{m.text}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="customer-service-quick">
|
|
||||||
{QUICK_QUESTIONS.map((q) => (
|
|
||||||
<button
|
|
||||||
key={q.key}
|
|
||||||
type="button"
|
|
||||||
className="customer-service-quick-btn"
|
|
||||||
disabled={sending}
|
|
||||||
onClick={() => sendText(q.label)}
|
|
||||||
>
|
|
||||||
{q.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="customer-service-inputbar">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="customer-service-input"
|
|
||||||
placeholder="请输入您的问题..."
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') void sendText(input);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="customer-service-send"
|
|
||||||
disabled={sending || !input.trim()}
|
|
||||||
onClick={() => sendText(input)}
|
|
||||||
>
|
|
||||||
发送
|
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
|
||||||
|
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
||||||
|
<span className="material-symbols-outlined">call</span>
|
||||||
|
或拨打客服电话 {CUSTOMER_SERVICE_PHONE}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
INVOICE_KIND_LABELS,
|
||||||
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceKind,
|
||||||
|
type InvoiceTitleType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type OrderRow = { id: string; orderNo: string; payAmount: number | string; productName?: string };
|
||||||
|
|
||||||
|
export default function InvoiceApplyPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const presetOrderId = params.get('orderId') || '';
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||||
|
const [orderId, setOrderId] = useState(presetOrderId);
|
||||||
|
const [titleType, setTitleType] = useState<InvoiceTitleType>('PERSONAL');
|
||||||
|
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
|
||||||
|
const [titleName, setTitleName] = useState('');
|
||||||
|
const [taxNo, setTaxNo] = useState('');
|
||||||
|
const [addressPhone, setAddressPhone] = useState('');
|
||||||
|
const [bankAccount, setBankAccount] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50')
|
||||||
|
.then((res) => setOrders(res.list ?? []))
|
||||||
|
.catch(() => setOrders([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (invoiceKind === 'SPECIAL') setTitleType('ENTERPRISE');
|
||||||
|
}, [invoiceKind]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!orderId) {
|
||||||
|
window.alert('请选择订单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!titleName.trim() || !email.trim() || !phone.trim()) {
|
||||||
|
window.alert('请填写抬头名称、邮箱与手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await request('USER_H5', `/trade/orders/${orderId}/invoices`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
titleType,
|
||||||
|
invoiceKind,
|
||||||
|
titleName: titleName.trim(),
|
||||||
|
taxNo: taxNo.trim() || undefined,
|
||||||
|
addressPhone: addressPhone.trim() || undefined,
|
||||||
|
bankAccount: bankAccount.trim() || undefined,
|
||||||
|
email: email.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
window.alert('发票申请已提交,总部将在 2 个工作日内开具');
|
||||||
|
navigate('/invoices');
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : '申请失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="申请发票" onBack={() => navigate(-1)} />
|
||||||
|
<main className="after-sale-body after-sale-form">
|
||||||
|
<label className="after-sale-label">选择已完成订单</label>
|
||||||
|
<div className="after-sale-order-list" style={{ marginBottom: 16 }}>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="after-sale-empty">暂无已完成订单</p>
|
||||||
|
) : (
|
||||||
|
orders.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setOrderId(o.id)}
|
||||||
|
>
|
||||||
|
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||||
|
<p className="after-sale-order-meta">
|
||||||
|
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="after-sale-label">发票类型</label>
|
||||||
|
<div className="invoice-chip-row">
|
||||||
|
{(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
className={`invoice-chip${invoiceKind === k ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => setInvoiceKind(k)}
|
||||||
|
>
|
||||||
|
{INVOICE_KIND_LABELS[k]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="after-sale-label">抬头类型</label>
|
||||||
|
<div className="invoice-chip-row">
|
||||||
|
{(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
className={`invoice-chip${titleType === t ? ' is-selected' : ''}`}
|
||||||
|
disabled={invoiceKind === 'SPECIAL' && t === 'PERSONAL'}
|
||||||
|
onClick={() => setTitleType(t)}
|
||||||
|
>
|
||||||
|
{INVOICE_TITLE_TYPE_LABELS[t]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="after-sale-label">抬头名称</label>
|
||||||
|
<input className="after-sale-input" value={titleName} onChange={(e) => setTitleName(e.target.value)} placeholder="个人姓名或公司全称" />
|
||||||
|
|
||||||
|
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||||
|
<>
|
||||||
|
<label className="after-sale-label">税号</label>
|
||||||
|
<input className="after-sale-input" value={taxNo} onChange={(e) => setTaxNo(e.target.value)} placeholder="纳税人识别号" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invoiceKind === 'SPECIAL' && (
|
||||||
|
<>
|
||||||
|
<label className="after-sale-label">地址与电话</label>
|
||||||
|
<input className="after-sale-input" value={addressPhone} onChange={(e) => setAddressPhone(e.target.value)} />
|
||||||
|
<label className="after-sale-label">开户行与账号</label>
|
||||||
|
<input className="after-sale-input" value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="after-sale-label">接收邮箱</label>
|
||||||
|
<input className="after-sale-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
<label className="after-sale-label">手机号</label>
|
||||||
|
<input className="after-sale-input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
|
||||||
|
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||||
|
{submitting ? '提交中…' : '提交申请'}
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
INVOICE_KIND_LABELS,
|
||||||
|
INVOICE_STATUS_LABELS,
|
||||||
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import SubPageHeader from '../components/SubPageHeader';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
export default function InvoiceListPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<InvoiceDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<{ items: InvoiceDto[] }>('USER_H5', '/trade/invoices?pageSize=50')
|
||||||
|
.then((res) => setItems(res.items ?? []))
|
||||||
|
.catch(() => setItems([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="after-sale-page">
|
||||||
|
<SubPageHeader title="我的发票" onBack={() => navigate(-1)} />
|
||||||
|
<main className="after-sale-body">
|
||||||
|
{loading ? (
|
||||||
|
<p className="after-sale-empty">加载中…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="after-sale-empty-box">
|
||||||
|
<p className="after-sale-empty">暂无发票申请</p>
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||||
|
申请发票
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="after-sale-order-list">
|
||||||
|
{items.map((inv) => (
|
||||||
|
<div key={inv.id} className="after-sale-order-item after-sale-ticket-card">
|
||||||
|
<div className="after-sale-ticket-head">
|
||||||
|
<span>
|
||||||
|
{INVOICE_TITLE_TYPE_LABELS[inv.titleType]} · {INVOICE_KIND_LABELS[inv.invoiceKind]}
|
||||||
|
</span>
|
||||||
|
<span className="after-sale-ticket-status">
|
||||||
|
{INVOICE_STATUS_LABELS[inv.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="after-sale-order-no">{inv.titleName}</p>
|
||||||
|
<p className="after-sale-order-meta">
|
||||||
|
{inv.invoiceNo} · 订单 {inv.orderNo ?? inv.orderId}
|
||||||
|
</p>
|
||||||
|
{inv.status === 'ISSUED' && inv.fileUrl ? (
|
||||||
|
<a className="after-sale-file-link" href={inv.fileUrl} target="_blank" rel="noreferrer">
|
||||||
|
查看/下载发票
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||||
|
申请发票
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ const ORDER_SHORTCUTS = [
|
|||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
{ icon: 'location_on', label: '地址管理', to: '/addresses' },
|
{ icon: 'location_on', label: '地址管理', to: '/addresses' },
|
||||||
|
{ icon: 'assignment_return', label: '售后工单', to: '/after-sale/list' },
|
||||||
|
{ icon: 'receipt_long', label: '我的发票', to: '/invoices' },
|
||||||
{ icon: 'storefront', label: '可用门店', to: '/stores' },
|
{ icon: 'storefront', label: '可用门店', to: '/stores' },
|
||||||
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
|
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
|
||||||
{ icon: 'info', label: '关于我们', action: 'about' as const },
|
{ icon: 'info', label: '关于我们', action: 'about' as const },
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export default function OrderDetailPage() {
|
|||||||
order &&
|
order &&
|
||||||
!canPay &&
|
!canPay &&
|
||||||
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||||
|
const canInvoice = order?.status === 'COMPLETED';
|
||||||
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||||
|
|
||||||
@@ -207,22 +208,6 @@ export default function OrderDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestRefund() {
|
|
||||||
if (!id || !canRefund) return;
|
|
||||||
setConfirming(true);
|
|
||||||
try {
|
|
||||||
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ remark: '用户申请退款' }),
|
|
||||||
});
|
|
||||||
await loadOrder();
|
|
||||||
} catch (e) {
|
|
||||||
window.alert(e instanceof Error ? e.message : '申请退款失败');
|
|
||||||
} finally {
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!order) return <div className="empty">加载中...</div>;
|
if (!order) return <div className="empty">加载中...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -470,8 +455,21 @@ export default function OrderDetailPage() {
|
|||||||
联系客服
|
联系客服
|
||||||
</button>
|
</button>
|
||||||
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
||||||
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
|
<button
|
||||||
申请退款
|
type="button"
|
||||||
|
className="order-detail-action-outline"
|
||||||
|
onClick={() => navigate(`/after-sale?orderId=${order.id}&type=REFUND`)}
|
||||||
|
>
|
||||||
|
申请售后
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canInvoice && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="order-detail-action-outline"
|
||||||
|
onClick={() => navigate(`/invoices/apply?orderId=${order.id}`)}
|
||||||
|
>
|
||||||
|
申请发票
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canPay && (
|
{canPay && (
|
||||||
|
|||||||
+247
-105
@@ -5786,7 +5786,6 @@
|
|||||||
color: #1a1c1b;
|
color: #1a1c1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Customer service chat page */
|
|
||||||
.customer-service-page {
|
.customer-service-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -5794,142 +5793,285 @@
|
|||||||
background: #faf9f7;
|
background: #faf9f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-order-card {
|
.customer-service-page--oa .customer-service-oa-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
margin: 12px 16px 0;
|
text-align: center;
|
||||||
padding: 12px 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
|
||||||
color: #a61d24;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-order-label {
|
.customer-service-wecom-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1c1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-service-wecom-hint {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #5a413f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-service-wecom-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #a61d24;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-service-wecom-btn .material-symbols-outlined {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-service-phone-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #a61d24;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-page {
|
||||||
|
min-height: 100dvh;
|
||||||
|
background: #faf9f7;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-steps {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-step {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-step.is-active,
|
||||||
|
.after-sale-step.is-done {
|
||||||
|
color: #a61d24;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 16px calc(24px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-type-list,
|
||||||
|
.after-sale-order-list,
|
||||||
|
.after-sale-form,
|
||||||
|
.after-sale-success {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-type-item,
|
||||||
|
.after-sale-order-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(226, 190, 188, 0.35);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-type-item.is-selected,
|
||||||
|
.after-sale-order-item.is-selected {
|
||||||
|
border-color: #a61d24;
|
||||||
|
box-shadow: 0 0 0 1px #a61d24 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-order-no {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1c1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-order-meta {
|
||||||
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #5a413f;
|
color: #5a413f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-order-no {
|
.after-sale-primary,
|
||||||
margin: 4px 0 0;
|
.after-sale-secondary {
|
||||||
font-size: 14px;
|
width: 100%;
|
||||||
font-weight: 600;
|
padding: 14px;
|
||||||
color: #1a1c1b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-chat {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-time {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-time span {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
background: rgba(227, 226, 224, 0.3);
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-bubble-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
max-width: 85%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-bubble-row.is-user {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-bubble-row.is-agent {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-avatar {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid rgba(226, 190, 188, 0.2);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #a61d24;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customer-service-bubble {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 14px;
|
border: none;
|
||||||
line-height: 1.5;
|
font-size: 15px;
|
||||||
background: #fff;
|
font-weight: 600;
|
||||||
color: #1a1c1b;
|
|
||||||
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-bubble.is-user {
|
.after-sale-primary {
|
||||||
background: #a61d24;
|
background: #a61d24;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-quick {
|
.after-sale-secondary {
|
||||||
|
background: #e3e2e0;
|
||||||
|
color: #1a1c1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5a413f;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-textarea,
|
||||||
|
.after-sale-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e3e2e0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-evidence {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-quick-btn {
|
.after-sale-evidence-img,
|
||||||
padding: 6px 12px;
|
.after-sale-evidence-add {
|
||||||
border-radius: 999px;
|
width: 72px;
|
||||||
border: 1px solid rgba(166, 29, 36, 0.25);
|
height: 72px;
|
||||||
background: #fff;
|
border-radius: 8px;
|
||||||
color: #a61d24;
|
object-fit: cover;
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-inputbar {
|
.after-sale-evidence-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
justify-content: center;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-top: 1px solid #e3e2e0;
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-input {
|
.after-sale-summary {
|
||||||
flex: 1;
|
margin: 0;
|
||||||
border: 1px solid #e3e2e0;
|
padding: 12px;
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
padding: 10px 16px;
|
background: rgba(166, 29, 36, 0.06);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
outline: none;
|
color: #a61d24;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-send {
|
.after-sale-empty,
|
||||||
border: none;
|
.after-sale-empty-box {
|
||||||
border-radius: 999px;
|
text-align: center;
|
||||||
padding: 10px 18px;
|
color: #999;
|
||||||
background: #a61d24;
|
padding: 24px 0;
|
||||||
color: #fff;
|
}
|
||||||
font-size: 14px;
|
|
||||||
|
.after-sale-success {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-success-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-success-title {
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customer-service-send:disabled {
|
.after-sale-success-no,
|
||||||
opacity: 0.5;
|
.after-sale-success-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5a413f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-ticket-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-ticket-status {
|
||||||
|
color: #a61d24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-ticket-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.after-sale-file-link {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #a61d24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-chip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(166, 29, 36, 0.25);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a61d24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-chip.is-selected {
|
||||||
|
background: #a61d24;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #a61d24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-chip:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
|
/* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
|
|||||||
/** 总部客服电话(C 端联系客服) */
|
/** 总部客服电话(C 端联系客服) */
|
||||||
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
|
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话)
|
||||||
|
* 可在 h5-user 用 VITE_CS_WECOM_URL 覆盖
|
||||||
|
*/
|
||||||
|
export const CUSTOMER_SERVICE_WECOM_URL =
|
||||||
|
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
|
||||||
|
|
||||||
function readEnv(env?: Record<string, string | undefined>) {
|
function readEnv(env?: Record<string, string | undefined>) {
|
||||||
return (
|
return (
|
||||||
env ??
|
env ??
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const HQ_PERMISSION_CATALOG = [
|
|||||||
{ key: 'benefit', label: '好客权益' },
|
{ key: 'benefit', label: '好客权益' },
|
||||||
{ key: 'deliveries', label: '配送单' },
|
{ key: 'deliveries', label: '配送单' },
|
||||||
{ key: 'tickets', label: '工单中心' },
|
{ key: 'tickets', label: '工单中心' },
|
||||||
|
{ key: 'invoices', label: '发票管理' },
|
||||||
{ key: 'resources', label: 'OSS 资源库' },
|
{ key: 'resources', label: 'OSS 资源库' },
|
||||||
{ key: 'logs', label: '日志' },
|
{ key: 'logs', label: '日志' },
|
||||||
{ key: 'hq_permissions', label: '权限分配' },
|
{ key: 'hq_permissions', label: '权限分配' },
|
||||||
@@ -38,9 +39,10 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'benefit',
|
'benefit',
|
||||||
'deliveries',
|
'deliveries',
|
||||||
'tickets',
|
'tickets',
|
||||||
|
'invoices',
|
||||||
'resources',
|
'resources',
|
||||||
'logs',
|
'logs',
|
||||||
],
|
],
|
||||||
FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'logs'],
|
FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'invoices', 'logs'],
|
||||||
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'logs'],
|
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ export * from './redeem';
|
|||||||
export * from './settlement';
|
export * from './settlement';
|
||||||
export * from './ops';
|
export * from './ops';
|
||||||
export * from './ticket';
|
export * from './ticket';
|
||||||
|
export * from './invoice';
|
||||||
export * from './user-log';
|
export * from './user-log';
|
||||||
|
|
||||||
export * from './store-log';
|
export * from './store-log';
|
||||||
export * from './partner-log';
|
export * from './partner-log';
|
||||||
export * from './promo';
|
export * from './promo';
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
|
||||||
|
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
|
||||||
|
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
|
||||||
|
|
||||||
|
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
|
||||||
|
PERSONAL: '个人',
|
||||||
|
ENTERPRISE: '企业',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INVOICE_KIND_LABELS: Record<InvoiceKind, string> = {
|
||||||
|
NORMAL: '增值税普通发票',
|
||||||
|
SPECIAL: '增值税专用发票',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
||||||
|
PENDING: '待开票',
|
||||||
|
ISSUED: '已开票',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CreateInvoiceRequest {
|
||||||
|
titleType: InvoiceTitleType;
|
||||||
|
invoiceKind: InvoiceKind;
|
||||||
|
titleName: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceDto {
|
||||||
|
id: string;
|
||||||
|
invoiceNo: string;
|
||||||
|
orderId: string;
|
||||||
|
userId: string;
|
||||||
|
titleType: InvoiceTitleType;
|
||||||
|
invoiceKind: InvoiceKind;
|
||||||
|
titleName: string;
|
||||||
|
taxNo?: string | null;
|
||||||
|
addressPhone?: string | null;
|
||||||
|
bankAccount?: string | null;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
fileUrl?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
issuedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
orderNo?: string;
|
||||||
|
/** 待开票超过 2 个工作日(总部列表标红用) */
|
||||||
|
overdue?: boolean;
|
||||||
|
}
|
||||||
@@ -1,4 +1,28 @@
|
|||||||
export type TicketTypeDto = 'REFUND' | 'RESHIPMENT' | 'ALERT';
|
/** 售后工单类型(PRD 四类型 + 系统 ALERT) */
|
||||||
|
export type TicketTypeDto =
|
||||||
|
| 'REFUND'
|
||||||
|
| 'RESHIPMENT'
|
||||||
|
| 'ALERT'
|
||||||
|
| 'DAMAGE_RETURN'
|
||||||
|
| 'RETURN_REFUND';
|
||||||
|
|
||||||
|
/** 用户可发起的售后类型 */
|
||||||
|
export const AFTER_SALE_TICKET_TYPES = [
|
||||||
|
'REFUND',
|
||||||
|
'RESHIPMENT',
|
||||||
|
'DAMAGE_RETURN',
|
||||||
|
'RETURN_REFUND',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AfterSaleTicketType = (typeof AFTER_SALE_TICKET_TYPES)[number];
|
||||||
|
|
||||||
|
export const TICKET_TYPE_LABELS: Record<TicketTypeDto, string> = {
|
||||||
|
REFUND: '仅退款',
|
||||||
|
RESHIPMENT: '破损补发',
|
||||||
|
ALERT: '异常',
|
||||||
|
DAMAGE_RETURN: '破损退货',
|
||||||
|
RETURN_REFUND: '退货退款',
|
||||||
|
};
|
||||||
|
|
||||||
export interface TicketDto {
|
export interface TicketDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,9 +32,16 @@ export interface TicketDto {
|
|||||||
refType: string;
|
refType: string;
|
||||||
refId: string;
|
refId: string;
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
|
extraJson?: Record<string, unknown> | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketActionRequest {
|
export interface TicketActionRequest {
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateAfterSaleTicketRequest {
|
||||||
|
ticketType: AfterSaleTicketType;
|
||||||
|
remark?: string;
|
||||||
|
evidenceUrls?: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,24 @@ enum TicketType {
|
|||||||
REFUND
|
REFUND
|
||||||
RESHIPMENT
|
RESHIPMENT
|
||||||
ALERT
|
ALERT
|
||||||
|
DAMAGE_RETURN
|
||||||
|
RETURN_REFUND
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InvoiceTitleType {
|
||||||
|
PERSONAL
|
||||||
|
ENTERPRISE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InvoiceKind {
|
||||||
|
NORMAL
|
||||||
|
SPECIAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InvoiceStatus {
|
||||||
|
PENDING
|
||||||
|
ISSUED
|
||||||
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AromaType {
|
enum AromaType {
|
||||||
@@ -705,6 +723,7 @@ model User {
|
|||||||
promoTouch UserPromoAttribution?
|
promoTouch UserPromoAttribution?
|
||||||
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
invoices UserInvoice[]
|
||||||
benefitCoupons BenefitCoupon[]
|
benefitCoupons BenefitCoupon[]
|
||||||
redeemRecords RedeemRecord[]
|
redeemRecords RedeemRecord[]
|
||||||
redeemPendingRecords RedeemPendingRecord[]
|
redeemPendingRecords RedeemPendingRecord[]
|
||||||
@@ -919,6 +938,7 @@ model Order {
|
|||||||
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
|
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
|
||||||
delivery OrderDelivery?
|
delivery OrderDelivery?
|
||||||
benefitCoupon BenefitCoupon?
|
benefitCoupon BenefitCoupon?
|
||||||
|
invoices UserInvoice[]
|
||||||
|
|
||||||
@@index([userId, status])
|
@@index([userId, status])
|
||||||
@@index([cityId, createdAt])
|
@@index([cityId, createdAt])
|
||||||
@@ -931,6 +951,37 @@ model Order {
|
|||||||
@@map("user_order")
|
@@map("user_order")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserInvoice {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
invoiceNo String @unique @map("invoice_no") @db.VarChar(32)
|
||||||
|
orderId BigInt @map("order_id") @db.UnsignedBigInt
|
||||||
|
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||||
|
titleType InvoiceTitleType @map("title_type")
|
||||||
|
invoiceKind InvoiceKind @map("invoice_kind")
|
||||||
|
titleName String @map("title_name") @db.VarChar(128)
|
||||||
|
taxNo String? @map("tax_no") @db.VarChar(32)
|
||||||
|
addressPhone String? @map("address_phone") @db.VarChar(256)
|
||||||
|
bankAccount String? @map("bank_account") @db.VarChar(256)
|
||||||
|
email String @db.VarChar(128)
|
||||||
|
phone String @db.VarChar(20)
|
||||||
|
status InvoiceStatus @default(PENDING)
|
||||||
|
fileUrl String? @map("file_url") @db.VarChar(512)
|
||||||
|
resourceId BigInt? @map("resource_id") @db.UnsignedBigInt
|
||||||
|
issuedAt DateTime? @map("issued_at") @db.DateTime(3)
|
||||||
|
operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
|
||||||
|
remark String? @db.VarChar(512)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([userId, status])
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@map("user_invoice")
|
||||||
|
}
|
||||||
|
|
||||||
model OrderDelivery {
|
model OrderDelivery {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
|
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export class CreateEventDto {
|
|||||||
|
|
||||||
export class CreateTicketDto {
|
export class CreateTicketDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT'])
|
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||||
ticketType: string;
|
ticketType: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -165,6 +165,9 @@ export class CreateTicketDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
param1Desc?: string;
|
param1Desc?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
extraJson?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateTicketStatusDto {
|
export class UpdateTicketStatusDto {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { AssignTicketDto, CreateTicketDto, UpdateTicketStatusDto } from './dto/c
|
|||||||
export class TicketController {
|
export class TicketController {
|
||||||
constructor(private readonly service: TicketService) {}
|
constructor(private readonly service: TicketService) {}
|
||||||
|
|
||||||
|
/** 总部建单;用户售后请走 /trade/orders/:id/after-sale-tickets */
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
create(@Body() dto: CreateTicketDto) {
|
create(@Body() dto: CreateTicketDto) {
|
||||||
return this.service.create(dto);
|
return this.service.create(dto);
|
||||||
}
|
}
|
||||||
@@ -26,6 +28,7 @@ export class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/status')
|
@Put(':id/status')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateTicketStatusDto) {
|
updateStatus(@Param('id') id: string, @Body() dto: UpdateTicketStatusDto) {
|
||||||
return this.service.updateStatus(BigInt(id), dto);
|
return this.service.updateStatus(BigInt(id), dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export class TicketService {
|
|||||||
remark: dto.remark,
|
remark: dto.remark,
|
||||||
param1: dto.param1,
|
param1: dto.param1,
|
||||||
param1Desc: dto.param1Desc,
|
param1Desc: dto.param1Desc,
|
||||||
|
extraJson: dto.extraJson ? (dto.extraJson as Prisma.InputJsonValue) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return serializeBigInt(ticket);
|
return serializeBigInt(ticket);
|
||||||
@@ -69,6 +70,24 @@ export class TicketService {
|
|||||||
return serializeBigInt(ticket);
|
return serializeBigInt(ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateExtraJson(id: bigint, extraJson: Record<string, unknown>, status?: string, remark?: string) {
|
||||||
|
await this.detail(id);
|
||||||
|
const ticket = await this.prisma.commonTicket.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
extraJson: extraJson as Prisma.InputJsonValue,
|
||||||
|
...(status
|
||||||
|
? {
|
||||||
|
status,
|
||||||
|
completedAt: ['COMPLETED', 'CLOSED', 'RESOLVED'].includes(status) ? new Date() : undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(remark !== undefined ? { remark } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(ticket);
|
||||||
|
}
|
||||||
|
|
||||||
async assign(id: bigint, dto: AssignTicketDto) {
|
async assign(id: bigint, dto: AssignTicketDto) {
|
||||||
await this.detail(id);
|
await this.detail(id);
|
||||||
const ticket = await this.prisma.commonTicket.update({
|
const ticket = await this.prisma.commonTicket.update({
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { TradeService } from '../trade/trade.service';
|
||||||
|
import { IssueInvoiceDto, RejectInvoiceDto } from '../trade/dto/after-sale.dto';
|
||||||
|
|
||||||
|
@Controller('admin/invoices')
|
||||||
|
@UseGuards(HqAuthGuard)
|
||||||
|
export class AdminInvoicesController {
|
||||||
|
constructor(private readonly tradeService: TradeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('pageSize') pageSize = '20',
|
||||||
|
) {
|
||||||
|
return this.tradeService.adminListInvoices({
|
||||||
|
status,
|
||||||
|
page: Number(page),
|
||||||
|
pageSize: Number(pageSize),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@Param('id') id: string) {
|
||||||
|
return this.tradeService.adminGetInvoice(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/issue')
|
||||||
|
issue(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: IssueInvoiceDto,
|
||||||
|
) {
|
||||||
|
return this.tradeService.adminIssueInvoice(BigInt(id), user.actorId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
reject(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: RejectInvoiceDto,
|
||||||
|
) {
|
||||||
|
return this.tradeService.adminRejectInvoice(BigInt(id), user.actorId, body.remark);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
||||||
|
import { RequirePartnerPermissions } from '../../common/decorators/partner-permission.decorator';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { AdminTicketsService } from './admin-tickets.service';
|
import { AdminTicketsService } from './admin-tickets.service';
|
||||||
@@ -27,8 +31,12 @@ export class AdminTicketsController {
|
|||||||
refIdParam: 'id',
|
refIdParam: 'id',
|
||||||
includeBody: true,
|
includeBody: true,
|
||||||
})
|
})
|
||||||
approve(@Param('id') id: string, @Body() body: { remark?: string }) {
|
approve(
|
||||||
return this.service.approve(BigInt(id), body.remark);
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { remark?: string },
|
||||||
|
) {
|
||||||
|
return this.service.approve(BigInt(id), body.remark, user.actorId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/reject')
|
@Post(':id/reject')
|
||||||
@@ -41,4 +49,52 @@ export class AdminTicketsController {
|
|||||||
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
|
reject(@Param('id') id: string, @Body() body: { remark?: string }) {
|
||||||
return this.service.reject(BigInt(id), body.remark);
|
return this.service.reject(BigInt(id), body.remark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/complete-collab')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.TICKET_APPROVE,
|
||||||
|
refType: 'TICKET',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
completeCollab(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.service.completeCollabByHq(BigInt(id), user.actorId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('partner/tickets')
|
||||||
|
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
|
||||||
|
@RequirePartnerPermissions('warehouse:manage', 'order:view')
|
||||||
|
export class PartnerTicketsController {
|
||||||
|
constructor(private readonly service: AdminTicketsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.service.listPartnerTickets(
|
||||||
|
user.actorId,
|
||||||
|
page ? Number(page) : 1,
|
||||||
|
pageSize ? Number(pageSize) : 20,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.service.getPartnerTicket(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/confirm-pickup')
|
||||||
|
@RequirePartnerPermissions('warehouse:manage')
|
||||||
|
confirmPickup(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.service.partnerConfirmPickup(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/confirm-reship')
|
||||||
|
@RequirePartnerPermissions('warehouse:manage')
|
||||||
|
confirmReship(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.service.partnerConfirmReship(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { BenefitService } from '../benefit/benefit.service';
|
import { BenefitService } from '../benefit/benefit.service';
|
||||||
import { TradeService } from '../trade/trade.service';
|
import { TradeService } from '../trade/trade.service';
|
||||||
@@ -7,6 +8,20 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
|
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||||
|
|
||||||
|
export type CollabPhase = 'AWAITING_PICKUP' | 'AWAITING_RESHIP' | 'HQ_DIRECT';
|
||||||
|
|
||||||
|
export type TicketCollabExtra = {
|
||||||
|
evidenceUrls?: string[];
|
||||||
|
warehouseId?: string;
|
||||||
|
warehouseName?: string;
|
||||||
|
warehousePartnerAccountId?: string;
|
||||||
|
storePartnerAccountId?: string;
|
||||||
|
collabPhase?: CollabPhase;
|
||||||
|
collabLogs?: { at: string; actorType: string; actorId: string; action: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLLAB_TYPES = ['RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminTicketsService {
|
export class AdminTicketsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -25,55 +40,161 @@ export class AdminTicketsService {
|
|||||||
return this.ticketService.detail(id);
|
return this.ticketService.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async approve(id: bigint, remark?: string) {
|
private parseExtra(raw: unknown): TicketCollabExtra {
|
||||||
|
if (!raw || typeof raw !== 'object') return {};
|
||||||
|
return raw as TicketCollabExtra;
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendLog(
|
||||||
|
extra: TicketCollabExtra,
|
||||||
|
actorType: string,
|
||||||
|
actorId: string,
|
||||||
|
action: string,
|
||||||
|
): TicketCollabExtra {
|
||||||
|
const logs = [...(extra.collabLogs ?? [])];
|
||||||
|
logs.push({ at: new Date().toISOString(), actorType, actorId, action });
|
||||||
|
return { ...extra, collabLogs: logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析订单负责仓:优先履约仓,否则城内首个 ACTIVE 仓 */
|
||||||
|
async resolveWarehouseForOrder(orderId: bigint) {
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
cityId: true,
|
||||||
|
fulfillmentWarehouseId: true,
|
||||||
|
partnerAccountIdAtPay: true,
|
||||||
|
orderNo: true,
|
||||||
|
receiverAddress: true,
|
||||||
|
receiverName: true,
|
||||||
|
receiverPhone: true,
|
||||||
|
productName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('关联订单不存在');
|
||||||
|
|
||||||
|
let warehouse = order.fulfillmentWarehouseId
|
||||||
|
? await this.prisma.cityWarehouse.findUnique({ where: { id: order.fulfillmentWarehouseId } })
|
||||||
|
: null;
|
||||||
|
if (!warehouse) {
|
||||||
|
warehouse = await this.prisma.cityWarehouse.findFirst({
|
||||||
|
where: { cityId: order.cityId, status: 'ACTIVE' },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { order, warehouse };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeRefund(orderId: bigint, remark: string) {
|
||||||
|
await this.prisma.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
||||||
|
});
|
||||||
|
await this.benefitService.voidCouponsOnRefund(orderId);
|
||||||
|
await this.prisma.logThirdParty.create({
|
||||||
|
data: {
|
||||||
|
provider: 'WECHAT_REFUND',
|
||||||
|
scene: 'ORDER_REFUND',
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: orderId,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
amount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.prisma.commonEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: 'ORDER_STATUS',
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: orderId,
|
||||||
|
actorType: 'HQ',
|
||||||
|
status: 'REFUNDED',
|
||||||
|
remark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeReship(orderId: bigint) {
|
||||||
|
await this.tradeService.applyStatusTransition(orderId, 'PENDING_SHIP', 'PENDING_SHIP', 'HQ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(id: bigint, remark?: string, actorId = '0') {
|
||||||
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||||
if (!ticket) throw new NotFoundException('工单不存在');
|
if (!ticket) throw new NotFoundException('工单不存在');
|
||||||
if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') {
|
if (ticket.status !== 'PENDING' && ticket.status !== 'OPEN') {
|
||||||
throw new BadRequestException('工单状态不可审批');
|
throw new BadRequestException('工单状态不可审批');
|
||||||
}
|
}
|
||||||
|
if (ticket.refType !== 'ORDER') {
|
||||||
if (ticket.ticketType === 'REFUND' && ticket.refType === 'ORDER') {
|
throw new BadRequestException('仅支持订单售后工单');
|
||||||
const orderId = ticket.refId;
|
|
||||||
await this.prisma.order.update({
|
|
||||||
where: { id: orderId },
|
|
||||||
data: { status: 'REFUNDED', payStatus: 'REFUNDED' },
|
|
||||||
});
|
|
||||||
await this.benefitService.voidCouponsOnRefund(orderId);
|
|
||||||
await this.prisma.logThirdParty.create({
|
|
||||||
data: {
|
|
||||||
provider: 'WECHAT_REFUND',
|
|
||||||
scene: 'ORDER_REFUND',
|
|
||||||
refType: 'ORDER',
|
|
||||||
refId: orderId,
|
|
||||||
status: 'SUCCESS',
|
|
||||||
amount: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.prisma.commonEvent.create({
|
|
||||||
data: {
|
|
||||||
eventType: 'ORDER_STATUS',
|
|
||||||
refType: 'ORDER',
|
|
||||||
refId: orderId,
|
|
||||||
actorType: 'HQ',
|
|
||||||
status: 'REFUNDED',
|
|
||||||
remark: remark ?? '退款工单审批通过',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.ticketType === 'RESHIPMENT' && ticket.refType === 'ORDER') {
|
const existingExtra = this.parseExtra(ticket.extraJson);
|
||||||
await this.tradeService.applyStatusTransition(
|
|
||||||
ticket.refId,
|
// 仅退款:立即退款
|
||||||
'PENDING_SHIP',
|
if (ticket.ticketType === 'REFUND') {
|
||||||
'PENDING_SHIP',
|
await this.executeRefund(ticket.refId, remark ?? '仅退款工单审批通过');
|
||||||
'HQ',
|
return this.ticketService.updateExtraJson(
|
||||||
|
id,
|
||||||
|
this.appendLog(existingExtra, 'HQ', actorId, 'APPROVE_REFUND'),
|
||||||
|
'RESOLVED',
|
||||||
|
remark ?? '审批通过',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.ticketService.updateStatus(id, {
|
if (!(COLLAB_TYPES as readonly string[]).includes(ticket.ticketType)) {
|
||||||
status: 'RESOLVED',
|
return this.ticketService.updateStatus(id, {
|
||||||
remark: remark ?? '审批通过',
|
status: 'RESOLVED',
|
||||||
|
remark: remark ?? '审批通过',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { order, warehouse } = await this.resolveWarehouseForOrder(ticket.refId);
|
||||||
|
const warehousePartnerAccountId =
|
||||||
|
warehouse?.partnerAccountId?.toString() ||
|
||||||
|
(warehouse
|
||||||
|
? (
|
||||||
|
await this.prisma.partnerAccount.findFirst({
|
||||||
|
where: { managedWarehouseId: warehouse.id },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
)?.id.toString()
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
let collabPhase: CollabPhase = warehousePartnerAccountId ? 'AWAITING_PICKUP' : 'HQ_DIRECT';
|
||||||
|
if (ticket.ticketType === 'RESHIPMENT' && warehousePartnerAccountId) {
|
||||||
|
collabPhase = 'AWAITING_RESHIP';
|
||||||
|
}
|
||||||
|
if (!warehouse) {
|
||||||
|
collabPhase = 'HQ_DIRECT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const storePartnerAccountId =
|
||||||
|
ticket.ticketType === 'RETURN_REFUND' && order.partnerAccountIdAtPay
|
||||||
|
? order.partnerAccountIdAtPay.toString()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let extra: TicketCollabExtra = {
|
||||||
|
...existingExtra,
|
||||||
|
warehouseId: warehouse?.id.toString(),
|
||||||
|
warehouseName: warehouse?.name,
|
||||||
|
warehousePartnerAccountId,
|
||||||
|
storePartnerAccountId,
|
||||||
|
collabPhase,
|
||||||
|
};
|
||||||
|
extra = this.appendLog(extra, 'HQ', actorId, `APPROVE_COLLAB:${collabPhase}`);
|
||||||
|
|
||||||
|
await this.prisma.commonEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: 'TICKET_COLLAB',
|
||||||
|
refType: 'TICKET',
|
||||||
|
refId: id,
|
||||||
|
actorType: 'HQ',
|
||||||
|
status: 'COLLABORATING',
|
||||||
|
remark: remark ?? `工单进入协同 ${collabPhase}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.ticketService.updateExtraJson(id, extra as Record<string, unknown>, 'COLLABORATING', remark ?? '审批通过,待协同');
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(id: bigint, remark?: string) {
|
reject(id: bigint, remark?: string) {
|
||||||
@@ -83,21 +204,203 @@ export class AdminTicketsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
/** 完成协同:取回确认(退货类)或补发确认 */
|
||||||
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(partnerAccountId);
|
async completeCollab(
|
||||||
const orders = await this.prisma.order.findMany({
|
id: bigint,
|
||||||
where: orderWhere,
|
opts: {
|
||||||
|
actorType: 'HQ' | 'PARTNER';
|
||||||
|
actorId: string;
|
||||||
|
mode: 'pickup' | 'reship' | 'auto';
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const ticket = await this.prisma.commonTicket.findUnique({ where: { id } });
|
||||||
|
if (!ticket) throw new NotFoundException('工单不存在');
|
||||||
|
if (ticket.status !== 'COLLABORATING') {
|
||||||
|
throw new BadRequestException('工单不在协同中');
|
||||||
|
}
|
||||||
|
if (ticket.refType !== 'ORDER') throw new BadRequestException('仅支持订单售后工单');
|
||||||
|
|
||||||
|
let extra = this.parseExtra(ticket.extraJson);
|
||||||
|
const phase = extra.collabPhase ?? 'HQ_DIRECT';
|
||||||
|
|
||||||
|
if (opts.actorType === 'PARTNER') {
|
||||||
|
// 仅管仓合伙人可操作协同节点;归属合伙人只读
|
||||||
|
if (extra.warehousePartnerAccountId !== opts.actorId) {
|
||||||
|
throw new BadRequestException('无权操作此工单');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode =
|
||||||
|
opts.mode === 'auto'
|
||||||
|
? ticket.ticketType === 'RESHIPMENT'
|
||||||
|
? 'reship'
|
||||||
|
: 'pickup'
|
||||||
|
: opts.mode;
|
||||||
|
|
||||||
|
if (mode === 'reship' && ticket.ticketType !== 'RESHIPMENT') {
|
||||||
|
throw new BadRequestException('当前工单不是补发类型');
|
||||||
|
}
|
||||||
|
if (mode === 'pickup' && !['DAMAGE_RETURN', 'RETURN_REFUND'].includes(ticket.ticketType)) {
|
||||||
|
throw new BadRequestException('当前工单不需要取回确认');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticket.ticketType === 'RESHIPMENT') {
|
||||||
|
await this.executeReship(ticket.refId);
|
||||||
|
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_RESHIP');
|
||||||
|
} else if (ticket.ticketType === 'DAMAGE_RETURN' || ticket.ticketType === 'RETURN_REFUND') {
|
||||||
|
await this.executeRefund(ticket.refId, `${ticket.ticketType} 协同取回后完成退款`);
|
||||||
|
extra = this.appendLog(extra, opts.actorType, opts.actorId, 'CONFIRM_PICKUP_REFUND');
|
||||||
|
} else {
|
||||||
|
throw new BadRequestException('工单类型不支持协同完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
extra = { ...extra, collabPhase: undefined };
|
||||||
|
|
||||||
|
await this.prisma.commonEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: 'TICKET_COLLAB',
|
||||||
|
refType: 'TICKET',
|
||||||
|
refId: id,
|
||||||
|
actorType: opts.actorType,
|
||||||
|
status: 'RESOLVED',
|
||||||
|
remark: `协同完成 phaseWas=${phase} mode=${mode}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.ticketService.updateExtraJson(id, extra as Record<string, unknown>, 'RESOLVED', '协同完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeCollabByHq(id: bigint, actorId: string) {
|
||||||
|
return this.completeCollab(id, { actorType: 'HQ', actorId, mode: 'auto' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPartnerTickets(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
const pid = primary.id.toString();
|
||||||
|
|
||||||
|
const warehouses = await this.prisma.cityWarehouse.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ partnerAccountId: primary.id }, { managedBy: { id: primary.id } }],
|
||||||
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const orderIds = orders.map((o) => o.id);
|
const warehouseIds = new Set(warehouses.map((w) => w.id.toString()));
|
||||||
|
|
||||||
const tickets = await this.prisma.commonTicket.findMany({
|
const tickets = await this.prisma.commonTicket.findMany({
|
||||||
where: {
|
where: {
|
||||||
ticketType: 'RESHIPMENT',
|
status: 'COLLABORATING',
|
||||||
|
ticketType: { in: [...COLLAB_TYPES] },
|
||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
refId: { in: orderIds },
|
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
return serializeBigInt(tickets);
|
|
||||||
|
const filtered = tickets.filter((t) => {
|
||||||
|
const extra = this.parseExtra(t.extraJson);
|
||||||
|
if (extra.warehousePartnerAccountId === pid) return true;
|
||||||
|
if (extra.storePartnerAccountId === pid) return true;
|
||||||
|
if (extra.warehouseId && warehouseIds.has(extra.warehouseId)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageItems = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||||||
|
const orderIds = pageItems.map((t) => t.refId);
|
||||||
|
const orders = await this.prisma.order.findMany({
|
||||||
|
where: { id: { in: orderIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
orderNo: true,
|
||||||
|
productName: true,
|
||||||
|
receiverAddress: true,
|
||||||
|
receiverName: true,
|
||||||
|
receiverPhone: true,
|
||||||
|
payAmount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const orderMap = new Map(orders.map((o) => [o.id.toString(), o]));
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
items: pageItems.map((t) => {
|
||||||
|
const order = orderMap.get(t.refId.toString());
|
||||||
|
const extra = this.parseExtra(t.extraJson);
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
extraJson: extra,
|
||||||
|
orderNo: order?.orderNo,
|
||||||
|
productName: order?.productName,
|
||||||
|
receiverAddress: order?.receiverAddress,
|
||||||
|
receiverName: order?.receiverName,
|
||||||
|
receiverPhone: order?.receiverPhone,
|
||||||
|
payAmount: order?.payAmount,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
total: filtered.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPartnerTicket(partnerAccountId: bigint, ticketId: bigint) {
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
const pid = primary.id.toString();
|
||||||
|
const ticket = await this.prisma.commonTicket.findUnique({ where: { id: ticketId } });
|
||||||
|
if (!ticket || ticket.refType !== 'ORDER') throw new NotFoundException('工单不存在或无权查看');
|
||||||
|
const extra = this.parseExtra(ticket.extraJson);
|
||||||
|
const warehouses = await this.prisma.cityWarehouse.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ partnerAccountId: primary.id }, { managedBy: { id: primary.id } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const warehouseIds = new Set(warehouses.map((w) => w.id.toString()));
|
||||||
|
const allowed =
|
||||||
|
extra.warehousePartnerAccountId === pid ||
|
||||||
|
extra.storePartnerAccountId === pid ||
|
||||||
|
(extra.warehouseId != null && warehouseIds.has(extra.warehouseId));
|
||||||
|
if (!allowed) throw new NotFoundException('工单不存在或无权查看');
|
||||||
|
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id: ticket.refId },
|
||||||
|
select: {
|
||||||
|
orderNo: true,
|
||||||
|
productName: true,
|
||||||
|
receiverAddress: true,
|
||||||
|
receiverName: true,
|
||||||
|
receiverPhone: true,
|
||||||
|
payAmount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt({
|
||||||
|
...ticket,
|
||||||
|
extraJson: extra,
|
||||||
|
orderNo: order?.orderNo,
|
||||||
|
productName: order?.productName,
|
||||||
|
receiverAddress: order?.receiverAddress,
|
||||||
|
receiverName: order?.receiverName,
|
||||||
|
receiverPhone: order?.receiverPhone,
|
||||||
|
payAmount: order?.payAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async partnerConfirmPickup(partnerAccountId: bigint, ticketId: bigint) {
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
return this.completeCollab(ticketId, {
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: primary.id.toString(),
|
||||||
|
mode: 'pickup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async partnerConfirmReship(partnerAccountId: bigint, ticketId: bigint) {
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
return this.completeCollab(ticketId, {
|
||||||
|
actorType: 'PARTNER',
|
||||||
|
actorId: primary.id.toString(),
|
||||||
|
mode: 'reship',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||||
|
return this.listPartnerTickets(partnerAccountId, 1, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ import { AdminHqLogsController } from './admin-hq-logs.controller';
|
|||||||
import { AdminHqLogsService } from './admin-hq-logs.service';
|
import { AdminHqLogsService } from './admin-hq-logs.service';
|
||||||
import { AdminOssLogsController } from './admin-oss-logs.controller';
|
import { AdminOssLogsController } from './admin-oss-logs.controller';
|
||||||
import { AdminOssLogsService } from './admin-oss-logs.service';
|
import { AdminOssLogsService } from './admin-oss-logs.service';
|
||||||
import { AdminTicketsController } from './admin-tickets.controller';
|
import { AdminTicketsController, PartnerTicketsController } from './admin-tickets.controller';
|
||||||
import { AdminTicketsService } from './admin-tickets.service';
|
import { AdminTicketsService } from './admin-tickets.service';
|
||||||
|
import { AdminInvoicesController } from './admin-invoices.controller';
|
||||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
import { BenefitModule } from '../benefit/benefit.module';
|
import { BenefitModule } from '../benefit/benefit.module';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
@@ -84,6 +85,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
|||||||
AdminHqLogsController,
|
AdminHqLogsController,
|
||||||
AdminOssLogsController,
|
AdminOssLogsController,
|
||||||
AdminTicketsController,
|
AdminTicketsController,
|
||||||
|
AdminInvoicesController,
|
||||||
AdminXiaofeixiaController,
|
AdminXiaofeixiaController,
|
||||||
AdminProductDetailTemplatesController,
|
AdminProductDetailTemplatesController,
|
||||||
AdminRedeemDebugController,
|
AdminRedeemDebugController,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAfterSaleTicketDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||||
|
ticketType: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
remark?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
evidenceUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateInvoiceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
||||||
|
titleType: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['NORMAL', 'SPECIAL'])
|
||||||
|
invoiceKind: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(128)
|
||||||
|
titleName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
taxNo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
addressPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
bankAccount?: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IssueInvoiceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
fileUrl: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
resourceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectInvoiceDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(512)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
PartnerProxyOrderSendSmsDto,
|
PartnerProxyOrderSendSmsDto,
|
||||||
} from './dto/partner-proxy-order.dto';
|
} from './dto/partner-proxy-order.dto';
|
||||||
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
|
import { ManualShipOrderDto } from '../ops/dto/admin-mutate.dto';
|
||||||
|
import { CreateAfterSaleTicketDto, CreateInvoiceDto } from './dto/after-sale.dto';
|
||||||
|
|
||||||
@Controller('trade/orders')
|
@Controller('trade/orders')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -75,6 +76,64 @@ export class TradeController {
|
|||||||
) {
|
) {
|
||||||
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
|
return this.tradeService.createRefundRequest(user.actorId, BigInt(id), body.remark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/after-sale-tickets')
|
||||||
|
createAfterSale(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: CreateAfterSaleTicketDto,
|
||||||
|
) {
|
||||||
|
return this.tradeService.createAfterSaleTicket(user.actorId, BigInt(id), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/invoices')
|
||||||
|
createInvoice(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: CreateInvoiceDto,
|
||||||
|
) {
|
||||||
|
return this.tradeService.createInvoice(user.actorId, BigInt(id), body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('trade/after-sale-tickets')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TradeAfterSaleTicketController {
|
||||||
|
constructor(private readonly tradeService: TradeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('pageSize') pageSize = '20',
|
||||||
|
) {
|
||||||
|
return this.tradeService.listAfterSaleTickets(user.actorId, Number(page), Number(pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.tradeService.getAfterSaleTicket(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('trade/invoices')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class TradeInvoiceController {
|
||||||
|
constructor(private readonly tradeService: TradeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('pageSize') pageSize = '20',
|
||||||
|
) {
|
||||||
|
return this.tradeService.listInvoices(user.actorId, Number(page), Number(pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.tradeService.getInvoice(user.actorId, BigInt(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/orders')
|
@Controller('partner/orders')
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
PartnerOrderController,
|
PartnerOrderController,
|
||||||
PartnerProxyOrderController,
|
PartnerProxyOrderController,
|
||||||
PartnerReshipmentController,
|
PartnerReshipmentController,
|
||||||
|
TradeAfterSaleTicketController,
|
||||||
|
TradeInvoiceController,
|
||||||
} from './trade.controller';
|
} from './trade.controller';
|
||||||
import { TradeService } from './trade.service';
|
import { TradeService } from './trade.service';
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ import { TradeService } from './trade.service';
|
|||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
TradeController,
|
TradeController,
|
||||||
|
TradeAfterSaleTicketController,
|
||||||
|
TradeInvoiceController,
|
||||||
PartnerOrderController,
|
PartnerOrderController,
|
||||||
PartnerProxyOrderController,
|
PartnerProxyOrderController,
|
||||||
PartnerReshipmentController,
|
PartnerReshipmentController,
|
||||||
|
|||||||
@@ -478,23 +478,293 @@ export class TradeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
|
async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) {
|
||||||
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
return this.createAfterSaleTicket(userId, orderId, {
|
||||||
if (!order) throw new NotFoundException('订单不存在');
|
|
||||||
if (!['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status)) {
|
|
||||||
throw new BadRequestException('当前订单不可申请退款');
|
|
||||||
}
|
|
||||||
await this.prisma.order.update({
|
|
||||||
where: { id: orderId },
|
|
||||||
data: { status: 'REFUNDING', payStatus: 'REFUNDING' },
|
|
||||||
});
|
|
||||||
return this.ticketService.create({
|
|
||||||
ticketType: 'REFUND',
|
ticketType: 'REFUND',
|
||||||
refType: 'ORDER',
|
|
||||||
refId: orderId.toString(),
|
|
||||||
remark: remark ?? '用户申请退款',
|
remark: remark ?? '用户申请退款',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createAfterSaleTicket(
|
||||||
|
userId: bigint,
|
||||||
|
orderId: bigint,
|
||||||
|
body: { ticketType: string; remark?: string; evidenceUrls?: string[] },
|
||||||
|
) {
|
||||||
|
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||||
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
|
||||||
|
throw new BadRequestException('当前订单不可申请售后');
|
||||||
|
}
|
||||||
|
if (['REFUNDING', 'REFUNDED'].includes(order.status)) {
|
||||||
|
throw new BadRequestException('订单已在退款流程中');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await this.prisma.commonTicket.findFirst({
|
||||||
|
where: {
|
||||||
|
ticketType: body.ticketType as never,
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: orderId,
|
||||||
|
status: { in: ['PENDING', 'OPEN'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pending) throw new BadRequestException('该类型售后工单已在处理中');
|
||||||
|
|
||||||
|
const evidenceUrls = (body.evidenceUrls ?? []).filter((u) => typeof u === 'string' && u.trim());
|
||||||
|
return this.ticketService.create({
|
||||||
|
ticketType: body.ticketType,
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: orderId.toString(),
|
||||||
|
remark: body.remark ?? '',
|
||||||
|
extraJson: evidenceUrls.length ? { evidenceUrls } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAfterSaleTickets(userId: bigint, page = 1, pageSize = 20) {
|
||||||
|
const orders = await this.prisma.order.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { id: true, orderNo: true },
|
||||||
|
});
|
||||||
|
const orderIds = orders.map((o) => o.id);
|
||||||
|
const orderNoMap = new Map(orders.map((o) => [o.id.toString(), o.orderNo]));
|
||||||
|
if (!orderIds.length) {
|
||||||
|
return serializeBigInt({ items: [], total: 0, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: { in: orderIds },
|
||||||
|
ticketType: { in: ['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'] as never[] },
|
||||||
|
};
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.commonTicket.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.commonTicket.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
items: items.map((t) => ({
|
||||||
|
...t,
|
||||||
|
orderNo: orderNoMap.get(t.refId.toString()) ?? null,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAfterSaleTicket(userId: bigint, ticketId: bigint) {
|
||||||
|
const ticket = await this.prisma.commonTicket.findUnique({ where: { id: ticketId } });
|
||||||
|
if (!ticket || ticket.refType !== 'ORDER') throw new NotFoundException('工单不存在');
|
||||||
|
const order = await this.prisma.order.findFirst({
|
||||||
|
where: { id: ticket.refId, userId },
|
||||||
|
select: { id: true, orderNo: true, status: true },
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('工单不存在');
|
||||||
|
return serializeBigInt({ ...ticket, orderNo: order.orderNo, orderStatus: order.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateInvoiceNo() {
|
||||||
|
return `INV${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(
|
||||||
|
userId: bigint,
|
||||||
|
orderId: bigint,
|
||||||
|
body: {
|
||||||
|
titleType: string;
|
||||||
|
invoiceKind: string;
|
||||||
|
titleName: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
remark?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } });
|
||||||
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
if (order.status !== 'COMPLETED') {
|
||||||
|
throw new BadRequestException('仅已完成订单可申请发票');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.userInvoice.findFirst({
|
||||||
|
where: { orderId, status: { in: ['PENDING', 'ISSUED'] } },
|
||||||
|
});
|
||||||
|
if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请');
|
||||||
|
|
||||||
|
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
|
||||||
|
throw new BadRequestException('企业抬头须填写税号');
|
||||||
|
}
|
||||||
|
if (body.invoiceKind === 'SPECIAL') {
|
||||||
|
if (body.titleType !== 'ENTERPRISE') {
|
||||||
|
throw new BadRequestException('专用发票仅支持企业抬头');
|
||||||
|
}
|
||||||
|
if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) {
|
||||||
|
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await this.prisma.userInvoice.create({
|
||||||
|
data: {
|
||||||
|
invoiceNo: this.generateInvoiceNo(),
|
||||||
|
orderId,
|
||||||
|
userId,
|
||||||
|
titleType: body.titleType as never,
|
||||||
|
invoiceKind: body.invoiceKind as never,
|
||||||
|
titleName: body.titleName.trim(),
|
||||||
|
taxNo: body.taxNo?.trim() || null,
|
||||||
|
addressPhone: body.addressPhone?.trim() || null,
|
||||||
|
bankAccount: body.bankAccount?.trim() || null,
|
||||||
|
email: body.email.trim(),
|
||||||
|
phone: body.phone.trim(),
|
||||||
|
remark: body.remark?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listInvoices(userId: bigint, page = 1, pageSize = 20) {
|
||||||
|
const where = { userId };
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.userInvoice.findMany({
|
||||||
|
where,
|
||||||
|
include: { order: { select: { orderNo: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.userInvoice.count({ where }),
|
||||||
|
]);
|
||||||
|
return serializeBigInt({
|
||||||
|
items: items.map((inv) => ({
|
||||||
|
...inv,
|
||||||
|
orderNo: inv.order.orderNo,
|
||||||
|
order: undefined,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoice(userId: bigint, invoiceId: bigint) {
|
||||||
|
const invoice = await this.prisma.userInvoice.findFirst({
|
||||||
|
where: { id: invoiceId, userId },
|
||||||
|
include: { order: { select: { orderNo: true, payAmount: true } } },
|
||||||
|
});
|
||||||
|
if (!invoice) throw new NotFoundException('发票不存在');
|
||||||
|
return serializeBigInt({
|
||||||
|
...invoice,
|
||||||
|
orderNo: invoice.order.orderNo,
|
||||||
|
payAmount: invoice.order.payAmount,
|
||||||
|
order: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作日差(粗略:排除周六日) */
|
||||||
|
private businessDaysSince(from: Date, to = new Date()): number {
|
||||||
|
let days = 0;
|
||||||
|
const cur = new Date(from);
|
||||||
|
cur.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(to);
|
||||||
|
end.setHours(0, 0, 0, 0);
|
||||||
|
while (cur < end) {
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
const w = cur.getDay();
|
||||||
|
if (w !== 0 && w !== 6) days += 1;
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const where: { status?: never } = {};
|
||||||
|
if (query.status) where.status = query.status as never;
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.prisma.userInvoice.findMany({
|
||||||
|
where,
|
||||||
|
include: { order: { select: { orderNo: true, payAmount: true } }, user: { select: { phone: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.userInvoice.count({ where }),
|
||||||
|
]);
|
||||||
|
return serializeBigInt({
|
||||||
|
items: items.map((inv) => ({
|
||||||
|
...inv,
|
||||||
|
orderNo: inv.order.orderNo,
|
||||||
|
payAmount: inv.order.payAmount,
|
||||||
|
userPhone: inv.user.phone,
|
||||||
|
overdue: inv.status === 'PENDING' && this.businessDaysSince(inv.createdAt) > 2,
|
||||||
|
order: undefined,
|
||||||
|
user: undefined,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminGetInvoice(id: bigint) {
|
||||||
|
const invoice = await this.prisma.userInvoice.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { order: { select: { orderNo: true, payAmount: true, productName: true } }, user: { select: { phone: true, nickname: true } } },
|
||||||
|
});
|
||||||
|
if (!invoice) throw new NotFoundException('发票不存在');
|
||||||
|
return serializeBigInt({
|
||||||
|
...invoice,
|
||||||
|
orderNo: invoice.order.orderNo,
|
||||||
|
payAmount: invoice.order.payAmount,
|
||||||
|
productName: invoice.order.productName,
|
||||||
|
userPhone: invoice.user.phone,
|
||||||
|
overdue: invoice.status === 'PENDING' && this.businessDaysSince(invoice.createdAt) > 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminIssueInvoice(
|
||||||
|
id: bigint,
|
||||||
|
operatorId: bigint,
|
||||||
|
body: { fileUrl: string; resourceId?: string; remark?: string },
|
||||||
|
) {
|
||||||
|
const invoice = await this.prisma.userInvoice.findUnique({ where: { id } });
|
||||||
|
if (!invoice) throw new NotFoundException('发票不存在');
|
||||||
|
if (invoice.status !== 'PENDING') throw new BadRequestException('当前状态不可开票');
|
||||||
|
const updated = await this.prisma.userInvoice.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'ISSUED',
|
||||||
|
fileUrl: body.fileUrl,
|
||||||
|
resourceId: body.resourceId ? BigInt(body.resourceId) : null,
|
||||||
|
issuedAt: new Date(),
|
||||||
|
operatorId,
|
||||||
|
remark: body.remark ?? invoice.remark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminRejectInvoice(id: bigint, operatorId: bigint, remark?: string) {
|
||||||
|
const invoice = await this.prisma.userInvoice.findUnique({ where: { id } });
|
||||||
|
if (!invoice) throw new NotFoundException('发票不存在');
|
||||||
|
if (invoice.status !== 'PENDING') throw new BadRequestException('当前状态不可驳回');
|
||||||
|
const updated = await this.prisma.userInvoice.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'REJECTED',
|
||||||
|
operatorId,
|
||||||
|
remark: remark ?? '驳回',
|
||||||
|
issuedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt(updated);
|
||||||
|
}
|
||||||
|
|
||||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user