386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
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">
|
||
单价 = 成本 ÷ 包规;门店金额 = 数量 × 单价;行级「实际称重」保存时按各店数量比例分摊到订货明细(称重>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;
|