Files
xin-procurement/web/pages/purchase/order.tsx
T
2026-08-14 21:28:55 +08:00

1197 lines
44 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,
Empty,
Form,
Image,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Select,
Space,
Spin,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import {AccountBookOutlined, 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 {
IBill,
IBillPrepare,
IPurchaseCell,
IPurchaseCellItem,
IPurchaseDetail,
IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
exportPurchase,
generateBill,
getBillPrepare,
getPurchaseCell,
getPurchaseDetail,
getPurchaseStoreSummary,
type BillGenerateStoreParams,
type PurchaseCellUpdateParams,
type PurchaseRowUpdateParams,
updatePurchaseCellItem,
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 [billOpen, setBillOpen] = useState(false);
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
const [billLoading, setBillLoading] = useState(false);
const [billSaving, setBillSaving] = useState(false);
const [billForm] = Form.useForm<{ stores: BillGenerateStoreParams[] }>();
// 弹窗内实时预览:附加金额 = 筐×筐单价 + 托盘×托盘单价;总额 = 商品 + 配送费 + 附加
const watchBillStores = Form.useWatch('stores', billForm) ?? [];
const billPreview = (billPrepare?.stores ?? []).map((row, index) => {
const input = watchBillStores[index] ?? {};
const deliveryFee = Number(input.delivery_fee ?? 0);
const added =
Number(input.box_num ?? 0) * Number(row.box_price) +
Number(input.tray_num ?? 0) * Number(row.tray_price);
return {
added,
total: Number(row.product_amount) + deliveryFee + added,
};
});
const billAllGenerated =
(billPrepare?.stores ?? []).length > 0 && billPrepare!.stores.every((row) => row.bill !== null);
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 openBillGenerate = async (id: number) => {
setBillOpen(true);
setBillLoading(true);
setBillPrepare(null);
try {
const res = await getBillPrepare(id);
const data = res.data.data ?? null;
setBillPrepare(data);
billForm.setFieldsValue({
stores: (data?.stores ?? []).map((row) => ({
store_id: row.store_id,
delivery_fee: row.bill ? Number(row.bill.delivery_fee) : 0,
box_num: row.bill ? row.bill.box_num : 0,
tray_num: row.bill ? row.bill.tray_num : 0,
})),
});
} finally {
setBillLoading(false);
}
};
/** 提交生成账单:按门店各生成一张并关联采购单全部订单,金额由系统汇总不可修改 */
const handleBillSave = async (values: { stores: BillGenerateStoreParams[] }) => {
if (!billPrepare) {
return;
}
setBillSaving(true);
try {
const res = await generateBill(billPrepare.purchase.id, values.stores);
message.success(`已生成 ${res.data.data?.count ?? 0} 张门店账单,采购单订单已关联`);
setBillOpen(false);
setBillPrepare(null);
await tableRef.current?.reload();
if (detail && detail.purchase.id === billPrepare.purchase.id) {
await loadDetail(detail.purchase.id!);
}
} finally {
setBillSaving(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 billColumns: TableProps<IBill>['columns'] = [
{ title: '门店', dataIndex: 'store_name', width: 140, align: 'center' },
{
title: '账单号',
dataIndex: 'bill_no',
width: 180,
align: 'center',
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
},
{ title: '账单日期', dataIndex: 'bill_date', width: 110, align: 'center' },
{
title: '商品金额',
dataIndex: 'product_amount',
width: 110,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '配送费',
dataIndex: 'delivery_fee',
width: 100,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '周转筐',
key: 'box',
width: 110,
align: 'center',
render: (_, row) => `${row.box_num} × ¥${Number(row.box_price).toFixed(2)}`,
},
{
title: '周转托盘',
key: 'tray',
width: 110,
align: 'center',
render: (_, row) => `${row.tray_num} × ¥${Number(row.tray_price).toFixed(2)}`,
},
{
title: '附加金额',
dataIndex: 'added_amount',
width: 100,
align: 'center',
render: (v) => ${Number(v).toFixed(2)}`,
},
{
title: '账单总金额',
dataIndex: 'total_amount',
width: 120,
align: 'center',
render: (v) => <Text strong type="danger">¥{Number(v).toFixed(2)}</Text>,
},
{
title: '支付状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (v) => {
const item = BILL_STATUS_MAP[Number(v ?? 0)];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
];
/** 门店账单合计行 */
const renderBillSummary = () => {
const bills = detail?.bills ?? [];
const totalProduct = bills.reduce((sum, row) => sum + Number(row.product_amount), 0);
const totalDelivery = bills.reduce((sum, row) => sum + Number(row.delivery_fee), 0);
const totalAdded = bills.reduce((sum, row) => sum + Number(row.added_amount), 0);
const totalAmount = bills.reduce((sum, row) => sum + Number(row.total_amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={3} align="center">
<Text strong>合计</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={3} align="center">
<Text strong>¥{totalProduct.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong>¥{totalDelivery.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} colSpan={2} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong>¥{totalAdded.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={8} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={9} align="center">
<Text strong>-</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>
) : (
<AuthButton auth="purchase.order.bill">
<Button
type={'primary'}
size="small"
variant={'solid'}
color={'orange'}
icon={<AccountBookOutlined />}
onClick={() => openBillGenerate(record.id!)}
>
生成账单
</Button>
</AuthButton>
)
];
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: 'bills', 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 === 'bills' ? (
detail.bills.length > 0 ? (
<Table<IBill>
rowKey="id"
size="small"
bordered
columns={billColumns}
dataSource={detail.bills}
pagination={false}
summary={renderBillSummary}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={detail.purchase.status === 0 ? '采购单完成后可在列表操作列生成账单' : '该采购单暂未生成账单'}
className="py-8!"
/>
)
) : (
<>
<Space style={{ marginBottom: 20 }}>
<AuthButton key="export" auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => detail && exportPurchase(detail.purchase.id!)}
>
导出
</Button>
</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={billPrepare ? `生成账单 · ${billPrepare.purchase.purchase_no}` : '生成账单'}
open={billOpen}
onCancel={() => {
setBillOpen(false);
setBillPrepare(null);
}}
onOk={() => billForm.submit()}
confirmLoading={billSaving}
okText="确认生成"
okButtonProps={{ disabled: billAllGenerated }}
width={960}
destroyOnHidden
>
<Spin spinning={billLoading}>
{billPrepare && (
<>
<div className="py-2 text-gray-500">
每个门店单独生成一张账单;商品金额由订单汇总不可修改,请填写各门店的配送费与周转筐/托盘数量。
{billAllGenerated ? '该采购单已全部生成账单,仅可查看。' : '生成后采购单中的全部订单将关联到对应门店账单。'}
</div>
<Form form={billForm} layout="vertical" onFinish={handleBillSave}>
<Form.List name="stores">
{(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="w-40 shrink-0">门店</div>
<div className="w-28 shrink-0 text-center">商品金额</div>
<div className="w-32 shrink-0 text-center">配送费(元)</div>
<div className="w-32 shrink-0 text-center">周转筐(¥{billPrepare.stores[0]?.box_price ?? '0.00'}</div>
<div className="w-32 shrink-0 text-center">周转托盘(¥{billPrepare.stores[0]?.tray_price ?? '0.00'}</div>
<div className="w-28 shrink-0 text-center">附加金额</div>
<div className="flex-1 text-center">账单总金额</div>
</div>
{fields.map((field) => {
const row = billPrepare.stores[field.name];
const billed = row?.bill != null;
return (
<div key={field.key} className="flex items-center border-t border-gray-100 px-4 py-2">
<div className="w-40 shrink-0 pr-2 text-sm">
<div>{row?.store_name ?? '-'}</div>
<div className="text-xs text-gray-400">
{row?.order_count ?? 0} 笔订单
{billed && <Tag className="ml-1!" color="success">已生成</Tag>}
</div>
</div>
<div className="w-28 shrink-0 text-center">
<Text strong>¥{row?.product_amount ?? '0.00'}</Text>
</div>
<Form.Item name={[field.name, 'store_id']} hidden>
<Input />
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'delivery_fee']}
rules={[{ required: true, message: '请输入配送费' }]}
>
<InputNumber
className="w-full"
min={0}
precision={2}
disabled={billed}
placeholder="配送费"
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'box_num']}
rules={[{ required: true, message: '请输入周转筐数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={billed}
placeholder="周转筐数量"
/>
</Form.Item>
<Form.Item
className="m-0! w-32 shrink-0 px-1!"
name={[field.name, 'tray_num']}
rules={[{ required: true, message: '请输入周转托盘数量' }]}
>
<InputNumber
className="w-full"
min={0}
precision={0}
disabled={billed}
placeholder="周转托盘数量"
/>
</Form.Item>
<div className="w-28 shrink-0 text-center">
¥{(billed ? Number(row.bill!.added_amount) : billPreview[field.name]?.added ?? 0).toFixed(2)}
</div>
<div className="flex-1 text-center">
<Text strong type="danger">
¥{(billed ? Number(row.bill!.total_amount) : billPreview[field.name]?.total ?? 0).toFixed(2)}
</Text>
{billed && (
<div className="text-xs text-gray-400">{row.bill!.bill_no}</div>
)}
</div>
</div>
);
})}
</div>
)}
</Form.List>
</Form>
</>
)}
</Spin>
</Modal>
</>
);
};
export default PurchaseOrderPage;