Files
xin-procurement/web/pages/order/store.tsx
T
2026-08-11 21:08:01 +08:00

601 lines
21 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,
Form,
InputNumber,
message,
Modal,
Popconfirm,
Radio,
Space,
Table,
Tag,
Typography,
Image,
} from 'antd';
import dayjs from 'dayjs';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IStoreOrder from '@/domain/iStoreOrder.ts';
import type { IStoreOrderItem } from '@/domain/iStoreOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import { getStoreOrder, updateOrderContainer, updateOrderStatus, batchUpdateOrderStatus } 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 {UnorderedListOutlined} from "@ant-design/icons";
const { Title, Text } = Typography;
/** 允许修改周转框/托盘数量的订单状态:已接单、采购中、配送中 */
const CONTAINER_EDITABLE_STATUS = [1, 2, 3];
/**
* 状态流转合法路径:待接单→已接单/已取消;采购中→配送中/已完成;配送中→已完成
* (已接单→采购中 通过「生成采购单」完成,不在流转按钮内)
*/
const NEXT_STATUS: Record<number, { status: number; label: string; danger?: boolean }[]> = {
0: [
{ status: 1, label: '接单' },
{ status: 9, label: '取消订单', danger: true },
],
2: [
{ status: 3, label: '开始配送' },
{ status: 4, label: '完成订单' },
],
3: [{ status: 4, label: '完成订单' }],
};
/** 批量流转可选目标状态(弹窗单选,后端全量校验,任一不允许则整批中止) */
const BATCH_TARGET_OPTIONS = [
{ value: 1, label: '接单(待接单 → 已接单)' },
{ value: 3, label: '开始配送(采购中 → 配送中)' },
{ value: 4, 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 [containerOpen, setContainerOpen] = useState(false);
const [containerOrder, setContainerOrder] = useState<IStoreOrder | null>(null);
const [containerSaving, setContainerSaving] = useState(false);
const [containerForm] = Form.useForm<{ box_num: number; tray_num: number }>();
/** 勾选行(批量流转/生成采购单用) */
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 openContainer = (record: IStoreOrder) => {
setContainerOrder(record);
containerForm.setFieldsValue({ box_num: record.box_num, tray_num: record.tray_num });
setContainerOpen(true);
};
const handleContainerSave = async (values: { box_num: number; tray_num: number }) => {
if (!containerOrder?.id) return;
setContainerSaving(true);
try {
await updateOrderContainer(containerOrder.id, values);
message.success('周转框/托盘数量已更新');
setContainerOpen(false);
await tableRef.current?.reload();
} finally {
setContainerSaving(false);
}
};
// 弹窗内实时预览:附加金额 = 框×单价 + 托盘×单价;订单总金额 = 商品金额 + 附加金额
const watchBoxNum = Number(Form.useWatch('box_num', containerForm) ?? 0);
const watchTrayNum = Number(Form.useWatch('tray_num', containerForm) ?? 0);
const previewAdded = watchBoxNum * Number(containerOrder?.box_price ?? 0)
+ watchTrayNum * Number(containerOrder?.tray_price ?? 0);
const previewTotal = Number(containerOrder?.product_amount ?? 0) + previewAdded;
const itemColumns: TableProps<IStoreOrderItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name' },
{ title: '规格', dataIndex: 'product_spec', render: (v) => v || '-' },
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => ${v}` },
{ title: '订货量', dataIndex: 'quantity', align: 'right' },
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => ${v}` },
{ title: '备注', dataIndex: 'remark', render: (v) => v || '-' },
];
const columns: XinTableColumn<IStoreOrder>[] = [
{
title: '订单号',
hideInTable: true,
dataIndex: 'order_no',
valueType: 'text',
hideInForm: true
},
{
title: '商品信息',
dataIndex: 'items',
hideInForm: true,
hideInSearch: true,
width: 300,
render: (_, record) => {
return (
<Space orientation={'vertical'} className={'max-h-30 w-60 overflow-y-auto pr-6'}>
{ record.items && record.items.length > 0 && record.items.map(item => (
<div className={'flex'}>
<Image src={item.image} width={40} height={40} ></Image>
<div className={'ml-2.5'}>
<div>{item.product_name}</div>
<div>{ item.price } × { item.quantity }</div>
</div>
</div>
))}
</Space>
)
}
},
{
title: '基本信息',
dataIndex: 'order_no',
hideInForm: true,
hideInSearch: true,
width: 300,
render: (_, record) => {
return (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}>订货门店:</Text>
<Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>
</div>
<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>
</Space>
)
}
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
hideInTable: true,
fieldProps: {
options: 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>{ 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',
},
{
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.box_num}
</div>
<div>
<Text type={'secondary'}>周转托盘数量:</Text>
{record.tray_num}
</div>
</Space>
)
},
{
title: '附加信息',
hideInForm: true,
dataIndex: 'box_num',
hideInSearch: true,
width: 200,
render: (_, record) => (
<Space orientation={'vertical'}>
<div>
<Text type={'secondary'}>附加总金额:</Text>
{record.added_amount}
</div>
<div>
<Text type={'secondary'}>商品总金额:</Text>
{record.product_amount}
</div>
<div>
<Text type={'secondary'}>订单总金额:</Text>
{record.total_amount}
</div>
</Space>
)
},
{
title: '订单状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
align: 'center',
fieldProps: {
options: Object.entries(STORE_ORDER_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => (
<Tag color={STORE_ORDER_STATUS_MAP[record.status ?? 0]?.color}>
{STORE_ORDER_STATUS_MAP[record.status ?? 0]?.text}
</Tag>
)
},
{
title: '采购单ID',
dataIndex: 'purchase_id',
valueType: 'digit',
hideInForm: true,
},
{
title: '账单ID',
dataIndex: 'statement_id',
valueType: 'digit',
hideInForm: true,
},
{
title: '操作栏',
dataIndex: 'operate',
align: 'center',
hideInForm: true,
hideInSearch: true,
render: (_, record) => (
<Space orientation={'vertical'}>
<Button key="detail" type={'primary'} icon={<UnorderedListOutlined />} onClick={() => openDetail(record.id!)}>
详情
</Button>
{
(NEXT_STATUS[record.status ?? -1] ?? []).map((action) => (
<AuthButton key={action.status} auth="order.store.update">
<Popconfirm
title={`确认将订单状态更新为「${action.label}」?`}
onConfirm={() => handleStatusChange(record.id!, action.status)}
>
<Button type={action.danger ? undefined : 'primary'} danger={action.danger}>
{action.label}
</Button>
</Popconfirm>
</AuthButton>
))
}
{
CONTAINER_EDITABLE_STATUS.includes(record.status ?? -1) ? (
<AuthButton key="container" auth="order.store.update">
<Button type={'primary'} onClick={() => openContainer(record)}>
设置附加信息
</Button>
</AuthButton>
) : null
}
</Space>
)
}
];
const tableProps: XinTableProps<IStoreOrder> = {
api: '/order/store',
columns,
rowKey: 'id',
accessName: 'order.store',
tableRef,
operateShow: false,
formProps: false,
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)}
width={720}
loading={detailLoading}
footer={
detail && NEXT_STATUS[detail.status ?? -1] ? (
<Space className="flex justify-end">
{NEXT_STATUS[detail.status!].map((action) => (
<AuthButton key={action.status} auth="order.store.update">
<Popconfirm
title={`确认将订单状态更新为「${action.label}」?`}
onConfirm={() => handleStatusChange(detail.id!, action.status)}
>
<Button type={action.danger ? undefined : 'primary'} danger={action.danger}>
{action.label}
</Button>
</Popconfirm>
</AuthButton>
))}
</Space>
) : null
}
>
{detail ? (
<>
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="订单号">{detail.order_no}</Descriptions.Item>
<Descriptions.Item label="门店">{detail.store?.name}</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_amount}</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={2}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
<Title level={5} className="!mt-6 !mb-3">
商品明细
</Title>
<Table<IStoreOrderItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={detail.items ?? []}
pagination={false}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={4} align="right">
合计
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong>¥{detail.total_amount}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} />
</Table.Summary.Row>
)}
/>
</>
) : 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>
{/* 修改周转框/托盘数量 */}
<Modal
title="修改周转框/托盘"
open={containerOpen}
onCancel={() => setContainerOpen(false)}
onOk={() => containerForm.submit()}
confirmLoading={containerSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
订单 {containerOrder?.order_no},保存后将按单价自动重算附加金额与订单总金额。
</div>
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
<Form.Item
label={`周转框数量(单价 ¥${containerOrder?.box_price ?? '0.00'}`}
name="box_num"
rules={[{ required: true, message: '请输入周转框数量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转框数量" />
</Form.Item>
<Form.Item
label={`周转托盘数量(单价 ¥${containerOrder?.tray_price ?? '0.00'}`}
name="tray_num"
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入周转托盘数量" />
</Form.Item>
</Form>
<Space orientation="vertical" className="w-full rounded bg-gray-50 p-3">
<div>
<Text type="secondary">商品总金额:</Text>¥{containerOrder?.product_amount ?? '0.00'}
</div>
<div>
<Text type="secondary">附加总金额:</Text>
<Text strong>¥{previewAdded.toFixed(2)}</Text>
</div>
<div>
<Text type="secondary">订单总金额:</Text>
<Text strong type="danger">¥{previewTotal.toFixed(2)}</Text>
</div>
</Space>
</Modal>
</>
);
};
export default StoreOrderPage;