Files
xin-procurement/web/pages/purchase/order.tsx
T
2026-08-12 14:38:20 +08:00

386 lines
13 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,
Descriptions,
Drawer,
Dropdown,
InputNumber,
message,
Popconfirm,
Table,
Tag,
Typography,
} from 'antd';
import { CheckOutlined, DownloadOutlined } 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 {
exportPurchase,
getPurchaseDetail,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 行草稿:成本/称重 + 各门店单元格数量 */
interface RowDraft {
cost_price?: number;
weight?: number;
cells: Record<number, number>;
}
/**
* 采购单管理(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 [drafts, setDrafts] = useState<Record<number, RowDraft>>({});
const [savingProductId, setSavingProductId] = useState<number | null>(null);
const [completing, setCompleting] = useState(false);
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
const res = await getPurchaseDetail(id);
setDetail(res.data.data ?? null);
setDrafts({});
} finally {
setDetailLoading(false);
}
};
const openDetail = async (id: number) => {
setDetailOpen(true);
await loadDetail(id);
};
const getDraft = (productId: number): RowDraft => drafts[productId] ?? { cells: {} };
const patchDraft = (productId: number, patch: Partial<RowDraft>) => {
setDrafts((prev) => {
const current = prev[productId] ?? { cells: {} };
return {
...prev,
[productId]: { ...current, ...patch, cells: { ...current.cells, ...patch.cells } },
};
});
};
/** 草稿成本(未修改取原值) */
const draftCost = (row: IPurchaseDetailRow): number =>
getDraft(row.product_id).cost_price ?? row.cost_price;
const isRowDirty = (row: IPurchaseDetailRow): boolean => {
// const draft = getDraft(row.product_id);
// if (draft.cost_price !== undefined && draft.cost_price !== row.cost_price) {
// return true;
// }
// if (draft.weight !== undefined && draft.weight !== row.weight) {
// return true;
// }
// return row.cells.some(
// (cell) => draft.cells[cell.order_item_id] !== undefined
// && draft.cells[cell.order_item_id] !== cell.quantity,
// );
};
/** 保存一行:先落各门店单元格数量,再落行级成本/称重,最后刷新 */
const saveRow = async (row: IPurchaseDetailRow) => {
if (!detail || !isRowDirty(row)) {
return;
}
const draft = getDraft(row.product_id);
setSavingProductId(row.product_id);
try {
// for (const cell of row.cells) {
// const qty = draft.cells[cell.order_item_id];
// if (qty !== undefined && qty !== cell.quantity) {
// await updatePurchaseCell(cell.order_item_id, { quantity: qty });
// }
// }
// const rowPatch: { cost_price?: number; weight?: number } = {};
// if (draft.cost_price !== undefined && draft.cost_price !== row.cost_price) {
// rowPatch.cost_price = draft.cost_price;
// }
// if (draft.weight !== undefined && draft.weight !== row.weight) {
// rowPatch.weight = draft.weight;
// }
// if (Object.keys(rowPatch).length > 0) {
// await updatePurchaseRow(detail.purchase.id!, row.product_id, rowPatch);
// }
message.success('已保存并同步订货明细');
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setSavingProductId(null);
}
};
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: 80,
align: 'center',
render: (_, row) => ${(Number(row.cost_price) / Number(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: 110, align: 'center', render: (v) => v || '-' }
];
const storeColumns = (detail?.stores ?? []).map((store) => ({
title: store.name,
key: `store-${store.id}`,
align: 'center' as const,
render: (_: unknown, row: IPurchaseDetailRow) => {
const cell = row.cells[store.id];
if (!cell) {
return <Text type="secondary">-</Text>;
}
return (
<Text>{ row.cells[store.id] }</Text>
);
},
}));
const tail: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{
title: '合计数量',
key: 'total_quantity',
width: 90,
align: 'center',
render: (_, row) => Object.values(row.cells).reduce((a, b) => a + b, 0),
},
{
title: '操作',
key: 'action',
width: 80,
fixed: 'right',
render: (_, row) => (
<AuthButton auth="purchase.order.update">
<Button
size="small"
type="link"
disabled={!isRowDirty(row)}
loading={savingProductId === row.product_id}
onClick={() => saveRow(row)}
>
修改
</Button>
</AuthButton>
),
},
];
return [...fixed, ...storeColumns, ...tail];
};
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,
};
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}
>
{detail ? (
<>
<Descriptions
column={3}
size="small"
bordered
extra={
detail.purchase.status === 0 ? (
<AuthButton auth="purchase.order.update">
<Popconfirm title="确认标记该采购单为已完成?" onConfirm={handleComplete}>
<Button size="small" icon={<CheckOutlined />} loading={completing}>
标记完成
</Button>
</Popconfirm>
</AuthButton>
) : undefined
}
>
<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>
<div className="my-2 text-gray-500">
单价 = 成本 ÷ 包规;门店金额 = 数量 × 单价;行级「实际称重」保存时按各店数量比例分摊到订货明细(称重&gt;0 时金额按称重×单价计)。
</div>
<Table<IPurchaseDetailRow>
rowKey="product_id"
size="small"
bordered
columns={buildItemColumns()}
dataSource={detail.items}
pagination={false}
scroll={{ x: 'max-content' }}
/>
</>
) : null}
</Drawer>
</>
);
};
export default PurchaseOrderPage;