采购单优化

This commit is contained in:
liu
2026-08-12 23:20:05 +08:00
parent c2c56408a0
commit 0211e5e98d
22 changed files with 768 additions and 1425 deletions
+271 -48
View File
@@ -3,13 +3,17 @@ import {
Button,
Descriptions,
Drawer,
Empty,
Form,
Image,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Select,
Space,
Spin,
Table,
Tag,
Typography,
@@ -24,12 +28,17 @@ import type {
} from '@/components/XinTable/typings.ts';
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
import type {
IPurchaseCell,
IPurchaseCellItem,
IPurchaseDetail,
IPurchaseDetailRow,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
getPurchaseDetail,
getPurchaseCell,
getPurchaseDetail, type PurchaseCellUpdateParams, type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseRow,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
@@ -39,16 +48,7 @@ import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/** 行修改表单:品名/供应商/包规/单位/成本 */
interface RowEditForm {
product_name: string;
supplier_id?: number;
product_spec: string;
unit: string;
cost_price: number;
}
/** 单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径) */
/** 参考单价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
const calcUnitCost = (cost: number, spec: string): number => {
const pack = parseFloat(spec);
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost;
@@ -70,14 +70,32 @@ const PurchaseOrderPage: React.FC = () => {
// 行修改弹窗
const [editingRow, setEditingRow] = useState<IPurchaseDetailRow | null>(null);
const [rowSaving, setRowSaving] = useState(false);
const [syncTarget, setSyncTarget] = useState<'purchase' | 'product'>('purchase');
const [editForm] = Form.useForm<RowEditForm>();
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>();
useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
// 单元格下钻弹窗打开时加载明细
useEffect(() => {
if (cellOpen && cellQuery) {
loadCell();
}
}, [cellOpen, cellQuery]);
const loadDetail = async (id: number) => {
setDetailLoading(true);
try {
@@ -93,6 +111,63 @@ const PurchaseOrderPage: React.FC = () => {
await loadDetail(id);
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
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({
@@ -105,7 +180,7 @@ const PurchaseOrderPage: React.FC = () => {
};
/** 提交行修改:同步该商品全部订货明细;syncTarget=product 时追加同步商品档案 */
const handleEditSave = async (values: RowEditForm) => {
const handleEditSave = async (values: PurchaseRowUpdateParams) => {
if (!detail || !editingRow) {
return;
}
@@ -114,13 +189,8 @@ const PurchaseOrderPage: React.FC = () => {
const res = await updatePurchaseRow(detail.purchase.id!, editingRow.product_id, {
...values,
supplier_id: values.supplier_id ?? 0,
sync_product: syncTarget === 'product',
});
message.success(
syncTarget === 'product'
? `已同步 ${res.data.data?.count ?? 0} 条订货明细,并更新商品档案`
: `已同步 ${res.data.data?.count ?? 0} 条订货明细`,
);
message.success(`已更新 ${res.data.data?.count ?? 0} 条订货明细`);
setEditingRow(null);
await loadDetail(detail.purchase.id!);
await tableRef.current?.reload();
@@ -156,9 +226,9 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => row.supplier?.name ?? '-',
},
{
title: '单价',
title: '参考单价',
key: 'unit_cost',
width: 90,
width: 100,
align: 'center',
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
},
@@ -174,7 +244,11 @@ const PurchaseOrderPage: React.FC = () => {
align: 'center' as const,
render: (_: unknown, row: IPurchaseDetailRow) => {
const quantity = row.cells[store.id];
return quantity !== undefined ? <Text>{quantity}</Text> : <Text type="secondary">-</Text>;
return quantity !== undefined ? (
<Typography.Link onClick={() => openCell(row, store)}>{quantity}</Typography.Link>
) : (
<Text type="secondary">-</Text>
);
},
}));
@@ -387,7 +461,7 @@ const PurchaseOrderPage: React.FC = () => {
)}
</Drawer>
{/* 行修改:品名/供应商/包规/单位/成本,同步采购单明细 / 追加同步商品档案 */}
{/* 行修改:品名/供应商/包规/单位/成本 */}
<Modal
title={editingRow ? `修改「${editingRow.product_name}` : '修改明细行'}
open={editingRow !== null}
@@ -397,31 +471,13 @@ const PurchaseOrderPage: React.FC = () => {
<Button key="cancel" onClick={() => setEditingRow(null)}>
</Button>,
<Button
key="product"
loading={rowSaving && syncTarget === 'product'}
onClick={() => {
setSyncTarget('product');
editForm.submit();
}}
>
</Button>,
<Button
key="purchase"
type="primary"
loading={rowSaving && syncTarget === 'purchase'}
onClick={() => {
setSyncTarget('purchase');
editForm.submit();
}}
>
<Button key="purchase" type="primary" loading={rowSaving} onClick={() => editForm.submit()}>
</Button>,
</Button>
]}
>
<div className="py-2 text-gray-500">
</div>
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
<Form.Item
@@ -431,7 +487,11 @@ const PurchaseOrderPage: React.FC = () => {
>
<Input maxLength={100} />
</Form.Item>
<Form.Item label="供应商" name="supplier_id">
<Form.Item
label="供应商"
name="supplier_id"
rules={[{ required: true, message: '请选择供应商' }]}
>
<Select
allowClear
showSearch={{ optionFilterProp: 'label' }}
@@ -439,10 +499,18 @@ const PurchaseOrderPage: React.FC = () => {
options={suppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item label="包规" name="product_spec" rules={[{ max: 100 }]}>
<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={[{ max: 20 }]}>
<Form.Item
label="单位"
name="unit"
rules={[{ required: true, message: '请输入单位' },{ max: 20 }]}
>
<Input maxLength={20} placeholder="如:斤" />
</Form.Item>
<Form.Item
@@ -454,6 +522,161 @@ const PurchaseOrderPage: React.FC = () => {
</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">
{item.editable ? (
<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>
</>
);
};