工单和发票
酒厂银行账户
This commit is contained in:
@@ -38,6 +38,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
|
||||
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||
{ value: 'TICKET_CREATE', label: '创建工单' },
|
||||
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
||||
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
||||
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
||||
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
||||
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
||||
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
@@ -47,6 +49,19 @@ type Row = {
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
type CreateFormValues = {
|
||||
orderNo: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
@@ -61,6 +76,11 @@ export default function InvoicesPage() {
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm] = Form.useForm<CreateFormValues>();
|
||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||
const titleType = Form.useWatch('titleType', createForm);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request(`/admin/invoices/${id}`));
|
||||
@@ -98,6 +118,36 @@ export default function InvoicesPage() {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
await request('/admin/invoices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
orderNo: values.orderNo.trim(),
|
||||
titleType: values.titleType,
|
||||
invoiceKind: values.invoiceKind,
|
||||
titleName: values.titleName.trim(),
|
||||
taxNo: values.taxNo?.trim() || undefined,
|
||||
addressPhone: values.addressPhone?.trim() || undefined,
|
||||
bankAccount: values.bankAccount?.trim() || undefined,
|
||||
email: values.email.trim(),
|
||||
phone: values.phone.trim(),
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('发票申请已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
@@ -138,7 +188,30 @@ export default function InvoicesPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>发票管理</Typography.Title>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
发票管理
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.setFieldsValue({
|
||||
titleType: 'PERSONAL',
|
||||
invoiceKind: 'NORMAL',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
创建发票申请
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -187,7 +260,7 @@ export default function InvoicesPage() {
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' ? (
|
||||
<>
|
||||
<Space>
|
||||
<Upload
|
||||
accept="image/*,.pdf"
|
||||
showUploadList={false}
|
||||
@@ -196,14 +269,14 @@ export default function InvoicesPage() {
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}>
|
||||
<Button type="primary" loading={uploading}>
|
||||
上传并开票
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button danger onClick={() => void reject()}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
@@ -242,6 +315,110 @@ export default function InvoicesPage() {
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="创建发票申请"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={creating}
|
||||
destroyOnClose
|
||||
okText="提交"
|
||||
width={520}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, message: '请填写已完成订单号' }]}
|
||||
extra="仅已完成订单可开票"
|
||||
>
|
||||
<Input placeholder="订单号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="invoiceKind"
|
||||
label="发票类型"
|
||||
rules={[{ required: true, message: '请选择发票类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
|
||||
value: k,
|
||||
label: INVOICE_KIND_LABELS[k],
|
||||
}))}
|
||||
onChange={(k: InvoiceKind) => {
|
||||
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleType"
|
||||
label="抬头类型"
|
||||
rules={[{ required: true, message: '请选择抬头类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
|
||||
value: t,
|
||||
label: INVOICE_TITLE_TYPE_LABELS[t],
|
||||
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleName"
|
||||
label="抬头名称"
|
||||
rules={[{ required: true, message: '请填写抬头名称' }]}
|
||||
>
|
||||
<Input placeholder="个人姓名或企业全称" />
|
||||
</Form.Item>
|
||||
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||
<Form.Item
|
||||
name="taxNo"
|
||||
label="税号"
|
||||
rules={[{ required: true, message: '企业抬头须填写税号' }]}
|
||||
>
|
||||
<Input placeholder="纳税人识别号" />
|
||||
</Form.Item>
|
||||
)}
|
||||
{invoiceKind === 'SPECIAL' && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="addressPhone"
|
||||
label="地址电话"
|
||||
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
|
||||
>
|
||||
<Input placeholder="注册地址及电话" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccount"
|
||||
label="开户行账号"
|
||||
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
|
||||
>
|
||||
<Input placeholder="开户行及账号" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="接收邮箱"
|
||||
rules={[
|
||||
{ required: true, message: '请填写邮箱' },
|
||||
{ type: 'email', message: '邮箱格式不正确' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="发票发送邮箱" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="手机号"
|
||||
rules={[{ required: true, message: '请填写手机号' }]}
|
||||
>
|
||||
<Input placeholder="联系手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
@@ -32,6 +45,13 @@ export default function TicketsPage() {
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm] = Form.useForm<{
|
||||
ticketType: TicketTypeDto;
|
||||
orderNo: string;
|
||||
remark?: string;
|
||||
}>();
|
||||
|
||||
async function approve(id: string) {
|
||||
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||
@@ -50,6 +70,29 @@ export default function TicketsPage() {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
try {
|
||||
await request('/admin/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticketType: values.ticketType,
|
||||
orderNo: values.orderNo.trim(),
|
||||
remark: values.remark?.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
message.success('工单已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
@@ -84,7 +127,21 @@ export default function TicketsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
工单中心
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
</div>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -137,14 +194,14 @@ export default function TicketsPage() {
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||
<>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
@@ -173,6 +230,44 @@ export default function TicketsPage() {
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="创建工单"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => void submitCreate()}
|
||||
confirmLoading={creating}
|
||||
destroyOnClose
|
||||
okText="提交"
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'REFUND' }}>
|
||||
<Form.Item
|
||||
name="ticketType"
|
||||
label="工单类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'REFUND', label: '仅退款' },
|
||||
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, message: '请填写订单号' }]}
|
||||
>
|
||||
<Input placeholder="关联订单号" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="可选" maxLength={512} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
@@ -17,10 +18,13 @@ import {
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
||||
import {
|
||||
WINERY_SETTLEMENT_RATE,
|
||||
type SystemConfigFormResponse,
|
||||
} from '@dukang/shared-types';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
@@ -59,8 +63,16 @@ const DELIVERY_LABELS: Record<string, string> = {
|
||||
CROSS_CITY: '跨城',
|
||||
};
|
||||
|
||||
const WINERY_BANK_KEYS = [
|
||||
'WINERY_BANK_ACCOUNT_NAME',
|
||||
'WINERY_BANK_NAME',
|
||||
'WINERY_BANK_BRANCH',
|
||||
'WINERY_BANK_ACCOUNT_NO',
|
||||
] as const;
|
||||
|
||||
export default function WineryBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [bankForm] = Form.useForm<Record<string, string>>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/winery-bills',
|
||||
@@ -79,6 +91,18 @@ export default function WineryBillsPage() {
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
const [bankOpen, setBankOpen] = useState(false);
|
||||
const [bankLoading, setBankLoading] = useState(false);
|
||||
const [bankSaving, setBankSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const canEditWineryBank =
|
||||
profile?.adminRole === 'SUPER_ADMIN' ||
|
||||
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
@@ -126,6 +150,41 @@ export default function WineryBillsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openBankModal() {
|
||||
setBankOpen(true);
|
||||
setBankLoading(true);
|
||||
try {
|
||||
const cfg = await request<SystemConfigFormResponse>('/admin/system-config');
|
||||
const values: Record<string, string> = {};
|
||||
for (const key of WINERY_BANK_KEYS) {
|
||||
values[key] = cfg.values[key] ?? '';
|
||||
}
|
||||
bankForm.setFieldsValue(values);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
setBankOpen(false);
|
||||
} finally {
|
||||
setBankLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBank() {
|
||||
const values = await bankForm.validateFields();
|
||||
setBankSaving(true);
|
||||
try {
|
||||
await request('/admin/system-config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ values }),
|
||||
});
|
||||
message.success('酒厂银行账户已保存');
|
||||
setBankOpen(false);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setBankSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
@@ -185,14 +244,29 @@ export default function WineryBillsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{canEditWineryBank ? (
|
||||
<Button type="default" onClick={() => void openBankModal()}>
|
||||
酒厂银行账户信息配置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
@@ -337,6 +411,43 @@ export default function WineryBillsPage() {
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="酒厂银行账户信息配置"
|
||||
open={bankOpen}
|
||||
onCancel={() => setBankOpen(false)}
|
||||
onOk={() => void saveBank()}
|
||||
confirmLoading={bankSaving}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
|
||||
<Form.Item
|
||||
name="WINERY_BANK_ACCOUNT_NAME"
|
||||
label="户名"
|
||||
rules={[{ required: true, message: '请填写户名' }]}
|
||||
>
|
||||
<Input placeholder="收款账户户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="WINERY_BANK_NAME"
|
||||
label="开户银行"
|
||||
rules={[{ required: true, message: '请填写开户银行' }]}
|
||||
>
|
||||
<Input placeholder="如:中国工商银行" />
|
||||
</Form.Item>
|
||||
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
|
||||
<Input placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="WINERY_BANK_ACCOUNT_NO"
|
||||
label="银行账号"
|
||||
rules={[{ required: true, message: '请填写银行账号' }]}
|
||||
>
|
||||
<Input placeholder="银行卡号" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export const HQ_PERMISSION_CATALOG = [
|
||||
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
|
||||
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
||||
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
||||
] as const;
|
||||
|
||||
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -37,6 +38,7 @@ export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
|
||||
oss: 'system_settings_oss',
|
||||
app: 'system_settings_app',
|
||||
deploy: 'system_settings_deploy',
|
||||
winery_bank: 'system_settings_winery_bank',
|
||||
};
|
||||
|
||||
export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values(
|
||||
@@ -97,6 +99,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
||||
'benefit',
|
||||
'invoices',
|
||||
'logs',
|
||||
'system_settings_winery_bank',
|
||||
],
|
||||
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
|
||||
};
|
||||
|
||||
@@ -45,6 +45,10 @@ export const HqOperationAction = {
|
||||
DELIVERY_UPDATE: 'DELIVERY_UPDATE',
|
||||
TICKET_APPROVE: 'TICKET_APPROVE',
|
||||
TICKET_REJECT: 'TICKET_REJECT',
|
||||
TICKET_CREATE: 'TICKET_CREATE',
|
||||
INVOICE_CREATE: 'INVOICE_CREATE',
|
||||
INVOICE_ISSUE: 'INVOICE_ISSUE',
|
||||
INVOICE_REJECT: 'INVOICE_REJECT',
|
||||
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
|
||||
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
||||
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
|
||||
@@ -119,6 +123,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
|
||||
[HqOperationAction.TICKET_APPROVE]: '工单通过',
|
||||
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
||||
[HqOperationAction.TICKET_CREATE]: '创建工单',
|
||||
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
|
||||
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
|
||||
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
|
||||
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
|
||||
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
||||
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
|
||||
|
||||
@@ -9,6 +9,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
|
||||
{ key: 'oss', label: '对象存储 OSS' },
|
||||
{ key: 'app', label: '应用链接' },
|
||||
{ key: 'deploy', label: '发布部署' },
|
||||
{ key: 'winery_bank', label: '酒厂银行账户' },
|
||||
];
|
||||
|
||||
const G = {
|
||||
@@ -19,6 +20,7 @@ const G = {
|
||||
oss: 'oss',
|
||||
app: 'app',
|
||||
deploy: 'deploy',
|
||||
winery_bank: 'winery_bank',
|
||||
} as const;
|
||||
|
||||
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
|
||||
@@ -101,6 +103,37 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
|
||||
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
|
||||
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
|
||||
|
||||
{
|
||||
key: 'WINERY_BANK_ACCOUNT_NAME',
|
||||
label: '户名',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '酒厂收款账户户名,打款时对照',
|
||||
},
|
||||
{
|
||||
key: 'WINERY_BANK_NAME',
|
||||
label: '开户银行',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'WINERY_BANK_BRANCH',
|
||||
label: '开户支行',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: '可选',
|
||||
},
|
||||
{
|
||||
key: 'WINERY_BANK_ACCOUNT_NO',
|
||||
label: '银行账号',
|
||||
group: G.winery_bank,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
];
|
||||
|
||||
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
|
||||
|
||||
@@ -170,6 +170,23 @@ export class CreateTicketDto {
|
||||
extraJson?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class AdminCreateTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||
ticketType: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
orderNo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
|
||||
@IsOptional()
|
||||
evidenceUrls?: string[];
|
||||
}
|
||||
|
||||
export class UpdateTicketStatusDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -2,8 +2,14 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
|
||||
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 { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { IssueInvoiceDto, RejectInvoiceDto } from '../trade/dto/after-sale.dto';
|
||||
import {
|
||||
AdminCreateInvoiceDto,
|
||||
IssueInvoiceDto,
|
||||
RejectInvoiceDto,
|
||||
} from '../trade/dto/after-sale.dto';
|
||||
|
||||
@Controller('admin/invoices')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -23,12 +29,29 @@ export class AdminInvoicesController {
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.INVOICE_CREATE,
|
||||
refType: 'INVOICE',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() body: AdminCreateInvoiceDto) {
|
||||
return this.tradeService.adminCreateInvoice(body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.tradeService.adminGetInvoice(BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/issue')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.INVOICE_ISSUE,
|
||||
refType: 'INVOICE',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
issue(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@@ -38,6 +61,12 @@ export class AdminInvoicesController {
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.INVOICE_REJECT,
|
||||
refType: 'INVOICE',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
reject(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminTicketsService } from './admin-tickets.service';
|
||||
import { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
import { AdminCreateTicketDto } from '../common/dto/common-mutate.dto';
|
||||
|
||||
@Controller('admin/tickets')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -19,6 +20,17 @@ export class AdminTicketsController {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.TICKET_CREATE,
|
||||
refType: 'TICKET',
|
||||
batch: true,
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() body: AdminCreateTicketDto) {
|
||||
return this.service.createByHq(body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.service.detail(BigInt(id));
|
||||
|
||||
@@ -40,6 +40,43 @@ export class AdminTicketsService {
|
||||
return this.ticketService.detail(id);
|
||||
}
|
||||
|
||||
async createByHq(body: {
|
||||
ticketType: string;
|
||||
orderNo: string;
|
||||
remark?: string;
|
||||
evidenceUrls?: string[];
|
||||
}) {
|
||||
const orderNo = body.orderNo?.trim();
|
||||
if (!orderNo) throw new BadRequestException('请填写订单号');
|
||||
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
|
||||
throw new BadRequestException('当前订单不可创建工单');
|
||||
}
|
||||
if (['REFUNDING', 'REFUNDED'].includes(order.status) && body.ticketType !== 'ALERT') {
|
||||
throw new BadRequestException('订单已在退款流程中');
|
||||
}
|
||||
|
||||
const pending = await this.prisma.commonTicket.findFirst({
|
||||
where: {
|
||||
ticketType: body.ticketType as never,
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
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: order.id.toString(),
|
||||
remark: body.remark ?? '',
|
||||
extraJson: evidenceUrls.length ? { evidenceUrls } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private parseExtra(raw: unknown): TicketCollabExtra {
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
return raw as TicketCollabExtra;
|
||||
|
||||
@@ -64,6 +64,13 @@ export class CreateInvoiceDto {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class AdminCreateInvoiceDto extends CreateInvoiceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
orderNo: string;
|
||||
}
|
||||
|
||||
export class IssueInvoiceDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -754,6 +754,27 @@ export class TradeService {
|
||||
return days;
|
||||
}
|
||||
|
||||
async adminCreateInvoice(
|
||||
body: {
|
||||
orderNo: string;
|
||||
titleType: string;
|
||||
invoiceKind: string;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
remark?: string;
|
||||
},
|
||||
) {
|
||||
const orderNo = body.orderNo?.trim();
|
||||
if (!orderNo) throw new BadRequestException('请填写订单号');
|
||||
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
return this.createInvoice(order.userId, order.id, body);
|
||||
}
|
||||
|
||||
async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
|
||||
Reference in New Issue
Block a user