Files
xin-procurement/web/pages/order/store.tsx
T
2026-08-27 14:20:09 +08:00

686 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
DatePicker,
Descriptions,
Drawer,
Empty,
Form,
message,
Modal,
Popconfirm,
Radio,
Space,
Tag,
Typography,
Image,
} from 'antd';
import dayjs from 'dayjs';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IStoreOrder from '@/domain/iStoreOrder.ts';
import { BILL_PAY_STATE_MAP, STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
getStoreOrder,
updateOrderStatus,
batchUpdateOrderStatus,
deleteStoreOrder,
} from '@/api/order/store.ts';
import { generatePurchase } from '@/api/purchase/order.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import AuthButton from '@/components/AuthButton';
import { DeleteOutlined, UnorderedListOutlined } from '@ant-design/icons';
import {PURCHASE_STATUS_MAP} from "@/domain/iPurchaseOrder.ts";
const { Title, Text } = Typography;
/**
* 状态流转合法路径:手动流转仅保留「接单」「取消订单」
* (已接单→采购中 由「生成采购单」推进;采购单完成→配送中、生成账单→已完成 由业务链路自动推进)
*/
const NEXT_STATUS: Record<number, { status: number; label: string; }> = {
0: { status: 1, label: '接单' },
};
/** 批量流转可选目标状态(弹窗单选,后端全量校验,任一不允许则整批中止) */
const BATCH_TARGET_OPTIONS = [
{ value: 1, label: '接单(待接单 → 已接单)' },
{ value: 9, label: '取消订单(待接单 → 已取消)' },
];
/**
* 门店订单管理(只读 + 状态流转,订单由小程序端创建)
*/
const StoreOrderPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IStoreOrder>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IStoreOrder | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
/** 勾选行(批量流转/生成采购单用) */
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchOpen, setBatchOpen] = useState(false);
const [batchTarget, setBatchTarget] = useState<number | null>(null);
const [batchLoading, setBatchLoading] = useState(false);
/** 生成采购单(合并已接单订单,生成后源订单转采购中) */
const [generateOpen, setGenerateOpen] = useState(false);
const [generateLoading, setGenerateLoading] = useState(false);
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
try {
const res = await getStoreOrder(id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const handleStatusChange = async (id: number, status: number) => {
await updateOrderStatus(id, status);
message.success('状态已更新');
setDetailOpen(false);
await tableRef.current?.reload();
};
/** 批量状态流转:后端全量校验,任一订单不允许流转则整批中止 */
const handleBatchStatus = async () => {
if (batchTarget === null) {
message.warning('请选择要流转的目标状态');
return;
}
setBatchLoading(true);
try {
const res = await batchUpdateOrderStatus(selectedRowKeys as number[], batchTarget);
message.success(`已批量流转 ${res.data.data?.success ?? 0} 单`);
setBatchOpen(false);
setBatchTarget(null);
setSelectedRowKeys([]);
await tableRef.current?.reload();
} finally {
setBatchLoading(false);
}
};
/** 生成采购单:勾选时合并所选订单(须均为已接单),未勾选时合并全部已接单订单 */
const handleGeneratePurchase = async (values: { purchase_date: dayjs.Dayjs }) => {
setGenerateLoading(true);
try {
const orderIds = selectedRowKeys.length > 0 ? selectedRowKeys.map(Number) : undefined;
const res = await generatePurchase(values.purchase_date.format('YYYY-MM-DD'), orderIds);
message.success(`采购单 ${res.data.data?.purchase_no} 已生成,源订单已转为采购中`);
setGenerateOpen(false);
setSelectedRowKeys([]);
await tableRef.current?.reload();
} finally {
setGenerateLoading(false);
}
};
/** 删除订单(软删除,仅已取消订单可删;删除后后台/小程序端均不可见) */
const handleDelete = async (id: number) => {
await deleteStoreOrder(id);
message.success('订单已删除');
setDetailOpen(false);
await tableRef.current?.reload();
};
const columns: XinTableColumn<IStoreOrder>[] = [
{
title: '订单号',
hideInTable: true,
dataIndex: 'order_no',
valueType: 'text',
hideInForm: true
},
{
title: '商品名称',
hideInTable: true,
dataIndex: 'product_name',
valueType: 'text',
hideInForm: true
},
{
title: '基本信息',
dataIndex: 'order_no',
hideInForm: true,
hideInSearch: true,
width: 300,
render: (_, record) => {
return (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}>订单号:</Text>
<Text copyable={{ text: record.order_no }}>{record.order_no}</Text>
</div>
<div><Text type={'secondary'}>订货时间:</Text>{record.created_at}</div>
<div>
<Text type={'secondary'}>订单状态:</Text>
<Tag color={STORE_ORDER_STATUS_MAP[record.status ?? 0]?.color}>
{STORE_ORDER_STATUS_MAP[record.status ?? 0]?.text}
</Tag>
</div>
</Space>
)
}
},
{
title: '商品信息',
dataIndex: 'items',
hideInForm: true,
hideInSearch: true,
defaultHiddenInTable: true,
width: 550,
render: (_, record) => {
return (
<Space wrap size={20}>
{ record.items && record.items.length > 0 && record.items.map(item => (
<div className={'flex'}>
<Image src={item.image} width={60} height={60} ></Image>
<div className={'ml-2.5'}>
<div>{item.product_name}</div>
<div className={'text-[12px] text-[#999]'}>{item.product_spec} {item.unit}</div>
<div className={'text-[12px] text-[#999]'}>
<span className={'text-[red]'}>{ item.price } </span> × { item.quantity }
</div>
</div>
</div>
))}
</Space>
)
}
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
hideInTable: true,
fieldProps: {
options: [{ label: '全部', value: '' }, ...stores.map((s) => ({ label: s.name, value: s.id }))],
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) => record.store?.name ?? '-',
},
{
title: '门店信息',
dataIndex: 'store_id',
hideInForm: true,
hideInSearch: true,
width: 300,
render: (_, record) => (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}>订货门店:</Text>
<Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>
</div>
<div><Text type={'secondary'}>联系人:</Text>{ record.store?.contact ?? '-' }</div>
<div><Text type={'secondary'}>联系电话:</Text>{ record.store?.phone ?? '-' }</div>
<div><Text type={'secondary'}>门店地址:</Text>{ record.store?.address ?? '-' }</div>
</Space>
),
},
{
title: '订货日期',
dataIndex: 'order_date',
valueType: 'dateRange',
hideInForm: true,
hideInTable: true,
align: 'center',
fieldProps: {
presets: [
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '昨天', value: [dayjs().subtract(1, 'day'), dayjs().subtract(1, 'day')] },
{ label: '前天', value: [dayjs().subtract(2, 'day'), dayjs().subtract(2, 'day')] },
{ label: '本周', value: [dayjs().startOf('week'), dayjs().endOf('week')] },
{ label: '本月', value: [dayjs().startOf('month'), dayjs().endOf('month')] },
],
},
},
{
title: '附加信息',
hideInForm: true,
dataIndex: 'status',
hideInSearch: true,
width: 200,
render: (_, record) => (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}>订货数量:</Text>
{record.total_quantity}
</div>
<div>
<Text type={'secondary'}>总重量:</Text>
{record.total_weight}
</div>
</Space>
)
},
{
title: '订单金额',
hideInForm: true,
dataIndex: 'total_amount',
hideInSearch: true,
width: 200,
render: (text) => (
<span className={'text-[red] text-[16px]'}>{text} </span>
)
},
{
title: '订单状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
align: 'center',
hideInTable: true,
fieldProps: {
options: [
{ label: '全部', value: '' },
...Object.entries(STORE_ORDER_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
],
}
},
{
title: '采购单号',
dataIndex: 'purchase_no',
valueType: 'text',
hideInForm: true,
hideInTable: true,
fieldProps: {
placeholder: '采购单号',
allowClear: true,
},
},
{
title: '采购单',
dataIndex: 'purchase_id',
valueType: 'digit',
hideInForm: true,
hideInSearch: true,
render: (_, record) => record.purchase ? (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}>采购单号:</Text>
{record.purchase.purchase_no}
</div>
<div>
<Text type={'secondary'}>采购单日期:</Text>
{record.purchase.purchase_date}
</div>
<div>
<Text type={'secondary'}>采购单状态:</Text>
<Tag color={PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.color}>
{PURCHASE_STATUS_MAP[record.purchase.status ?? 0]?.text}
</Tag>
</div>
</Space>
) : '-'
},
{
title: '账单信息',
dataIndex: 'bill_id',
hideInForm: true,
hideInSearch: true,
width: 240,
render: (_, record) => record.bill ? (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}>账单号:</Text>
{record.bill.bill_no}
</div>
<div>
<Text type={'secondary'}>支付状态:</Text>
<Tag color={BILL_PAY_STATE_MAP[record.bill.pay_state ?? 0]?.color}>
{record.bill.pay_state_name ?? BILL_PAY_STATE_MAP[record.bill.pay_state ?? 0]?.text}
</Tag>
</div>
</Space>
) : '-'
},
{
title: '操作栏',
dataIndex: 'operate',
hideInForm: true,
hideInSearch: true,
width: 180,
render: (_, record) => (
<Space size={5} wrap>
<Button
type={'primary'}
icon={<UnorderedListOutlined />}
onClick={() => openDetail(record.id!)}
/>
{ NEXT_STATUS[record.status!] && (
<AuthButton auth="order.store.update">
<Popconfirm
title={`确认将订单状态更新为「${NEXT_STATUS[record.status!].label}」?`}
onConfirm={() => handleStatusChange(record.id!, NEXT_STATUS[record.status!].status)}
>
<Button type={'primary'} color='green' variant="solid">
{NEXT_STATUS[record.status!].label}
</Button>
</Popconfirm>
</AuthButton>
)}
{ record.status === 0 && (
<AuthButton auth="order.store.update">
<Popconfirm
title={`确认取消该订单吗?`}
onConfirm={() => handleStatusChange(record.id!, 9)}
>
<Button type={'primary'} color='danger' variant="solid">
取消订单
</Button>
</Popconfirm>
</AuthButton>
)}
{ record.status === 9 && (
<AuthButton auth="order.store.delete">
<Popconfirm
title="确认删除该订单吗?"
description="仅已取消订单可删除,删除后后台与小程序端均不可见"
onConfirm={() => handleDelete(record.id!)}
>
<Button icon={<DeleteOutlined />} color='danger' variant="solid" />
</Popconfirm>
</AuthButton>
)}
</Space>
)
}
];
const tableProps: XinTableProps<IStoreOrder> = {
api: '/order/store',
columns,
rowKey: 'id',
accessName: 'order.store',
tableRef,
operateShow: false,
formProps: false,
searchDefaultOpen: true,
rowSelection: {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
},
actionBarRender: (dom) => [
dom.search,
<AuthButton key="generate" auth="purchase.order.generate">
<Button type="primary" onClick={() => setGenerateOpen(true)}>
生成采购单
</Button>
</AuthButton>,
<AuthButton key="batch" auth="order.store.update">
<Button
type="primary"
disabled={selectedRowKeys.length === 0}
onClick={() => setBatchOpen(true)}
>
批量操作{selectedRowKeys.length > 0 ? `${selectedRowKeys.length}` : ''}
</Button>
</AuthButton>,
dom.keywordSearch,
],
};
return (
<>
<div className="mb-5">
<Title level={3}>门店订单</Title>
<Text type="secondary">
小程序下单为待接单;接单后通过「生成采购单」合并为采购单并转采购中,采购单完成后订单自动转配送中,生成账单后订单自动完成。
</Text>
</div>
<XinTable<IStoreOrder> {...tableProps} />
<Drawer
title="订单详情"
open={detailOpen}
onClose={() => setDetailOpen(false)}
size={1000}
loading={detailLoading}
footer={
detail ? (
<Space className="flex justify-end" wrap>
{ NEXT_STATUS[detail.status!] && (
<AuthButton auth="order.store.update">
<Popconfirm
title={`确认将订单状态更新为「${NEXT_STATUS[detail.status!].label}」?`}
onConfirm={() => handleStatusChange(detail.id!, NEXT_STATUS[detail.status!].status)}
>
<Button type={'primary'} color='green' variant="solid">
{NEXT_STATUS[detail.status!].label}
</Button>
</Popconfirm>
</AuthButton>
)}
{ detail.status === 0 && (
<AuthButton auth="order.store.update">
<Popconfirm
title={`确认取消该订单吗?`}
onConfirm={() => handleStatusChange(detail.id!, 9)}
>
<Button type={'primary'} color='danger' variant="solid">
取消订单
</Button>
</Popconfirm>
</AuthButton>
)}
{ detail.status === 9 && (
<AuthButton auth="order.store.delete">
<Popconfirm
title="确认删除该订单吗?"
description="仅已取消订单可删除,删除后后台与小程序端均不可见"
onConfirm={() => handleDelete(detail.id!)}
>
<Button icon={<DeleteOutlined />} color='danger' variant="solid">
删除
</Button>
</Popconfirm>
</AuthButton>
)}
</Space>
) : null
}
>
{detail ? (
<>
{/* 门店信息 */}
<Descriptions title="门店信息" column={3} size="small" bordered>
<Descriptions.Item label="门店名称">
{detail.store?.name ?? `门店#${detail.store_id}`}
</Descriptions.Item>
<Descriptions.Item label="联系人">{detail.store?.contact ?? '-'}</Descriptions.Item>
<Descriptions.Item label="联系电话">{detail.store?.phone ?? '-'}</Descriptions.Item>
<Descriptions.Item label="门店地址" span={3}>
{detail.store?.address ?? '-'}
</Descriptions.Item>
</Descriptions>
{/* 订单信息 */}
<Descriptions title="订单信息" column={3} size="small" bordered className="mt-4!">
<Descriptions.Item label="订单号">{detail.order_no}</Descriptions.Item>
<Descriptions.Item label="订货日期">{detail.order_date}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.color}>
{STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="订货总量">{detail.total_quantity}</Descriptions.Item>
<Descriptions.Item label="总重量">{detail.total_weight}</Descriptions.Item>
<Descriptions.Item label="账单ID">{detail.bill_id ?? '-'}</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={3}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
{/* 关联账单信息(生成账单后展示) */}
{detail.bill ? (
<Descriptions title="账单信息" column={3} size="small" bordered className="mt-4!">
<Descriptions.Item label="账单号">{detail.bill.bill_no}</Descriptions.Item>
<Descriptions.Item label="账单日期">{detail.bill.bill_date ?? '-'}</Descriptions.Item>
<Descriptions.Item label="支付状态">
<Tag color={BILL_PAY_STATE_MAP[detail.bill.pay_state ?? 0]?.color}>
{detail.bill.pay_state_name ?? BILL_PAY_STATE_MAP[detail.bill.pay_state ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="付款时间">{detail.bill.paid_at ?? '-'}</Descriptions.Item>
<Descriptions.Item label="付款备注">{detail.bill.pay_remark || '-'}</Descriptions.Item>
</Descriptions>
) : null}
{/* 商品明细(商城模式:首图 + 单价 × 订货量 + 金额) */}
<Title level={5} className="mt-6! mb-3!">
商品明细
</Title>
<div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="flex-1">商品信息</div>
<div className="w-30 shrink-0 text-center">供应商</div>
<div className="w-30 shrink-0 text-center">单价</div>
<div className="w-26 shrink-0 text-center">订货量</div>
<div className="w-33 shrink-0 text-center">订货金额</div>
<div className="w-33 shrink-0 text-center">重量</div>
</div>
{(detail.items ?? []).map((item) => (
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center">
<Image.PreviewGroup>
{item.image ? (
<Image
src={item.image}
width={64}
height={64}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
) : (
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
暂无图片
</div>
)}
</Image.PreviewGroup>
<div className="ml-3 min-w-0">
<div className="text-sm font-medium">{item.product_name}</div>
<div className="mt-0.5 text-xs text-gray-500">
{item.product_spec || '-'} {item.unit || '-'}
<div>成本:¥{item.cost_price ?? '0.00'}</div>
</div>
{item.remark ? (
<div className="mt-0.5 truncate text-xs text-gray-500">备注:{item.remark}</div>
) : null}
</div>
</div>
<div className="w-30 shrink-0 text-center">{item.supplier?.name ?? '-'}</div>
<div className="w-30 shrink-0 text-center">¥{item.price}</div>
<div className="w-26 shrink-0 text-center">{item.quantity}</div>
<div className="w-33 shrink-0 text-center">
<Text strong>¥{item.amount}</Text>
</div>
<div className="w-33 shrink-0 text-center">
{item.weight ?? '-'}
</div>
</div>
))}
<div className="flex bg-gray-50 px-4 py-3 text-sm">
<div className="flex-1">总计</div>
<div className="w-30 shrink-0 text-center"></div>
<div className="w-30 shrink-0 text-center"></div>
<div className="w-26 shrink-0 text-center">{detail.total_quantity}</div>
<div className="w-33 shrink-0 text-center text-[red] text-[16px]">{detail.total_amount}</div>
<div className="w-33 shrink-0 text-center">{detail.total_weight}</div>
</div>
{(detail.items ?? []).length === 0 && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无商品明细"
className="py-8!"
/>
)}
</div>
</>
) : null}
</Drawer>
{/* 生成采购单:合并已接单订单 */}
<Modal
title="生成采购单"
open={generateOpen}
onCancel={() => setGenerateOpen(false)}
onOk={() => generateForm.submit()}
confirmLoading={generateLoading}
okText="确认生成"
destroyOnHidden
>
<div className="py-2 text-gray-500">
{selectedRowKeys.length > 0
? `将合并选中的 ${selectedRowKeys.length} 笔订单生成一张采购单(须均为已接单状态,否则整批中止)。`
: '未勾选订单时,将合并全部「已接单」订单生成一张采购单。'}
生成后源订单自动转为「采购中」。
</div>
<Form form={generateForm} layout="vertical" onFinish={handleGeneratePurchase}>
<Form.Item
label="采购日期"
name="purchase_date"
initialValue={dayjs()}
rules={[{ required: true, message: '请选择采购日期' }]}
>
<DatePicker className="w-full" allowClear={false} />
</Form.Item>
</Form>
</Modal>
{/* 批量状态流转 */}
<Modal
title="批量状态流转"
open={batchOpen}
onCancel={() => setBatchOpen(false)}
onOk={handleBatchStatus}
confirmLoading={batchLoading}
okText="确认流转"
destroyOnHidden
>
<div className="py-2 text-gray-500">
已勾选 {selectedRowKeys.length} 笔订单;任一订单不满足流转条件时,本次批量操作将整批中止,不做任何修改。
</div>
<Radio.Group
className="w-full py-2"
value={batchTarget}
onChange={(e) => setBatchTarget(e.target.value as number)}
>
<Space orientation="vertical">
{BATCH_TARGET_OPTIONS.map((opt) => (
<Radio key={opt.value} value={opt.value}>
{opt.label}
</Radio>
))}
</Space>
</Radio.Group>
</Modal>
</>
);
};
export default StoreOrderPage;