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>(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(null); const [detailLoading, setDetailLoading] = useState(false); const [editing, setEditing] = useState>({}); const [savingItemId, setSavingItemId] = useState(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('/purchase/order', id); const purchase = res.data.data ?? null; setDetail(purchase); const editingMap: Record = {}; 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['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) => ( setEditing((prev) => ({ ...prev, [record.id!]: { ...prev[record.id!], price: v ?? 0 }, })) } className="!w-24" /> ), }, { title: '数量', dataIndex: 'quantity', width: 120, render: (_, record) => ( setEditing((prev) => ({ ...prev, [record.id!]: { ...prev[record.id!], quantity: v ?? 0 }, })) } className="!w-20" /> ), }, { title: '实际称重', dataIndex: 'weight', width: 130, render: (_, record) => ( setEditing((prev) => ({ ...prev, [record.id!]: { ...prev[record.id!], weight: v ?? 0 }, })) } className="!w-24" /> ), }, { title: '金额', dataIndex: 'amount', width: 100, align: 'right', render: (v) => ¥{String(v)}, }, { title: '发送状态', dataIndex: 'is_sent', width: 150, render: (_, record) => record.is_sent === 1 ? ( 已发送 {record.sent_at ? ` ${record.sent_at}` : ''} ) : ( 未发送 ), }, { title: '操作', key: 'action', width: 150, fixed: 'right', render: (_, record) => ( {record.is_sent !== 1 ? ( handleSend(record)} > ) : null} ), }, ]; const aggColumns = (nameTitle: string): TableProps['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[] = [ { title: '采购单号', dataIndex: 'purchase_no', valueType: 'text', hideInForm: true, render: (_, record) => {record.purchase_no}, }, { 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 ? ( ¥{record.actual_amount} ) : ( 未录入 ), }, { 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 {item?.text}; }, align: 'center', }, { title: '制单人', dataIndex: 'operator', hideInForm: true, hideInSearch: true, render: (_, record) => record.operator?.nickname ?? '-', align: 'center', }, ]; const operateRender: XinTableProps['operateRender'] = (record) => [ , 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') }, ], }} > , dom.search, dom.keywordSearch, ], }; return ( <>
采购单 按门店订单汇总生成;录入实际称重/单价后执行金额分摊(按订货比例摊到各门店,尾差修正确保金额守恒)。
{...tableProps} /> {/* 生成采购单 */} setGenerateOpen(false)} onOk={() => generateForm.submit()} confirmLoading={generateLoading} okText="确认生成" destroyOnHidden >
将汇总所选日期全部「待汇总」订单,按商品聚合生成采购单(估算单价取最低等级价)。
{/* 采购单详情 */} setDetailOpen(false)} width={1080} loading={detailLoading} > {detail ? ( <> {detail.purchase_date} {PURCHASE_STATUS_MAP[detail.status ?? 0]?.text} {detail.operator?.nickname ?? '-'} ¥{detail.estimate_amount} ¥{detail.actual_amount} {detail.total_weight}
录入实际称重与单价后点击行内「保存」,金额由后端重算(称重>0 按 称重×单价,否则按 数量×单价)。
rowKey="id" size="small" columns={itemColumns} dataSource={detail.items ?? []} pagination={false} scroll={{ x: 1200 }} /> ), }, { key: 'allocation', label: '金额分摊', children: ( <> {allocation ? ( 分摊总额:¥{allocation.total} ) : null} {allocation && (allocation.byStore.length > 0 || allocation.byProduct.length > 0) ? (
按门店 rowKey={(row) => String(row.store_id)} size="small" columns={aggColumns('门店')} dataSource={allocation.byStore} pagination={false} />
按商品 rowKey={(row) => String(row.product_id)} size="small" columns={aggColumns('商品')} dataSource={allocation.byProduct} pagination={false} />
) : ( )} ), }, ]} /> ) : null}
); }; export default PurchaseOrderPage;