Files
xin-procurement/web/pages/purchase/order.tsx
T
2026-08-12 17:30:46 +08:00

462 lines
15 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Select,
Table,
Tag,
Typography,
} from 'antd';
import { CheckOutlined, EditOutlined } from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type {
IPurchaseDetail,
IPurchaseDetailRow,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import {
getPurchaseDetail,
updatePurchaseRow,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 行修改表单:品名/供应商/包规/单位/成本 */
interface RowEditForm {
product_name: string;
supplier_id?: number;
product_spec: string;
unit: string;
cost_price: number;
}
/** 单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径) */
const calcUnitCost = (cost: number, spec: string): number => {
const pack = parseFloat(spec);
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
};
/**
* 采购单管理(C1 在门店订单页合并生成 / C2-C3 导出 / C4 明细矩阵修改)
* 采购单无独立明细表,明细直接溯源门店订货明细:商品行 × 门店列
*/
const PurchaseOrderPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
// 详情抽屉
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IPurchaseDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [completing, setCompleting] = useState(false);
// 行修改弹窗
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
const [rowSaving, setRowSaving] = useState(false);
const [syncTarget, setSyncTarget] = useState<'purchase' | 'product'>('purchase');
const [editForm] = Form.useForm<RowEditForm>();
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await getPurchaseDetail(id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const openDetail = async (id: number) => {
setDetailOpen(true);
await loadDetail(id);
};
const openEdit = (row: IPurchaseDetailRow) => {
setEditingRow(row);
editForm.setFieldsValue({
product_name: row.product_name,
supplier_id: row.supplier_id > 0 ? row.supplier_id : undefined,
product_spec: row.product_spec,
unit: row.unit,
cost_price: Number(row.cost_price),
});
};
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
const handleEditSave = async (values: RowEditForm) => {
if (!detail || !editingRow) {
return;
}
setRowSaving(true);
try {
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
...values,
supplier_id: values.supplier_id ?? 0,
sync_product: syncTarget === 'product',
});
message.success(
syncTarget === 'product'
? `已同步 ${res.data.data?.count ?? 0} 条订货明细,并更新商品档案`
: `已同步 ${res.data.data?.count ?? 0} 条订货明细`,
);
setEditingRow(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setRowSaving(false);
}
};
const handleComplete = async () => {
if (!detail) {
return;
}
setCompleting(true);
try {
await Update(`/purchase/order/${detail.purchase.id}`, { status: 3 });
message.success('采购单已标记完成');
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setCompleting(false);
}
};
/** 明细矩阵列:品名/供应商/单价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' },
{
title: '供应商',
dataIndex: 'supplier',
width: 110,
align: 'center',
render: (_, row) => row.supplier?.name ?? '-',
},
{
title: '单价',
key: 'unit_cost',
width: 90,
align: 'center',
render: (_, row) => ${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
},
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
{ title: '成本', dataIndex: 'cost_price', width: 90, align: 'center', render: (v) => ${Number(v).toFixed(2)}` },
];
const storeColumns = (detail?.stores ?? []).map((store) => ({
title: <span className={'text-[red]'}>{store.name}</span>,
key: `store-${store.id}`,
width: 100,
align: 'center' as const,
render: (_: unknown, row: IPurchaseDetailRow) => {
const quantity = row.cells[store.id];
return quantity !== undefined ? <Text>{quantity}</Text> : <Text type="secondary">-</Text>;
},
}));
const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{
title: '合计数量',
key: 'total_quantity',
width: 90,
align: 'center',
render: (_, row) => <Text strong>{row.quantity}</Text>,
},
{
title: '操作',
key: 'action',
width: 90,
fixed: 'right',
align: 'center',
render: (_, row) => (
<AuthButton auth="purchase.order.update">
<Button
size="small"
type="link"
icon={<EditOutlined />}
onClick={() => openEdit(row)}
>
修改
</Button>
</AuthButton>
),
},
];
return [...fixed, ...storeColumns, ...tail];
};
/** 底部统计行:按门店统计金额(Σ 门店数量 × 行单价)+ 合计 */
const renderSummary = () => {
const stores = detail?.stores ?? [];
const items = detail?.items ?? [];
const storeTotals = stores.map((store) =>
items.reduce(
(sum, row) => sum + (row.cells[store.id] ?? 0) * Number(row.cost_price),
0,
),
);
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalAmount = storeTotals.reduce((sum, amount) => sum + amount, 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={6} align="center">
<Text strong>成本统计(按门店)</Text>
</Table.Summary.Cell>
{storeTotals.map((amount, index) => (
<Table.Summary.Cell key={stores[index].id} index={6 + index} align="center">
<Text strong>¥{amount.toFixed(2)}</Text>
</Table.Summary.Cell>
))}
<Table.Summary.Cell index={6 + stores.length} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7 + stores.length} align="center">
<Text strong>¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
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>
];
const tableProps: XinTableProps<IPurchaseOrder> = {
api: '/purchase/order',
columns,
rowKey: 'id',
accessName: 'purchase.order',
tableRef,
operateRender,
formProps: false,
};
return (
<>
<div className="mb-5">
<Title level={3}>采购单</Title>
<Text type="secondary">
在「门店订单」页勾选已接单订单合并生成采购单;采购单支持修改订单信息。
</Text>
</div>
<XinTable<IPurchaseOrder> {...tableProps} />
{/* 采购单详情:商品行 × 门店列矩阵 */}
<Drawer
title={detail ? `采购单 ${detail.purchase.purchase_no}` : '采购单详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
size={1200}
loading={detailLoading}
extra={detail?.purchase.status === 0 && (
<AuthButton auth="purchase.order.update">
<Popconfirm title="确认标记该采购单为已完成?" onConfirm={handleComplete}>
<Button size="small" icon={<CheckOutlined />} loading={completing}>
标记完成
</Button>
</Popconfirm>
</AuthButton>
)}
>
{detail && (
<>
<Title level={5} className="mt-5! mb-3!">
采购单信息
</Title>
<Descriptions column={3} size="small" bordered>
<Descriptions.Item label="采购日期">{detail.purchase.purchase_date}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.color}>
{PURCHASE_STATUS_MAP[detail.purchase.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="制单人">
{detail.purchase.operator?.nickname ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="预估金额">¥{detail.purchase.estimate_amount}</Descriptions.Item>
<Descriptions.Item label="实际金额">¥{detail.purchase.actual_amount}</Descriptions.Item>
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
</Descriptions>
<Title level={5} className="mt-5! mb-3!">
商品明细
</Title>
<Table<IPurchaseDetailRow>
rowKey="product_id"
size="small"
bordered
columns={buildItemColumns()}
dataSource={detail.items}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderSummary}
/>
</>
)}
</Drawer>
{/* 行修改:品名/供应商/包规/单位/成本,同步采购单明细 / 追加同步商品档案 */}
<Modal
title={editingRow ? `修改「${editingRow.product_name}」` : '修改明细行'}
open={editingRow !== null}
onCancel={() => setEditingRow(null)}
destroyOnHidden
footer={[
<Button key="cancel" onClick={() => setEditingRow(null)}>
取消
</Button>,
<Button
key="product"
loading={rowSaving && syncTarget === 'product'}
onClick={() => {
setSyncTarget('product');
editForm.submit();
}}
>
保存并同步
</Button>,
<Button
key="purchase"
type="primary"
loading={rowSaving && syncTarget === 'purchase'}
onClick={() => {
setSyncTarget('purchase');
editForm.submit();
}}
>
保存
</Button>,
]}
>
<div className="py-2 text-gray-500">
「保存」仅修改本采购单中该商品的所有订单项;「保存并同步」在此基础上同时更新商品档案(后续新订单生效)。
</div>
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
<Form.Item
label="品名"
name="product_name"
rules={[{ required: true, message: '请输入品名' }, { max: 100 }]}
>
<Input maxLength={100} />
</Form.Item>
<Form.Item label="供应商" name="supplier_id">
<Select
allowClear
showSearch={{ optionFilterProp: 'label' }}
placeholder="选择供应商"
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item label="包规" name="product_spec" rules={[{ max: 100 }]}>
<Input maxLength={100} placeholder="如:10斤/箱" />
</Form.Item>
<Form.Item label="单位" name="unit" rules={[{ max: 20 }]}>
<Input maxLength={20} placeholder="如:斤" />
</Form.Item>
<Form.Item
label="成本"
name="cost_price"
rules={[{ required: true, message: '请输入成本' }]}
>
<InputNumber min={0} precision={2} prefix="¥" className="w-full" />
</Form.Item>
</Form>
</Modal>
</>
);
};
export default PurchaseOrderPage;