first version
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
PlusOutlined,
|
||||
SendOutlined,
|
||||
SplitCellsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
|
||||
import type {
|
||||
IAllocationAggRow,
|
||||
IPurchaseOrderItem,
|
||||
} from '@/domain/iPurchaseOrder.ts';
|
||||
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import {
|
||||
allocatePurchase,
|
||||
exportPurchase,
|
||||
generatePurchase,
|
||||
getAllocation,
|
||||
sendPurchaseItem,
|
||||
updatePurchaseItem,
|
||||
} from '@/api/purchase/order.ts';
|
||||
import { Get } from '@/api/common/table.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 行内编辑中的明细值 */
|
||||
interface EditingItem {
|
||||
price: number;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 明细修改 / C5-C6 发送 / D3 分摊)
|
||||
*/
|
||||
const PurchaseOrderPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
|
||||
|
||||
// 生成采购单
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [generateLoading, setGenerateLoading] = useState(false);
|
||||
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
|
||||
|
||||
// 详情抽屉
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<IPurchaseOrder | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
|
||||
const [savingItemId, setSavingItemId] = useState<number | null>(null);
|
||||
|
||||
// 分摊
|
||||
const [allocating, setAllocating] = useState(false);
|
||||
const [allocation, setAllocation] = useState<{
|
||||
byStore: IAllocationAggRow[];
|
||||
byProduct: IAllocationAggRow[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await Get<IPurchaseOrder>('/purchase/order', id);
|
||||
const purchase = res.data.data ?? null;
|
||||
setDetail(purchase);
|
||||
const editingMap: Record<number, EditingItem> = {};
|
||||
purchase?.items?.forEach((item) => {
|
||||
if (item.id !== undefined) {
|
||||
editingMap[item.id] = {
|
||||
price: Number(item.price ?? 0),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
weight: Number(item.weight ?? 0),
|
||||
};
|
||||
}
|
||||
});
|
||||
setEditing(editingMap);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setAllocation(null);
|
||||
setDetailOpen(true);
|
||||
await loadDetail(id);
|
||||
await loadAllocation(id);
|
||||
};
|
||||
|
||||
const loadAllocation = async (id: number) => {
|
||||
try {
|
||||
const res = await getAllocation(id);
|
||||
const data = res.data.data;
|
||||
if (data) {
|
||||
setAllocation({
|
||||
byStore: data.by_store ?? [],
|
||||
byProduct: data.by_product ?? [],
|
||||
total: data.total_amount ?? 0,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 未分摊时忽略
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (values: { purchase_date: dayjs.Dayjs }) => {
|
||||
setGenerateLoading(true);
|
||||
try {
|
||||
const res = await generatePurchase(values.purchase_date.format('YYYY-MM-DD'));
|
||||
message.success(`采购单 ${res.data.data?.purchase_no} 已生成`);
|
||||
setGenerateOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
await openDetail(res.data.data!.id);
|
||||
} finally {
|
||||
setGenerateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isItemDirty = (item: IPurchaseOrderItem): boolean => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
edit.price !== Number(item.price ?? 0) ||
|
||||
edit.quantity !== Number(item.quantity ?? 0) ||
|
||||
edit.weight !== Number(item.weight ?? 0)
|
||||
);
|
||||
};
|
||||
|
||||
const saveItem = async (item: IPurchaseOrderItem) => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit || !isItemDirty(item)) {
|
||||
return;
|
||||
}
|
||||
setSavingItemId(item.id!);
|
||||
try {
|
||||
const res = await updatePurchaseItem(item.id!, {
|
||||
price: edit.price,
|
||||
quantity: edit.quantity,
|
||||
weight: edit.weight,
|
||||
});
|
||||
message.success(`金额已重算:¥${res.data.data?.amount}`);
|
||||
await loadDetail(detail!.id!);
|
||||
} finally {
|
||||
setSavingItemId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (item: IPurchaseOrderItem) => {
|
||||
await sendPurchaseItem(item.id!);
|
||||
message.success('已发送给供应商');
|
||||
await loadDetail(detail!.id!);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleAllocate = async () => {
|
||||
setAllocating(true);
|
||||
try {
|
||||
const res = await allocatePurchase(detail!.id!);
|
||||
message.success(`分摊完成,共 ${res.data.data?.count} 条记录`);
|
||||
await loadAllocation(detail!.id!);
|
||||
} finally {
|
||||
setAllocating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemColumns: TableProps<IPurchaseOrderItem>['columns'] = [
|
||||
{ title: '序号', dataIndex: 'sort', width: 60, align: 'center' },
|
||||
{ title: '品名', dataIndex: 'product_name', width: 130 },
|
||||
{ title: '规格', dataIndex: 'product_spec', width: 110, render: (v) => v || '-' },
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
width: 130,
|
||||
render: (_, record) => record.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
width: 130,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={editing[record.id!]?.price}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], price: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
value={editing[record.id!]?.quantity}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], quantity: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-20"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '实际称重',
|
||||
dataIndex: 'weight',
|
||||
width: 130,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={3}
|
||||
value={editing[record.id!]?.weight}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], weight: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v) => <Text strong>¥{String(v)}</Text>,
|
||||
},
|
||||
{
|
||||
title: '发送状态',
|
||||
dataIndex: 'is_sent',
|
||||
width: 150,
|
||||
render: (_, record) =>
|
||||
record.is_sent === 1 ? (
|
||||
<Tag color="success">
|
||||
已发送
|
||||
{record.sent_at ? ` ${record.sent_at}` : ''}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>未发送</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!isItemDirty(record)}
|
||||
loading={savingItemId === record.id}
|
||||
onClick={() => saveItem(record)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</AuthButton>
|
||||
{record.is_sent !== 1 ? (
|
||||
<AuthButton auth="purchase.order.send">
|
||||
<Popconfirm
|
||||
title="确认发送该明细给供应商?"
|
||||
onConfirm={() => handleSend(record)}
|
||||
>
|
||||
<Button size="small" type="link" icon={<SendOutlined />}>
|
||||
发送
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const aggColumns = (nameTitle: string): TableProps<IAllocationAggRow>['columns'] => [
|
||||
{
|
||||
title: nameTitle,
|
||||
key: 'name',
|
||||
render: (_, row) => row.store_name ?? row.product_name ?? '-',
|
||||
},
|
||||
{ title: '数量', dataIndex: 'quantity', align: 'right' },
|
||||
{ title: '重量', dataIndex: 'weight', align: 'right' },
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
align: 'right',
|
||||
render: (v) => `¥${v}`,
|
||||
},
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
||||
{
|
||||
title: '采购单号',
|
||||
dataIndex: 'purchase_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
|
||||
},
|
||||
{
|
||||
title: '采购日期',
|
||||
dataIndex: 'purchase_date',
|
||||
valueType: 'dateRange',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.purchase_date,
|
||||
},
|
||||
{
|
||||
title: '预估金额',
|
||||
dataIndex: 'estimate_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => `¥${record.estimate_amount}`,
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
dataIndex: 'actual_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) =>
|
||||
Number(record.actual_amount) > 0 ? (
|
||||
<Text strong>¥{record.actual_amount}</Text>
|
||||
) : (
|
||||
<Text type="secondary">未录入</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: Object.entries(PURCHASE_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = PURCHASE_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '制单人',
|
||||
dataIndex: 'operator',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => record.operator?.nickname ?? '-',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
<AuthButton key="export" auth="purchase.order.export">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'all-xlsx', label: '全品类 Excel', onClick: () => exportPurchase(record.id!, 'all', 'xlsx') },
|
||||
{ key: 'all-pdf', label: '全品类 PDF', onClick: () => exportPurchase(record.id!, 'all', 'pdf') },
|
||||
{ key: 'category-xlsx', label: '蔬果分类 Excel', onClick: () => exportPurchase(record.id!, 'category', 'xlsx') },
|
||||
{ key: 'category-pdf', label: '蔬果分类 PDF', onClick: () => exportPurchase(record.id!, 'category', 'pdf') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<DownloadOutlined />} />
|
||||
</Dropdown>
|
||||
</AuthButton>,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IPurchaseOrder> = {
|
||||
api: '/purchase/order',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'purchase.order',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
actionBarRender: (dom) => [
|
||||
<AuthButton key="generate" auth="purchase.order.generate">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateOpen(true)}>
|
||||
生成采购单
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
dom.search,
|
||||
dom.keywordSearch,
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>采购单</Title>
|
||||
<Text type="secondary">
|
||||
按门店订单汇总生成;录入实际称重/单价后执行金额分摊(按订货比例摊到各门店,尾差修正确保金额守恒)。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IPurchaseOrder> {...tableProps} />
|
||||
|
||||
{/* 生成采购单 */}
|
||||
<Modal
|
||||
title="生成采购单"
|
||||
open={generateOpen}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
onOk={() => generateForm.submit()}
|
||||
confirmLoading={generateLoading}
|
||||
okText="确认生成"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
将汇总所选日期全部「待汇总」订单,按商品聚合生成采购单(估算单价取最低等级价)。
|
||||
</div>
|
||||
<Form
|
||||
form={generateForm}
|
||||
layout="vertical"
|
||||
onFinish={handleGenerate}
|
||||
initialValues={{ purchase_date: dayjs() }}
|
||||
>
|
||||
<Form.Item
|
||||
label="采购日期"
|
||||
name="purchase_date"
|
||||
rules={[{ required: true, message: '请选择采购日期' }]}
|
||||
>
|
||||
<DatePicker className="w-full" allowClear={false} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 采购单详情 */}
|
||||
<Drawer
|
||||
title={detail ? `采购单 ${detail.purchase_no}` : '采购单详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={1080}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={3} size="small" bordered>
|
||||
<Descriptions.Item label="采购日期">{detail.purchase_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={PURCHASE_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
{PURCHASE_STATUS_MAP[detail.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="制单人">
|
||||
{detail.operator?.nickname ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预估金额">¥{detail.estimate_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="总重量">{detail.total_weight}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Tabs
|
||||
className="mt-4"
|
||||
items={[
|
||||
{
|
||||
key: 'items',
|
||||
label: `采购明细(${detail.items?.length ?? 0})`,
|
||||
children: (
|
||||
<>
|
||||
<div className="mb-2 text-gray-500">
|
||||
录入实际称重与单价后点击行内「保存」,金额由后端重算(称重>0 按 称重×单价,否则按 数量×单价)。
|
||||
</div>
|
||||
<Table<IPurchaseOrderItem>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items ?? []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'allocation',
|
||||
label: '金额分摊',
|
||||
children: (
|
||||
<>
|
||||
<Space className="mb-3">
|
||||
<AuthButton auth="purchase.order.allocate">
|
||||
<Popconfirm
|
||||
title="执行金额分摊?"
|
||||
description="按订货比例将实际金额摊到各门店单品,重复执行会先清空旧分摊记录。"
|
||||
onConfirm={handleAllocate}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SplitCellsOutlined />}
|
||||
loading={allocating}
|
||||
>
|
||||
执行分摊
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
{allocation ? (
|
||||
<Text type="secondary">
|
||||
分摊总额:¥{allocation.total}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{allocation && (allocation.byStore.length > 0 || allocation.byProduct.length > 0) ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Title level={5}>按门店</Title>
|
||||
<Table<IAllocationAggRow>
|
||||
rowKey={(row) => String(row.store_id)}
|
||||
size="small"
|
||||
columns={aggColumns('门店')}
|
||||
dataSource={allocation.byStore}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Title level={5}>按商品</Title>
|
||||
<Table<IAllocationAggRow>
|
||||
rowKey={(row) => String(row.product_id)}
|
||||
size="small"
|
||||
columns={aggColumns('商品')}
|
||||
dataSource={allocation.byProduct}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无分摊记录,请先录入实际金额后执行分摊" />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrderPage;
|
||||
Reference in New Issue
Block a user