采购单优化

This commit is contained in:
liu
2026-08-17 18:13:33 +08:00
parent a06c45dc49
commit 3dbb9205a4
16 changed files with 2215 additions and 437 deletions
+491 -20
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Descriptions,
@@ -11,6 +11,7 @@ import {
message,
Modal,
Popconfirm,
Radio,
Select,
Space,
Spin,
@@ -19,7 +20,7 @@ import {
Tag,
Typography,
} from 'antd';
import {AccountBookOutlined, DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
import {AccountBookOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
@@ -37,25 +38,35 @@ import type {
IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
IPurchaseSupplierItem,
PurchaseStoreItemAddParams,
PurchaseStoreItemUpdateParams,
} 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 {
addPurchaseStoreItem,
exportPurchase,
exportPurchaseStores,
exportPurchaseSuppliers,
generateBill,
getBillPrepare,
getPurchaseCell,
getPurchaseDetail,
getPurchaseStoreSummary,
removePurchaseStoreItem,
type BillGenerateStoreParams,
type PurchaseCellUpdateParams,
type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseRow,
updatePurchaseStoreItem,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import { getProductOptions } from '@/api/product/goods.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import type IProduct from '@/domain/iProduct.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
@@ -103,6 +114,56 @@ const PurchaseOrderPage: React.FC = () => {
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false);
// 门店购买详情:新增单品弹窗
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemSaving, setAddItemSaving] = useState(false);
const [addItemForm] = Form.useForm<PurchaseStoreItemAddParams>();
const [productOptions, setProductOptions] = useState<IProduct[]>([]);
// 门店购买详情:单品编辑弹窗(数量/称重/单价)
const [storeItemTarget, setStoreItemTarget] = useState<IPurchaseStoreItem | null>(null);
const [storeItemSaving, setStoreItemSaving] = useState(false);
const [storeItemForm] = Form.useForm<PurchaseStoreItemUpdateParams>();
// 供应商采购明细页签
const [supplierId, setSupplierId] = useState<number>(0);
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
const [itemExportOpen, setItemExportOpen] = useState(false);
const [itemExportSupplier, setItemExportSupplier] = useState<number>(0);
const [storeExportOpen, setStoreExportOpen] = useState(false);
const [storeExportScope, setStoreExportScope] = useState<'current' | 'all'>('current');
const [supplierExportOpen, setSupplierExportOpen] = useState(false);
const [supplierExportScope, setSupplierExportScope] = useState<'current' | 'all'>('current');
/** 采购单内出现的供应商(矩阵/供应商页签筛选与导出选项共用) */
const purchaseSuppliers = useMemo(() => {
const map = new Map<number, string>();
(detail?.items ?? []).forEach((row) => {
if (row.supplier_id > 0 && row.supplier?.name) {
map.set(row.supplier_id, row.supplier.name);
}
});
return Array.from(map, ([id, name]) => ({ id, name }));
}, [detail?.items]);
/** 供应商采购明细行:按供应商过滤矩阵行,金额=数量×成本价(成本口径) */
const supplierItems = useMemo<IPurchaseSupplierItem[]>(() => {
if (!detail || supplierId <= 0) return [];
return detail.items
.filter((row) => row.supplier_id === supplierId)
.map((row) => ({
product_id: row.product_id,
product_name: row.product_name,
product_spec: row.product_spec,
unit: row.unit,
cost_price: String(row.cost_price),
quantity: row.quantity,
weight: String(row.weight),
amount: (row.quantity * Number(row.cost_price)).toFixed(2),
}));
}, [detail, supplierId]);
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
const [billOpen, setBillOpen] = useState(false);
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
@@ -144,6 +205,15 @@ const PurchaseOrderPage: React.FC = () => {
}
}, [detail]);
// 详情加载后默认选中第一个供应商(当前选中供应商仍在采购单内则保留)
useEffect(() => {
if (purchaseSuppliers.length > 0) {
setSupplierId((prev) => (purchaseSuppliers.some((s) => s.id === prev) ? prev : purchaseSuppliers[0].id));
} else {
setSupplierId(0);
}
}, [purchaseSuppliers]);
// 切到「门店购买详情」页签或切换门店时加载汇总
useEffect(() => {
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
@@ -182,6 +252,76 @@ const PurchaseOrderPage: React.FC = () => {
}
};
/** 门店单品增删改后:刷新门店汇总、采购单详情与列表 */
const refreshAfterStoreItemChange = async () => {
await loadStoreSummary();
if (detail) {
await loadDetail(detail.purchase.id!);
}
await tableRef.current?.reload();
};
/** 打开新增单品弹窗(懒加载商品选项) */
const openAddItem = () => {
addItemForm.resetFields();
setAddItemOpen(true);
if (productOptions.length === 0) {
getProductOptions().then((res) => setProductOptions(res.data.data ?? []));
}
};
/** 提交新增单品 */
const handleAddItemSave = async (values: PurchaseStoreItemAddParams) => {
if (!detail || storeId <= 0) {
return;
}
setAddItemSaving(true);
try {
await addPurchaseStoreItem(detail.purchase.id!, storeId, values);
message.success('已添加单品,订货单与采购单汇总已重算');
setAddItemOpen(false);
await refreshAfterStoreItemChange();
} finally {
setAddItemSaving(false);
}
};
/** 打开单品编辑弹窗(回显加权平均单价/数量/称重) */
const openStoreItemEdit = (row: IPurchaseStoreItem) => {
setStoreItemTarget(row);
storeItemForm.setFieldsValue({
quantity: row.quantity,
price: Number(row.price ?? 0),
weight: Number(row.weight ?? 0),
});
};
/** 提交单品修改:同商品多笔订单明细合并到最早一条,级联重算 */
const handleStoreItemSave = async (values: PurchaseStoreItemUpdateParams) => {
if (!detail || !storeItemTarget || storeId <= 0) {
return;
}
setStoreItemSaving(true);
try {
await updatePurchaseStoreItem(detail.purchase.id!, storeId, storeItemTarget.product_id, values);
message.success('单品已更新,订货单与采购单汇总已重算');
setStoreItemTarget(null);
await refreshAfterStoreItemChange();
} finally {
setStoreItemSaving(false);
}
};
/** 移除单品 */
const handleStoreItemRemove = async (row: IPurchaseStoreItem) => {
if (!detail || storeId <= 0) {
return;
}
await removePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id);
message.success('已移除单品,订货单与采购单汇总已重算');
await refreshAfterStoreItemChange();
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
setCellQuery({ productId: row.product_id, storeId: store.id });
@@ -323,7 +463,7 @@ const PurchaseOrderPage: React.FC = () => {
}
};
/** 明细矩阵列:品名/供应商/价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
/** 明细矩阵列:品名/供应商/参考零售价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' },
@@ -335,11 +475,17 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => row.supplier?.name ?? '-',
},
{
title: '参考成本单价',
key: 'unit_cost',
title: '参考零售价',
key: 'retail_price',
width: 100,
align: 'center',
render: (_, row) => `¥${calcUnitRefPrice(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
render: (_, row) => {
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
const quantity = Number(row.quantity ?? 0);
if (quantity <= 0) return <Text type="secondary">-</Text>;
const weightedPrice = Number(row.amount ?? 0) / quantity;
return `¥${calcUnitRefPrice(weightedPrice, 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 || '-' },
@@ -430,7 +576,7 @@ const PurchaseOrderPage: React.FC = () => {
);
};
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 */
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
{
@@ -464,6 +610,40 @@ const PurchaseOrderPage: React.FC = () => {
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
{
title: '操作',
key: 'action',
width: 130,
align: 'center',
render: (_, row) =>
detail?.purchase.status === 0 ? (
<Space size={0}>
<AuthButton auth="purchase.order.update">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openStoreItemEdit(row)}
>
</Button>
</AuthButton>
<AuthButton auth="purchase.order.update">
<Popconfirm
title="移除该单品?"
description={`将从「${storeSummary?.store?.name ?? '该门店'}」采购明细中移除「${row.product_name}`}
onConfirm={() => handleStoreItemRemove(row)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</AuthButton>
</Space>
) : (
<Text type="secondary">-</Text>
),
},
];
/** 门店购买详情合计行:总数量 + 总预计金额 */
@@ -486,6 +666,61 @@ const PurchaseOrderPage: React.FC = () => {
<Table.Summary.Cell index={7} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={8} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 供应商采购明细列:品名/包规/单位/成本价/数量/重量/金额(成本口径) */
const supplierColumns: TableProps<IPurchaseSupplierItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name', width: 180, align: 'center' },
{ title: '包规', dataIndex: 'product_spec', width: 110, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '成本价',
dataIndex: 'cost_price',
width: 110,
align: 'center',
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 100,
align: 'center',
render: (v) => <Text strong>{v}</Text>,
},
{ title: '重量', dataIndex: 'weight', width: 110, align: 'center', render: (v) => `${Number(v).toFixed(3)}` },
{
title: '金额',
dataIndex: 'amount',
width: 120,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
];
/** 供应商采购明细合计行 */
const renderSupplierTotal = () => {
const totalQuantity = supplierItems.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalWeight = supplierItems.reduce((sum, row) => sum + Number(row.weight), 0);
const totalAmount = supplierItems.reduce((sum, row) => sum + Number(row.amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={4} align="center">
<Text strong></Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
@@ -770,23 +1005,48 @@ const PurchaseOrderPage: React.FC = () => {
items={[
{ key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' },
{ key: 'suppliers', 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 className="mb-3 flex items-center justify-between gap-2">
<div className="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>
<Space>
{detail.purchase.status === 0 && (
<AuthButton auth="purchase.order.update">
<Button icon={<PlusOutlined />} onClick={openAddItem}>
</Button>
</AuthButton>
)}
<AuthButton auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => {
setStoreExportScope('current');
setStoreExportOpen(true);
}}
>
</Button>
</AuthButton>
</Space>
</div>
<Spin spinning={storeLoading}>
{storeSummary && storeSummary.items.length > 0 ? (
@@ -810,6 +1070,53 @@ const PurchaseOrderPage: React.FC = () => {
)}
</Spin>
</>
) : detailTab === 'suppliers' ? (
<>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Text></Text>
<Select
value={supplierId || undefined}
onChange={(value) => setSupplierId(value)}
placeholder="选择供应商"
className="w-60!"
showSearch
optionFilterProp="label"
options={purchaseSuppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
</div>
<AuthButton auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => {
setSupplierExportScope('current');
setSupplierExportOpen(true);
}}
>
</Button>
</AuthButton>
</div>
{supplierItems.length > 0 ? (
<Table<IPurchaseSupplierItem>
rowKey="product_id"
size="small"
bordered
columns={supplierColumns}
dataSource={supplierItems}
pagination={false}
summary={renderSupplierTotal}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该供应商在此采购单中无采购明细"
className="py-8!"
/>
)}
</>
) : detailTab === 'bills' ? (
detail.bills.length > 0 ? (
<Table<IBill>
@@ -836,7 +1143,10 @@ const PurchaseOrderPage: React.FC = () => {
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => detail && exportPurchase(detail.purchase.id!)}
onClick={() => {
setItemExportSupplier(0);
setItemExportOpen(true);
}}
>
</Button>
@@ -874,7 +1184,7 @@ const PurchaseOrderPage: React.FC = () => {
]}
>
<div className="py-2 text-gray-500">
</div>
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
<Form.Item
@@ -1187,6 +1497,167 @@ const PurchaseOrderPage: React.FC = () => {
)}
</Spin>
</Modal>
{/* 新增单品:选择商品 + 采购数量 + 称重 */}
<Modal
title={`新增单品 · ${storeSummary?.store?.name ?? ''}`}
open={addItemOpen}
onCancel={() => setAddItemOpen(false)}
onOk={() => addItemForm.submit()}
confirmLoading={addItemSaving}
okText="添加"
destroyOnHidden
>
<div className="py-2 text-gray-500">
</div>
<Form form={addItemForm} layout="vertical" onFinish={handleAddItemSave}>
<Form.Item
label="商品"
name="product_id"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select
showSearch={{ optionFilterProp: 'label' }}
placeholder="搜索并选择商品"
options={productOptions.map((p) => ({
value: p.id!,
label: `${p.name}${p.spec ? `${p.spec}` : ''}`,
}))}
/>
</Form.Item>
<Form.Item
label="采购数量"
name="quantity"
rules={[{ required: true, message: '请输入采购数量' }]}
>
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入采购数量(包数)" />
</Form.Item>
<Form.Item label="称重(斤,可选)" name="weight">
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
{/* 单品编辑:数量/称重/单价(同商品多笔订单明细合并到最早一条) */}
<Modal
title={storeItemTarget ? `编辑「${storeItemTarget.product_name}` : '编辑单品'}
open={storeItemTarget !== null}
onCancel={() => setStoreItemTarget(null)}
onOk={() => storeItemForm.submit()}
confirmLoading={storeItemSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
</div>
<Form form={storeItemForm} layout="vertical" onFinish={handleStoreItemSave}>
<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="导出商品明细"
open={itemExportOpen}
onCancel={() => setItemExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchase(detail.purchase.id!, itemExportSupplier > 0 ? itemExportSupplier : undefined);
}
setItemExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<div className="py-2 text-gray-500">
0
</div>
<div className="flex items-center gap-2 py-2">
<Text></Text>
<Select
value={itemExportSupplier}
onChange={setItemExportSupplier}
className="flex-1"
options={[
{ value: 0, label: '全部供应商' },
...purchaseSuppliers.map((s) => ({ value: s.id, label: s.name })),
]}
/>
</div>
</Modal>
{/* 门店购买详情导出:当前门店 / 全部门店(多工作表) */}
<Modal
title="导出门店购买详情"
open={storeExportOpen}
onCancel={() => setStoreExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchaseStores(
detail.purchase.id!,
storeExportScope === 'current' && storeId > 0 ? storeId : undefined,
);
}
setStoreExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<Radio.Group
className="py-2"
value={storeExportScope}
onChange={(e) => setStoreExportScope(e.target.value)}
options={[
{ value: 'current', label: `当前门店(${detail?.stores.find((s) => s.id === storeId)?.name ?? '-'}` },
{ value: 'all', label: '全部门店(合并为一个 XLSX,每门店一个工作表)' },
]}
/>
</Modal>
{/* 供应商采购明细导出:当前供应商 / 全部供应商(多工作表) */}
<Modal
title="导出供应商采购明细"
open={supplierExportOpen}
onCancel={() => setSupplierExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchaseSuppliers(
detail.purchase.id!,
supplierExportScope === 'current' && supplierId > 0 ? supplierId : undefined,
);
}
setSupplierExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<Radio.Group
className="py-2"
value={supplierExportScope}
onChange={(e) => setSupplierExportScope(e.target.value)}
options={[
{ value: 'current', label: `当前供应商(${purchaseSuppliers.find((s) => s.id === supplierId)?.name ?? '-'}` },
{ value: 'all', label: '全部供应商(合并为一个 XLSX,每供应商一个工作表)' },
]}
/>
</Modal>
</>
);
};