Files
xin-procurement/web/pages/purchase/order.tsx
T
2026-08-13 12:34:32 +08:00

1107 lines
40 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, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Dropdown,
Empty,
Form,
Image,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Select,
Space,
Spin,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import {DownloadOutlined, EditOutlined, UnorderedListOutlined} 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 {
IPurchaseCell,
IPurchaseCellItem,
IPurchaseContainerStore,
IPurchaseDetail,
IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
exportPurchase,
getPurchaseCell,
getPurchaseDetail,
getPurchaseStoreSummary,
type PurchaseCellUpdateParams, type PurchaseContainerOrderParams, type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseContainer,
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;
/** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
const calcUnitRefPrice = (total: number, spec: string): number => {
const pack = parseFloat(spec);
return Number.isFinite(pack) && pack > 0 ? total / pack : total;
};
/**
* 采购单管理(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 [editForm] = Form.useForm<PurchaseRowUpdateParams>();
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 单元格下钻弹窗(门店 × 商品订货明细)
const [cellOpen, setCellOpen] = useState(false);
const [cellLoading, setCellLoading] = useState(false);
const [cellData, setCellData] = useState<IPurchaseCell | null>(null);
const [cellQuery, setCellQuery] = useState<{ productId: number; storeId: number } | null>(null);
// 单元格明细编辑(数量/称重)
const [cellItemOpen, setCellItemOpen] = useState(false);
const [cellItemTarget, setCellItemTarget] = useState<IPurchaseCellItem | null>(null);
const [cellItemSaving, setCellItemSaving] = useState(false);
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
// 门店购买详情(按商品聚合的门店采购汇总)
const [detailTab, setDetailTab] = useState('items');
const [storeId, setStoreId] = useState<number>(0);
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false);
// 周转框/托盘合并记录修改(按门店覆盖全部订单逐笔修改)
const [containerOpen, setContainerOpen] = useState(false);
const [containerStore, setContainerStore] = useState<IPurchaseContainerStore | null>(null);
const [containerSaving, setContainerSaving] = useState(false);
const [containerForm] = Form.useForm<{ orders: PurchaseContainerOrderParams[] }>();
// 弹窗内实时预览:合并合计 = Σ 各订单框/托盘数量,附加金额 = 合计 × 单价
const watchContainerOrders = Form.useWatch('orders', containerForm) ?? [];
const previewContainerBox = watchContainerOrders.reduce(
(sum, order) => sum + Number(order?.box_num ?? 0),
0,
);
const previewContainerTray = watchContainerOrders.reduce(
(sum, order) => sum + Number(order?.tray_num ?? 0),
0,
);
const previewContainerAdded =
previewContainerBox * Number(containerStore?.box_price ?? 0) +
previewContainerTray * Number(containerStore?.tray_price ?? 0);
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
// 单元格下钻弹窗打开时加载明细
useEffect(() => {
if (cellOpen && cellQuery) {
loadCell();
}
}, [cellOpen, cellQuery]);
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
useEffect(() => {
if (detail && detail.stores.length > 0) {
setStoreId((prev) => (detail.stores.some((s) => s.id === prev) ? prev : detail.stores[0].id));
}
}, [detail]);
// 切到「门店购买详情」页签或切换门店时加载汇总
useEffect(() => {
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
void loadStoreSummary();
}
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
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) => {
setDetailTab('items');
setStoreSummary(null);
setDetailOpen(true);
await loadDetail(id);
};
/** 加载门店购买详情(门店采购汇总) */
const loadStoreSummary = async () => {
if (!detail || storeId <= 0) {
return;
}
setStoreLoading(true);
try {
const res = await getPurchaseStoreSummary(detail.purchase.id!, storeId);
setStoreSummary(res.data.data ?? null);
} finally {
setStoreLoading(false);
}
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
setCellQuery({ productId: row.product_id, storeId: store.id });
setCellData(null);
setCellOpen(true);
};
/** 加载单元格明细 */
const loadCell = async () => {
if (!detail || !cellQuery) {
return;
}
setCellLoading(true);
try {
const res = await getPurchaseCell(detail.purchase.id!, cellQuery.productId, cellQuery.storeId);
setCellData(res.data.data ?? null);
} finally {
setCellLoading(false);
}
};
/** 单元格明细修改/同步后:刷新弹窗、采购单详情与列表 */
const refreshAfterCellChange = async () => {
await loadCell();
if (detail) {
await loadDetail(detail.purchase.id!);
}
await tableRef.current?.reload();
};
const openCellItemEdit = (item: IPurchaseCellItem) => {
setCellItemTarget(item);
cellItemForm.setFieldsValue({
quantity: item.quantity,
price: Number(item.price ?? 0),
weight: Number(item.weight ?? 0),
});
setCellItemOpen(true);
};
/** 提交单元格明细修改:级联重算明细金额、订货单与采购单汇总 */
const handleCellItemSave = async (values: PurchaseCellUpdateParams) => {
if (!cellItemTarget?.id) {
return;
}
setCellItemSaving(true);
try {
await updatePurchaseCellItem(cellItemTarget.id, values);
message.success('明细已更新,订货单与采购单汇总已重算');
setCellItemOpen(false);
setCellItemTarget(null);
await refreshAfterCellChange();
} finally {
setCellItemSaving(false);
}
};
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: PurchaseRowUpdateParams) => {
if (!detail || !editingRow) {
return;
}
setRowSaving(true);
try {
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
...values,
supplier_id: values.supplier_id ?? 0,
});
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
setEditingRow(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setRowSaving(false);
}
};
/** 打开周转框/托盘合并记录修改(初始化该门店全部订单的当前数量) */
const openContainerEdit = (store: IPurchaseContainerStore) => {
setContainerStore(store);
containerForm.setFieldsValue({
orders: store.orders.map((order) => ({
order_id: order.order_id,
box_num: order.box_num,
tray_num: order.tray_num,
})),
});
setContainerOpen(true);
};
/** 提交合并记录修改:逐笔更新订单,自动重算附加金额与订单总金额 */
const handleContainerSave = async (values: { orders: PurchaseContainerOrderParams[] }) => {
if (!detail || !containerStore) {
return;
}
setContainerSaving(true);
try {
await updatePurchaseContainer(detail.purchase.id!, containerStore.store_id, values.orders);
message.success('周转框/托盘已更新,订单金额已重算');
setContainerOpen(false);
setContainerStore(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
} finally {
setContainerSaving(false);
}
};
const handleComplete = async (id: number) => {
setCompleting(true);
try {
await Update(`/purchase/order/${id}/finish`, { status: 3 });
message.success('采购单已标记完成');
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: 100,
align: 'center',
render: (_, row) => ${calcUnitRefPrice(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 ? (
<Typography.Link onClick={() => openCell(row, store)}>{quantity}</Typography.Link>
) : (
<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) => (
<>
{ detail?.purchase.status === 0 ? (
<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 storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
{
title: '参考零售价',
key: 'retail_price',
width: 130,
align: 'center',
render: (_, row) => ${calcUnitRefPrice(Number(row.price), row.product_spec).toFixed(2)}`,
},
{ title: '包规', dataIndex: 'product_spec', width: 90, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '单价',
dataIndex: 'price',
width: 100,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 90,
align: 'center',
render: (v) => <Text strong>{v}</Text>,
},
{ title: '重量', dataIndex: 'weight', width: 90, align: 'center', render: (v) => `${v || '-'}斤` },
{
title: '预计金额',
dataIndex: 'amount',
width: 110,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
];
/** 门店购买详情合计行:总数量 + 总预计金额 */
const renderStoreTotal = () => {
const items = storeSummary?.items ?? [];
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={5} align="center">
<Text strong>合计</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 周转框/托盘合并记录列:门店/订单数/框合计/托盘合计/附加金额(合计可点击下钻逐订单修改) */
const containerColumns: TableProps<IPurchaseContainerStore>['columns'] = [
{ title: '门店', dataIndex: 'store_name', width: 180, align: 'center' },
{ title: '订单数', dataIndex: 'order_count', width: 90, align: 'center' },
{
title: '周转框合计',
dataIndex: 'box_num',
width: 110,
align: 'center',
render: (_, row) => (
<Typography.Link onClick={() => openContainerEdit(row)}>{row.box_num}</Typography.Link>
),
},
{
title: '周转托盘合计',
dataIndex: 'tray_num',
width: 120,
align: 'center',
render: (_, row) => (
<Typography.Link onClick={() => openContainerEdit(row)}>{row.tray_num}</Typography.Link>
),
},
{
title: '附加金额',
dataIndex: 'added_amount',
width: 110,
align: 'center',
render: (_, row) => <Text strong type="danger">¥{row.added_amount}</Text>,
},
];
/** 周转框/托盘合并记录合计行 */
const renderContainerSummary = () => {
const containers = detail?.containers ?? [];
const totalBox = containers.reduce((sum, row) => sum + row.box_num, 0);
const totalTray = containers.reduce((sum, row) => sum + row.tray_num, 0);
const totalAdded = containers.reduce((sum, row) => sum + Number(row.added_amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={2} align="center">
<Text strong>合计</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} align="center">
<Text strong>{totalBox}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="center">
<Text strong>{totalTray}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong type="danger">¥{totalAdded.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
const columns: XinTableColumn<IPurchaseOrder>[] = [
{
title: '采购单号',
dataIndex: 'purchase_no',
valueType: 'text',
hideInForm: true,
align: 'center',
width: 260,
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
},
{
title: '采购日期',
dataIndex: 'purchase_date',
valueType: 'date',
align: 'center',
hideInForm: true,
render: (_, record) => record.purchase_date,
},
{
title: '预估成本',
dataIndex: 'estimate_amount',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => ${record.estimate_amount}`,
},
{
title: '实际成本',
dataIndex: 'actual_amount',
hideInSearch: true,
valueType: "digit",
fieldProps: {
min: 0,
precision: 3,
placeholder: "请输入单价"
},
align: 'center',
render: (_, record) =>
Number(record.actual_amount) > 0 ? (
<Text strong>¥{record.actual_amount}</Text>
) : (
<Text type="secondary">未录入</Text>
),
},
{
title: '总件数',
dataIndex: 'total_quantity',
hideInForm: true,
hideInSearch: true,
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: "textarea",
hideInTable: true,
hideInSearch: true
},
{
title: '总重量',
dataIndex: 'total_weight',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => `${record.total_weight}斤`,
},
{
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, dom) => [
record.status === 0 ? dom.edit : null,
<Button key="detail" size="small" icon={<UnorderedListOutlined />} type={'primary'} onClick={() => openDetail(record.id!)} />,
record.status === 0 ? (
<AuthButton auth="purchase.order.update">
<Popconfirm title="确认该采购单已完成?" description="已完成采购单将锁定,不能再修改订单信息" onConfirm={() => handleComplete(record.id!)}>
<Button type={'primary'} size="small" variant={'solid'} color={'green'} loading={completing}>
完成采购
</Button>
</Popconfirm>
</AuthButton>
) : null
];
const tableProps: XinTableProps<IPurchaseOrder> = {
api: '/purchase/order',
columns,
rowKey: 'id',
accessName: 'purchase.order',
tableRef,
operateRender,
operateProps: {
width: 300
},
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 && (
<>
<Title level={5} className="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_quantity}</Descriptions.Item>
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{detail.purchase.created_at}</Descriptions.Item>
<Descriptions.Item label="备注" span={3}>{detail.purchase.remark}</Descriptions.Item>
</Descriptions>
<Tabs
activeKey={detailTab}
onChange={setDetailTab}
className="mt-3!"
items={[
{ key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' },
{ key: 'containers', label: '周转框/托盘' },
]}
/>
{ detailTab === 'stores' ? (
<>
<div className="mb-3 flex items-center gap-2">
<Text>选择门店:</Text>
<Select
value={storeId || undefined}
onChange={(value) => setStoreId(value)}
placeholder="选择门店"
className="w-60!"
showSearch
optionFilterProp="label"
options={detail.stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</div>
<Spin spinning={storeLoading}>
{storeSummary && storeSummary.items.length > 0 ? (
<Table<IPurchaseStoreItem>
rowKey="product_id"
size="small"
bordered
columns={storeColumns}
dataSource={storeSummary.items}
pagination={false}
summary={renderStoreTotal}
/>
) : (
!storeLoading && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该门店在此采购单中无采购商品"
className="py-8!"
/>
)
)}
</Spin>
</>
) : detailTab === 'containers' ? (
detail.containers.length > 0 ? (
<Table<IPurchaseContainerStore>
rowKey="store_id"
size="small"
bordered
columns={containerColumns}
dataSource={detail.containers}
pagination={false}
summary={renderContainerSummary}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该采购单暂无门店订单"
className="py-8!"
/>
)
) : (
<>
<Space style={{ marginBottom: 20 }}>
<AuthButton key="export" auth="purchase.order.export">
<Dropdown
menu={{
items: [
{ key: 'all', label: '导出全品类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'all') },
{ key: 'category', label: '仅导出蔬果分类', onClick: () => detail && exportPurchase(detail.purchase.id!, 'category') },
],
}}
>
<Button type="primary" ghost icon={<DownloadOutlined />}>
导出
</Button>
</Dropdown>
</AuthButton>
</Space>
<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="purchase" type="primary" loading={rowSaving} onClick={() => 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"
rules={[{ required: true, message: '请选择供应商' }]}
>
<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={[{ required: true, message: '请输入包规' }, { max: 100 }]}
>
<Input maxLength={100} placeholder="如:10斤/箱" />
</Form.Item>
<Form.Item
label="单位"
name="unit"
rules={[{ required: true, message: '请输入单位' },{ 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>
{/* 单元格下钻 */}
<Modal
title={cellData ? `${cellData.store?.name ?? ''} · ${cellData.product?.name ?? ''}` : '门店商品明细'}
open={cellOpen}
onCancel={() => setCellOpen(false)}
footer={null}
width={1000}
destroyOnHidden
styles={{ body: {paddingTop: 16} }}
>
<Spin spinning={cellLoading}>
{cellData && (
<>
{cellData.items.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该门店无此商品明细"
className="py-8!"
/>
) : (
<div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="flex-1">商品信息</div>
<div className="w-30 shrink-0 text-center">单价</div>
<div className="w-26 shrink-0 text-center">订货量</div>
<div className="w-33 shrink-0 text-center">订货金额</div>
<div className="w-33 shrink-0 text-center">重量</div>
<div className="w-40 shrink-0 text-center">操作</div>
</div>
{cellData.items.map((item) => (
<div key={item.id} className="flex items-center border-t border-gray-100 px-4 py-3">
<div className="flex min-w-0 flex-1 items-center">
<Image.PreviewGroup>
{item.image ? (
<Image
src={item.image}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
) : (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
暂无图片
</div>
)}
</Image.PreviewGroup>
<div className="ml-3 min-w-0">
<div className="text-sm font-medium">
{item.product_name}
<Tag
className="ml-2!"
color={STORE_ORDER_STATUS_MAP[item.order_status]?.color}
>
{STORE_ORDER_STATUS_MAP[item.order_status]?.text}
</Tag>
</div>
<div className="mt-0.5 text-xs text-gray-500">
订单:{item.order_no}
</div>
{item.remark ? (
<div className="mt-0.5 truncate text-xs text-gray-500">
备注:{item.remark}
</div>
) : null}
</div>
</div>
<div className="w-30 shrink-0 text-center">¥{item.price}</div>
<div className="w-26 shrink-0 text-center">{item.quantity}</div>
<div className="w-33 shrink-0 text-center">
<Text strong>¥{item.amount}</Text>
</div>
<div className="w-33 shrink-0 text-center">
{item.weight ?? '-'}
</div>
<div className="w-40 shrink-0 text-center">
{detail?.purchase.status === 0 ? (
<Space size={0}>
<AuthButton auth="purchase.order.update">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openCellItemEdit(item)}
>
编辑
</Button>
</AuthButton>
</Space>
) : (
<Text type="secondary">-</Text>
)}
</div>
</div>
))}
</div>
)}
{cellData.items.length > 0 && (
<div className="mt-3! flex justify-end gap-6 text-sm">
<Text type="secondary">
合计数量:
<Text strong>{cellData.items.reduce((sum, item) => sum + item.quantity, 0)}</Text>
</Text>
<Text type="secondary">
合计订货金额:
<Text strong type="danger">
¥
{cellData.items
.reduce((sum, item) => sum + Number(item.amount), 0)
.toFixed(2)}
</Text>
</Text>
</div>
)}
</>
)}
</Spin>
</Modal>
{/* 单元格明细编辑 */}
<Modal
title={cellItemTarget ? `编辑「${cellItemTarget.product_name}」` : '编辑明细'}
open={cellItemOpen}
onCancel={() => {
setCellItemOpen(false);
setCellItemTarget(null);
}}
onOk={() => cellItemForm.submit()}
confirmLoading={cellItemSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
订单 {cellItemTarget?.order_no};修改保存后,系统将自动重算明细金额、订货单与采购单汇总。
</div>
<Form form={cellItemForm} layout="vertical" onFinish={handleCellItemSave}>
<Form.Item
label="单价(元)"
name="price"
rules={[{ required: true, message: '请输入单价' }]}
>
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
</Form.Item>
<Form.Item
label="订货量"
name="quantity"
rules={[{ required: true, message: '请输入订货量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入订货量" />
</Form.Item>
<Form.Item label="称重" name="weight" rules={[{ required: true, message: '请输入称重' }]}>
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
{/* 周转框/托盘合并记录下钻:按门店展示全部订单逐笔修改(采购单已完成则只读) */}
<Modal
title={
containerStore
? `${containerStore.store_name} · 周转框/托盘`
: '周转框/托盘'
}
open={containerOpen}
onCancel={() => {
setContainerOpen(false);
setContainerStore(null);
}}
onOk={() => containerForm.submit()}
confirmLoading={containerSaving}
okText="保存"
okButtonProps={{ disabled: detail?.purchase.status !== 0 }}
width={760}
destroyOnHidden
>
<div className="py-2 text-gray-500">
该门店在本采购单下的全部订单({containerStore?.order_count ?? 0} 笔):
{detail?.purchase.status === 0
? '逐笔修改后保存,系统将自动重算每笔订单的附加金额与订单总金额。'
: '采购单已完成,仅可查看。'}
</div>
<Form form={containerForm} layout="vertical" onFinish={handleContainerSave}>
<Form.List name="orders">
{(fields) => (
<div className="overflow-hidden rounded border border-gray-200">
<div className="flex bg-gray-50 px-4 py-2 text-sm text-gray-500">
<div className="flex-1">订单号</div>
<div className="w-36 shrink-0 text-center">
周转框(单价 ¥{containerStore?.box_price ?? '0.00'}
</div>
<div className="w-36 shrink-0 text-center">
周转托盘(单价 ¥{containerStore?.tray_price ?? '0.00'}
</div>
</div>
{fields.map((field) => {
const order = containerStore?.orders[field.name];
return (
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
<div className="min-w-0 flex-1 pr-2 text-sm">
<Text copyable={{ text: order?.order_no ?? '' }}>{order?.order_no ?? '-'}</Text>
</div>
<Form.Item name={[field.name, 'order_id']} hidden>
<Input />
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0"
name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转框数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={detail?.purchase.status !== 0}
placeholder="周转框数量"
/>
</Form.Item>
<Form.Item
className="m-0! w-36 shrink-0"
name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={detail?.purchase.status !== 0}
placeholder="周转托盘数量"
/>
</Form.Item>
</div>
);
})}
</div>
)}
</Form.List>
</Form>
<Space orientation="vertical" className="mt-3! w-full rounded bg-gray-50 p-3">
<div>
<Text type="secondary">周转框合计:</Text>
<Text strong>{previewContainerBox}</Text>
<Text type="secondary" className="ml-6!">周转托盘合计:</Text>
<Text strong>{previewContainerTray}</Text>
</div>
<div>
<Text type="secondary">附加金额合计:</Text>
<Text strong type="danger">¥{previewContainerAdded.toFixed(2)}</Text>
</div>
</Space>
</Modal>
</>
);
};
export default PurchaseOrderPage;