Files
xin-procurement/web/pages/purchase/order.tsx
T
2026-07-23 20:41:25 +08:00

591 lines
18 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, { 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">
录入实际称重与单价后点击行内「保存」,金额由后端重算(称重&gt;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;