feat(mes): 迁移 materialstock

pull/350/head
YunaiV 2026-05-29 19:26:04 +08:00
parent f27942c8f9
commit 8192bb4777
10 changed files with 1735 additions and 0 deletions

View File

@ -0,0 +1,2 @@
export { default as WmMaterialStockSelectDialog } from './wm-material-stock-select-dialog.vue';
export { default as WmMaterialStockSelect } from './wm-material-stock-select.vue';

View File

@ -0,0 +1,270 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { computed, nextTick, ref } from 'vue';
import { Alert, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import MdItemTypeTree from '#/views/mes/md/item/type/components/md-item-type-tree.vue';
import { useSelectGridColumns, useSelectGridFormSchema } from '../data';
/** 虚拟仓过滤模式:'exclude' 排除虚拟仓(默认),'only' 只看虚拟仓,'all' 不过滤 */
type VirtualFilter = 'all' | 'exclude' | 'only';
const props = withDefaults(
defineProps<{
batchId?: number;
itemId?: number;
virtualFilter?: VirtualFilter;
warehouseId?: number;
}>(),
{
batchId: undefined,
itemId: undefined,
virtualFilter: 'exclude',
warehouseId: undefined,
},
);
const emit = defineEmits<{
selected: [rows: MesWmMaterialStockApi.MaterialStock[]];
}>();
const open = ref(false);
const multiple = ref(true);
const syncingSingleSelection = ref(false);
const selectedRows = ref<MesWmMaterialStockApi.MaterialStock[]>([]);
const preSelectedIds = ref<number[]>([]);
const searchItemTypeId = ref<number>();
/** 当前 props 是否带预过滤 */
const showAlert = computed(
() =>
props.batchId != null ||
props.warehouseId != null ||
props.virtualFilter === 'only',
);
/** 预过滤提示文字 */
const alertTitle = computed(() => {
const parts: string[] = [];
if (props.batchId != null) {
parts.push('批次');
}
if (props.warehouseId != null) {
parts.push('仓库');
}
if (props.virtualFilter === 'only') {
parts.push('只看虚拟仓');
}
return `已按${parts.join('/')}预过滤`;
});
/** 单选模式同步 VXE 勾选状态 */
async function syncSingleSelection(row?: MesWmMaterialStockApi.MaterialStock) {
syncingSingleSelection.value = true;
await nextTick();
await gridApi.grid.clearCheckboxRow();
if (row) {
await gridApi.grid.setCheckboxRow(row, true);
}
await nextTick();
syncingSingleSelection.value = false;
}
/** 处理勾选变化 */
async function handleCheckboxChange({
checked,
records,
row,
}: {
checked: boolean;
records: MesWmMaterialStockApi.MaterialStock[];
row?: MesWmMaterialStockApi.MaterialStock;
}) {
if (syncingSingleSelection.value) {
return;
}
if (!multiple.value) {
const selected = checked && row ? [row] : [];
selectedRows.value = selected;
await syncSingleSelection(selected[0]);
return;
}
selectedRows.value = records;
}
/** 处理全选变化 */
function handleCheckboxAll({
records,
}: {
records: MesWmMaterialStockApi.MaterialStock[];
}) {
if (syncingSingleSelection.value) {
return;
}
selectedRows.value = records;
}
/** 双击行:单选直接确认;多选切换勾选 */
async function handleRowDblclick({
row,
}: {
row: MesWmMaterialStockApi.MaterialStock;
}) {
if (multiple.value) {
const checked = !gridApi.grid.isCheckedByCheckboxRow(row);
await gridApi.grid.setCheckboxRow(row, checked);
handleCheckboxChange({
checked,
records: gridApi.grid.getCheckboxRecords() as MesWmMaterialStockApi.MaterialStock[],
row,
});
return;
}
selectedRows.value = [row];
await syncSingleSelection(row);
handleConfirm();
}
/** 回显预选 */
function applyPreSelection() {
if (preSelectedIds.value.length === 0) {
return;
}
const rows = gridApi.grid.getData() as MesWmMaterialStockApi.MaterialStock[];
for (const row of rows) {
if (row.id && preSelectedIds.value.includes(row.id)) {
gridApi.grid.setCheckboxRow(row, true);
if (!multiple.value) {
selectedRows.value = [row];
}
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useSelectGridFormSchema(),
},
gridOptions: {
columns: useSelectGridColumns(),
height: 480,
keepSource: true,
checkboxConfig: { highlight: true, range: true, reserve: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMaterialStockPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
batchId: props.batchId,
//
frozen: false,
itemTypeId: searchItemTypeId.value,
itemId: formValues.itemId ?? props.itemId,
warehouseId: formValues.warehouseId ?? props.warehouseId,
virtualFilter:
props.virtualFilter === 'all' ? undefined : props.virtualFilter,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>,
gridEvents: {
cellDblclick: handleRowDblclick,
checkboxAll: handleCheckboxAll,
checkboxChange: handleCheckboxChange,
},
});
/** 物料分类树节点点击 */
function handleTypeNodeClick(row: undefined | { id?: number }) {
searchItemTypeId.value = row?.id;
gridApi.query();
}
/** 重置查询和选择状态 */
async function resetQueryState() {
selectedRows.value = [];
searchItemTypeId.value = undefined;
await gridApi.grid.clearCheckboxRow();
await gridApi.formApi.resetForm();
}
/** 打开弹窗 */
async function openModal(
selectedIds?: number[],
options?: { multiple?: boolean },
) {
open.value = true;
multiple.value = options?.multiple ?? true;
preSelectedIds.value = selectedIds || [];
await nextTick();
await resetQueryState();
await gridApi.query();
await nextTick();
applyPreSelection();
}
/** 关闭弹窗 */
async function closeModal() {
open.value = false;
await resetQueryState();
}
/** 确认选择 */
function handleConfirm() {
if (selectedRows.value.length === 0) {
message.warning(multiple.value ? '请至少选择一条数据' : '请选择一条数据');
return;
}
emit(
'selected',
multiple.value ? selectedRows.value : [selectedRows.value[0]!],
);
open.value = false;
}
defineExpose({ open: openModal });
</script>
<template>
<Modal
v-model:open="open"
:destroy-on-close="true"
title="库存物资选择"
width="80%"
@cancel="closeModal"
@ok="handleConfirm"
>
<Alert
v-if="showAlert"
class="mb-3"
:message="alertTitle"
show-icon
type="info"
/>
<div class="flex gap-3">
<div class="bg-card w-1/6 rounded p-2">
<MdItemTypeTree @node-click="handleTypeNodeClick" />
</div>
<div class="w-5/6">
<Grid table-title="" />
</div>
</div>
</Modal>
</template>

View File

@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { computed, ref, useAttrs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input, Tooltip } from 'ant-design-vue';
import { getMaterialStock } from '#/api/mes/wm/materialstock';
import WmMaterialStockSelectDialog from './wm-material-stock-select-dialog.vue';
defineOptions({ name: 'WmMaterialStockSelect', inheritAttrs: false });
const props = withDefaults(
defineProps<{
allowClear?: boolean;
batchId?: number;
disabled?: boolean;
itemId?: number;
modelValue?: number;
placeholder?: string;
virtualFilter?: 'all' | 'exclude' | 'only';
warehouseId?: number;
}>(),
{
allowClear: true,
batchId: undefined,
disabled: false,
itemId: undefined,
modelValue: undefined,
placeholder: '请选择库存',
virtualFilter: 'exclude',
warehouseId: undefined,
},
);
const emit = defineEmits<{
change: [item: MesWmMaterialStockApi.MaterialStock | undefined];
'update:modelValue': [value: number | undefined];
}>();
const attrs = useAttrs();
const dialogRef = ref<InstanceType<typeof WmMaterialStockSelectDialog>>();
const hovering = ref(false);
const selectedItem = ref<MesWmMaterialStockApi.MaterialStock>();
const displayLabel = computed(() => {
const item = selectedItem.value;
if (!item) {
return '';
}
return `${item.warehouseName || '-'} / ${item.batchCode || '-'} / 数量:${item.quantity}`;
});
const showClear = computed(
() =>
props.allowClear &&
!props.disabled &&
hovering.value &&
props.modelValue != null,
);
/** 根据 ID 单条查询库存信息(用于编辑回显) */
async function resolveItemById(id: number | undefined) {
if (id == null) {
selectedItem.value = undefined;
return;
}
if (selectedItem.value?.id === id) {
return;
}
try {
selectedItem.value = await getMaterialStock(id);
} catch (error) {
console.error('[WmMaterialStockSelect] resolveItemById failed:', error);
}
}
watch(() => props.modelValue, resolveItemById, { immediate: true });
/** 清空已选库存 */
function clearSelected() {
selectedItem.value = undefined;
emit('update:modelValue', undefined);
emit('change', undefined);
}
/** 打开库存选择弹窗 */
function handleClick(event: MouseEvent) {
if (props.disabled) {
return;
}
const target = event.target as HTMLElement;
if (showClear.value && target.closest('.ant-input-suffix')) {
event.stopPropagation();
clearSelected();
return;
}
const selectedIds = props.modelValue == null ? [] : [props.modelValue];
dialogRef.value?.open(selectedIds, { multiple: false });
}
/** 弹窗选中回调 */
function handleSelected(rows: MesWmMaterialStockApi.MaterialStock[]) {
const item = rows[0];
if (!item) {
return;
}
selectedItem.value = item;
emit('update:modelValue', item.id);
emit('change', item);
}
</script>
<template>
<div
v-bind="attrs"
class="w-full"
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
@click="handleClick"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
<template #title>
<div v-if="selectedItem" class="leading-6">
<div>物料{{ selectedItem.itemName || '-' }}</div>
<div>批次{{ selectedItem.batchCode || '-' }}</div>
<div>数量{{ selectedItem.quantity ?? '-' }}</div>
<div>仓库{{ selectedItem.warehouseName || '-' }}</div>
<div>库区{{ selectedItem.locationName || '-' }}</div>
<div>库位{{ selectedItem.areaName || '-' }}</div>
</div>
</template>
<Input
:disabled="disabled"
:placeholder="placeholder"
:value="displayLabel"
readonly
>
<template #suffix>
<IconifyIcon
class="size-4"
:icon="showClear ? 'lucide:circle-x' : 'lucide:search'"
/>
</template>
</Input>
</Tooltip>
</div>
<WmMaterialStockSelectDialog
ref="dialogRef"
:batch-id="batchId"
:item-id="itemId"
:virtual-filter="virtualFilter"
:warehouse-id="warehouseId"
@selected="handleSelected"
/>
</template>

View File

@ -0,0 +1,276 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import MdItemSelect from '#/views/mes/md/item/components/md-item-select.vue';
import MdVendorSelect from '#/views/mes/md/vendor/components/md-vendor-select.vue';
import {
WmWarehouseAreaSelect,
WmWarehouseLocationSelect,
WmWarehouseSelect,
} from '../warehouse/components';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'itemId',
label: '物料',
component: markRaw(MdItemSelect),
componentProps: {
placeholder: '请选择物料',
},
},
{
fieldName: 'batchCode',
label: '批次号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入批次号',
},
},
{
fieldName: 'warehouseId',
label: '仓库',
component: markRaw(WmWarehouseSelect),
componentProps: {
placeholder: '请选择仓库',
},
},
{
fieldName: 'locationId',
label: '库区',
component: markRaw(WmWarehouseLocationSelect),
dependencies: {
triggerFields: ['warehouseId'],
componentProps: (values) => ({
warehouseId: values.warehouseId,
placeholder: '请选择库区',
}),
},
},
{
fieldName: 'frozen',
label: '是否冻结',
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '是', value: true },
{ label: '否', value: false },
],
placeholder: '请选择',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onFrozenChange: (row: MesWmMaterialStockApi.MaterialStock) => void,
): VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>['columns'] {
return [
{
field: 'itemCode',
title: '产品物料编码',
minWidth: 120,
},
{
field: 'itemName',
title: '产品物料名称',
minWidth: 140,
},
{
field: 'specification',
title: '规格型号',
minWidth: 120,
},
{
field: 'quantity',
title: '在库数量',
width: 100,
},
{
field: 'unitMeasureName',
title: '单位',
width: 80,
},
{
field: 'batchCode',
title: '批次号',
minWidth: 140,
slots: { default: 'batchCode' },
},
{
field: 'warehouseName',
title: '仓库',
minWidth: 100,
},
{
field: 'locationName',
title: '库区',
minWidth: 100,
},
{
field: 'areaName',
title: '库位',
minWidth: 100,
slots: { default: 'areaName' },
},
{
field: 'receiptTime',
title: '入库日期',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'frozen',
title: '冻结',
width: 90,
cellRender: {
name: 'CellSwitch',
attrs: { beforeChange: onFrozenChange },
props: { checkedValue: true, unCheckedValue: false },
},
},
];
}
/** 选择弹窗的搜索表单 */
export function useSelectGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'itemId',
label: '物料',
component: markRaw(MdItemSelect),
componentProps: {
placeholder: '请选择物料',
},
},
{
fieldName: 'vendorId',
label: '供应商',
component: markRaw(MdVendorSelect),
componentProps: {
placeholder: '请选择供应商',
},
},
{
fieldName: 'batchCode',
label: '批次号',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入批次号',
},
},
{
fieldName: 'warehouseId',
label: '仓库',
component: markRaw(WmWarehouseSelect),
componentProps: {
placeholder: '请选择仓库',
},
},
{
fieldName: 'locationId',
label: '库区',
component: markRaw(WmWarehouseLocationSelect),
dependencies: {
triggerFields: ['warehouseId'],
componentProps: (values) => ({
warehouseId: values.warehouseId,
placeholder: '请选择库区',
}),
},
},
{
fieldName: 'areaId',
label: '库位',
component: markRaw(WmWarehouseAreaSelect),
dependencies: {
triggerFields: ['locationId'],
componentProps: (values) => ({
locationId: values.locationId,
placeholder: '请选择库位',
}),
},
},
];
}
/** 选择弹窗的字段 */
export function useSelectGridColumns(): VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>['columns'] {
return [
{
type: 'checkbox',
width: 50,
},
{
field: 'itemCode',
title: '产品物料编码',
minWidth: 120,
},
{
field: 'itemName',
title: '产品物料名称',
minWidth: 140,
},
{
field: 'specification',
title: '规格型号',
minWidth: 120,
},
{
field: 'unitMeasureName',
title: '单位',
width: 80,
},
{
field: 'batchCode',
title: '入库批次号',
minWidth: 120,
},
{
field: 'warehouseName',
title: '仓库',
minWidth: 100,
},
{
field: 'locationName',
title: '库区',
minWidth: 100,
},
{
field: 'areaName',
title: '库位',
minWidth: 100,
},
{
field: 'quantity',
title: '在库数量',
width: 100,
},
{
field: 'receiptTime',
title: '入库日期',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'frozen',
title: '冻结',
width: 80,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
];
}

View File

@ -0,0 +1,156 @@
<script lang="ts" setup>
// TODO @AI system user index.vue
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportMaterialStock,
getMaterialStockPage,
updateMaterialStockFrozen,
} from '#/api/mes/wm/materialstock';
import { $t } from '#/locales';
import MdItemTypeTree from '#/views/mes/md/item/type/components/md-item-type-tree.vue';
import AreaForm from '#/views/mes/wm/warehouse/area/modules/form.vue';
import { useGridColumns, useGridFormSchema } from './data';
const [AreaModal, areaModalApi] = useVbenModal({
connectedComponent: AreaForm,
destroyOnClose: true,
});
/** 处理冻结状态切换 */
async function handleFrozenChange(row: MesWmMaterialStockApi.MaterialStock) {
const text = row.frozen ? '冻结' : '解冻';
try {
await confirm(`确认要"${text}"该库存记录吗?`);
} catch {
return false;
}
await updateMaterialStockFrozen({
id: row.id!,
frozen: row.frozen!,
});
message.success(`${text}成功`);
return true;
}
/** 已选物料分类 */
const searchItemTypeId = ref<number>();
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(handleFrozenChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMaterialStockPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
itemTypeId: searchItemTypeId.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>,
});
/** 物料分类树节点点击 */
function handleTypeNodeClick(row: undefined | { id?: number }) {
searchItemTypeId.value = row?.id;
gridApi.query();
}
/** 打开库位详情弹窗 */
function handleOpenAreaDetail(row: MesWmMaterialStockApi.MaterialStock) {
if (!row.areaId) {
return;
}
areaModalApi.setData({ formType: 'detail', id: row.areaId }).open();
}
/** 导出表格 */
async function handleExport() {
const data = await exportMaterialStock({
...(await gridApi.formApi.getValues()),
itemTypeId: searchItemTypeId.value,
});
downloadFileFromBlobPart({ fileName: '库存台账.xls', source: data });
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【仓库】批次管理、库存现有量、库存事务"
url="https://doc.iocoder.cn/mes/wm/stock/"
/>
</template>
<AreaModal />
<div class="flex h-full gap-3">
<div class="bg-card w-1/6 rounded p-3">
<MdItemTypeTree @node-click="handleTypeNodeClick" />
</div>
<div class="w-5/6">
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['mes:wm-material-stock:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #batchCode="{ row }">
<span v-if="row.batchId" :title="row.batchCode">
{{ row.batchCode }}
</span>
<span v-else>-</span>
</template>
<template #areaName="{ row }">
<Button
v-if="row.areaId"
:title="row.areaName"
size="small"
type="link"
@click="handleOpenAreaDetail(row)"
>
{{ row.areaName }}
</Button>
<span v-else>-</span>
</template>
</Grid>
</div>
</div>
</Page>
</template>

View File

@ -0,0 +1,2 @@
export { default as WmMaterialStockSelectDialog } from './wm-material-stock-select-dialog.vue';
export { default as WmMaterialStockSelect } from './wm-material-stock-select.vue';

View File

@ -0,0 +1,279 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { computed, nextTick, ref } from 'vue';
import { ElAlert, ElButton, ElDialog, ElMessage } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getMaterialStockPage } from '#/api/mes/wm/materialstock';
import MdItemTypeTree from '#/views/mes/md/item/type/components/md-item-type-tree.vue';
import { useSelectGridColumns, useSelectGridFormSchema } from '../data';
/** 虚拟仓过滤模式:'exclude' 排除虚拟仓(默认),'only' 只看虚拟仓,'all' 不过滤 */
type VirtualFilter = 'all' | 'exclude' | 'only';
const props = withDefaults(
defineProps<{
batchId?: number;
itemId?: number;
virtualFilter?: VirtualFilter;
warehouseId?: number;
}>(),
{
batchId: undefined,
itemId: undefined,
virtualFilter: 'exclude',
warehouseId: undefined,
},
);
const emit = defineEmits<{
selected: [rows: MesWmMaterialStockApi.MaterialStock[]];
}>();
const open = ref(false);
const multiple = ref(true);
const syncingSingleSelection = ref(false);
const selectedRows = ref<MesWmMaterialStockApi.MaterialStock[]>([]);
const preSelectedIds = ref<number[]>([]);
const searchItemTypeId = ref<number>();
/** 当前 props 是否带预过滤 */
const showAlert = computed(
() =>
props.batchId != null ||
props.warehouseId != null ||
props.virtualFilter === 'only',
);
/** 预过滤提示文字 */
const alertTitle = computed(() => {
const parts: string[] = [];
if (props.batchId != null) {
parts.push('批次');
}
if (props.warehouseId != null) {
parts.push('仓库');
}
if (props.virtualFilter === 'only') {
parts.push('只看虚拟仓');
}
return `已按${parts.join('/')}预过滤`;
});
/** 单选模式同步 VXE 勾选状态 */
async function syncSingleSelection(row?: MesWmMaterialStockApi.MaterialStock) {
syncingSingleSelection.value = true;
await nextTick();
await gridApi.grid.clearCheckboxRow();
if (row) {
await gridApi.grid.setCheckboxRow(row, true);
}
await nextTick();
syncingSingleSelection.value = false;
}
/** 处理勾选变化 */
async function handleCheckboxChange({
checked,
records,
row,
}: {
checked: boolean;
records: MesWmMaterialStockApi.MaterialStock[];
row?: MesWmMaterialStockApi.MaterialStock;
}) {
if (syncingSingleSelection.value) {
return;
}
if (!multiple.value) {
const selected = checked && row ? [row] : [];
selectedRows.value = selected;
await syncSingleSelection(selected[0]);
return;
}
selectedRows.value = records;
}
/** 处理全选变化 */
function handleCheckboxAll({
records,
}: {
records: MesWmMaterialStockApi.MaterialStock[];
}) {
if (syncingSingleSelection.value) {
return;
}
selectedRows.value = records;
}
/** 双击行:单选直接确认;多选切换勾选 */
async function handleRowDblclick({
row,
}: {
row: MesWmMaterialStockApi.MaterialStock;
}) {
if (multiple.value) {
const checked = !gridApi.grid.isCheckedByCheckboxRow(row);
await gridApi.grid.setCheckboxRow(row, checked);
handleCheckboxChange({
checked,
records:
gridApi.grid.getCheckboxRecords() as MesWmMaterialStockApi.MaterialStock[],
row,
});
return;
}
selectedRows.value = [row];
await syncSingleSelection(row);
handleConfirm();
}
/** 回显预选 */
function applyPreSelection() {
if (preSelectedIds.value.length === 0) {
return;
}
const rows = gridApi.grid.getData() as MesWmMaterialStockApi.MaterialStock[];
for (const row of rows) {
if (row.id && preSelectedIds.value.includes(row.id)) {
gridApi.grid.setCheckboxRow(row, true);
if (!multiple.value) {
selectedRows.value = [row];
}
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useSelectGridFormSchema(),
},
gridOptions: {
columns: useSelectGridColumns(),
height: 480,
keepSource: true,
checkboxConfig: {
highlight: true,
range: true,
reserve: true,
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMaterialStockPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
batchId: props.batchId,
//
frozen: false,
itemTypeId: searchItemTypeId.value,
itemId: formValues.itemId ?? props.itemId,
warehouseId: formValues.warehouseId ?? props.warehouseId,
virtualFilter:
props.virtualFilter === 'all' ? undefined : props.virtualFilter,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>,
gridEvents: {
cellDblclick: handleRowDblclick,
checkboxAll: handleCheckboxAll,
checkboxChange: handleCheckboxChange,
},
});
/** 物料分类树节点点击 */
function handleTypeNodeClick(row: undefined | { id?: number }) {
searchItemTypeId.value = row?.id;
gridApi.query();
}
/** 重置查询和选择状态 */
async function resetQueryState() {
selectedRows.value = [];
searchItemTypeId.value = undefined;
await gridApi.grid.clearCheckboxRow();
await gridApi.formApi.resetForm();
}
/** 打开弹窗 */
async function openModal(
selectedIds?: number[],
options?: { multiple?: boolean },
) {
open.value = true;
multiple.value = options?.multiple ?? true;
preSelectedIds.value = selectedIds || [];
await nextTick();
await resetQueryState();
await gridApi.query();
await nextTick();
applyPreSelection();
}
/** 关闭弹窗 */
async function closeModal() {
open.value = false;
await resetQueryState();
}
/** 确认选择 */
function handleConfirm() {
if (selectedRows.value.length === 0) {
ElMessage.warning(multiple.value ? '请至少选择一条数据' : '请选择一条数据');
return;
}
emit(
'selected',
multiple.value ? selectedRows.value : [selectedRows.value[0]!],
);
open.value = false;
}
defineExpose({ open: openModal });
</script>
<template>
<ElDialog
v-model="open"
destroy-on-close
title="库存物资选择"
width="80%"
@close="closeModal"
>
<ElAlert
v-if="showAlert"
class="mb-3"
:closable="false"
show-icon
:title="alertTitle"
type="info"
/>
<div class="flex gap-3">
<div class="bg-card w-1/6 rounded p-2">
<MdItemTypeTree @node-click="handleTypeNodeClick" />
</div>
<div class="w-5/6">
<Grid table-title="" />
</div>
</div>
<template #footer>
<ElButton @click="closeModal"></ElButton>
<ElButton type="primary" @click="handleConfirm"></ElButton>
</template>
</ElDialog>
</template>

View File

@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { computed, ref, useAttrs, watch } from 'vue';
import { CircleX, Search } from '@vben/icons';
import { ElInput, ElTooltip } from 'element-plus';
import { getMaterialStock } from '#/api/mes/wm/materialstock';
import WmMaterialStockSelectDialog from './wm-material-stock-select-dialog.vue';
defineOptions({ name: 'WmMaterialStockSelect', inheritAttrs: false });
const props = withDefaults(
defineProps<{
batchId?: number;
clearable?: boolean;
disabled?: boolean;
itemId?: number;
modelValue?: number;
placeholder?: string;
virtualFilter?: 'all' | 'exclude' | 'only';
warehouseId?: number;
}>(),
{
batchId: undefined,
clearable: true,
disabled: false,
itemId: undefined,
modelValue: undefined,
placeholder: '请选择库存',
virtualFilter: 'exclude',
warehouseId: undefined,
},
);
const emit = defineEmits<{
change: [item: MesWmMaterialStockApi.MaterialStock | undefined];
'update:modelValue': [value: number | undefined];
}>();
const attrs = useAttrs();
const dialogRef = ref<InstanceType<typeof WmMaterialStockSelectDialog>>();
const hovering = ref(false);
const selectedItem = ref<MesWmMaterialStockApi.MaterialStock>();
const displayLabel = computed(() => {
const item = selectedItem.value;
if (!item) {
return '';
}
return `${item.warehouseName || '-'} / ${item.batchCode || '-'} / 数量:${item.quantity}`;
});
const showClear = computed(
() =>
props.clearable &&
!props.disabled &&
hovering.value &&
props.modelValue != null,
);
/** 根据 ID 单条查询库存信息(用于编辑回显) */
async function resolveItemById(id: number | undefined) {
if (id == null) {
selectedItem.value = undefined;
return;
}
if (selectedItem.value?.id === id) {
return;
}
try {
selectedItem.value = await getMaterialStock(id);
} catch (error) {
console.error('[WmMaterialStockSelect] resolveItemById failed:', error);
}
}
watch(() => props.modelValue, resolveItemById, { immediate: true });
/** 清空已选库存 */
function clearSelected() {
selectedItem.value = undefined;
emit('update:modelValue', undefined);
emit('change', undefined);
}
/** 打开库存选择弹窗 */
function handleClick(event: MouseEvent) {
if (props.disabled) {
return;
}
const target = event.target as HTMLElement;
if (showClear.value && target.closest('.el-input__suffix')) {
event.stopPropagation();
clearSelected();
return;
}
const selectedIds = props.modelValue == null ? [] : [props.modelValue];
dialogRef.value?.open(selectedIds, { multiple: false });
}
/** 弹窗选中回调 */
function handleSelected(rows: MesWmMaterialStockApi.MaterialStock[]) {
const item = rows[0];
if (!item) {
return;
}
selectedItem.value = item;
emit('update:modelValue', item.id);
emit('change', item);
}
</script>
<template>
<div
v-bind="attrs"
class="w-full"
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
@click="handleClick"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<ElTooltip :disabled="!selectedItem" placement="top" :show-after="500">
<template #content>
<div v-if="selectedItem" class="leading-6">
<div>物料{{ selectedItem.itemName || '-' }}</div>
<div>批次{{ selectedItem.batchCode || '-' }}</div>
<div>数量{{ selectedItem.quantity ?? '-' }}</div>
<div>仓库{{ selectedItem.warehouseName || '-' }}</div>
<div>库区{{ selectedItem.locationName || '-' }}</div>
<div>库位{{ selectedItem.areaName || '-' }}</div>
</div>
</template>
<ElInput
:disabled="disabled"
:model-value="displayLabel"
:placeholder="placeholder"
readonly
>
<template #suffix>
<CircleX v-if="showClear" class="size-4" />
<Search v-else class="size-4" />
</template>
</ElInput>
</ElTooltip>
</div>
<WmMaterialStockSelectDialog
ref="dialogRef"
:batch-id="batchId"
:item-id="itemId"
:virtual-filter="virtualFilter"
:warehouse-id="warehouseId"
@selected="handleSelected"
/>
</template>

View File

@ -0,0 +1,276 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { markRaw } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import MdItemSelect from '#/views/mes/md/item/components/md-item-select.vue';
import MdVendorSelect from '#/views/mes/md/vendor/components/md-vendor-select.vue';
import {
WmWarehouseAreaSelect,
WmWarehouseLocationSelect,
WmWarehouseSelect,
} from '../warehouse/components';
/** 列表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'itemId',
label: '物料',
component: markRaw(MdItemSelect),
componentProps: {
placeholder: '请选择物料',
},
},
{
fieldName: 'batchCode',
label: '批次号',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入批次号',
},
},
{
fieldName: 'warehouseId',
label: '仓库',
component: markRaw(WmWarehouseSelect),
componentProps: {
placeholder: '请选择仓库',
},
},
{
fieldName: 'locationId',
label: '库区',
component: markRaw(WmWarehouseLocationSelect),
dependencies: {
triggerFields: ['warehouseId'],
componentProps: (values) => ({
placeholder: '请选择库区',
warehouseId: values.warehouseId,
}),
},
},
{
fieldName: 'frozen',
label: '是否冻结',
component: 'Select',
componentProps: {
clearable: true,
options: [
{ label: '是', value: true },
{ label: '否', value: false },
],
placeholder: '请选择',
},
},
];
}
/** 列表的字段 */
export function useGridColumns(
onFrozenChange: (row: MesWmMaterialStockApi.MaterialStock) => void,
): VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>['columns'] {
return [
{
field: 'itemCode',
title: '产品物料编码',
minWidth: 120,
},
{
field: 'itemName',
title: '产品物料名称',
minWidth: 140,
},
{
field: 'specification',
title: '规格型号',
minWidth: 120,
},
{
field: 'quantity',
title: '在库数量',
width: 100,
},
{
field: 'unitMeasureName',
title: '单位',
width: 80,
},
{
field: 'batchCode',
title: '批次号',
minWidth: 140,
slots: { default: 'batchCode' },
},
{
field: 'warehouseName',
title: '仓库',
minWidth: 100,
},
{
field: 'locationName',
title: '库区',
minWidth: 100,
},
{
field: 'areaName',
title: '库位',
minWidth: 100,
slots: { default: 'areaName' },
},
{
field: 'receiptTime',
title: '入库日期',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'frozen',
title: '冻结',
width: 90,
cellRender: {
name: 'CellSwitch',
attrs: { beforeChange: onFrozenChange },
props: { activeValue: true, inactiveValue: false },
},
},
];
}
/** 选择弹窗的搜索表单 */
export function useSelectGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'itemId',
label: '物料',
component: markRaw(MdItemSelect),
componentProps: {
placeholder: '请选择物料',
},
},
{
fieldName: 'vendorId',
label: '供应商',
component: markRaw(MdVendorSelect),
componentProps: {
placeholder: '请选择供应商',
},
},
{
fieldName: 'batchCode',
label: '批次号',
component: 'Input',
componentProps: {
clearable: true,
placeholder: '请输入批次号',
},
},
{
fieldName: 'warehouseId',
label: '仓库',
component: markRaw(WmWarehouseSelect),
componentProps: {
placeholder: '请选择仓库',
},
},
{
fieldName: 'locationId',
label: '库区',
component: markRaw(WmWarehouseLocationSelect),
dependencies: {
triggerFields: ['warehouseId'],
componentProps: (values) => ({
placeholder: '请选择库区',
warehouseId: values.warehouseId,
}),
},
},
{
fieldName: 'areaId',
label: '库位',
component: markRaw(WmWarehouseAreaSelect),
dependencies: {
triggerFields: ['locationId'],
componentProps: (values) => ({
locationId: values.locationId,
placeholder: '请选择库位',
}),
},
},
];
}
/** 选择弹窗的字段 */
export function useSelectGridColumns(): VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>['columns'] {
return [
{
type: 'checkbox',
width: 50,
},
{
field: 'itemCode',
title: '产品物料编码',
minWidth: 120,
},
{
field: 'itemName',
title: '产品物料名称',
minWidth: 140,
},
{
field: 'specification',
title: '规格型号',
minWidth: 120,
},
{
field: 'unitMeasureName',
title: '单位',
width: 80,
},
{
field: 'batchCode',
title: '入库批次号',
minWidth: 120,
},
{
field: 'warehouseName',
title: '仓库',
minWidth: 100,
},
{
field: 'locationName',
title: '库区',
minWidth: 100,
},
{
field: 'areaName',
title: '库位',
minWidth: 100,
},
{
field: 'quantity',
title: '在库数量',
width: 100,
},
{
field: 'receiptTime',
title: '入库日期',
width: 180,
formatter: 'formatDateTime',
},
{
field: 'frozen',
title: '冻结',
width: 80,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
},
},
];
}

View File

@ -0,0 +1,156 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesWmMaterialStockApi } from '#/api/mes/wm/materialstock';
import { ref } from 'vue';
import { confirm, DocAlert, Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { ElButton, ElMessage } from 'element-plus';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
exportMaterialStock,
getMaterialStockPage,
updateMaterialStockFrozen,
} from '#/api/mes/wm/materialstock';
import { $t } from '#/locales';
import MdItemTypeTree from '#/views/mes/md/item/type/components/md-item-type-tree.vue';
import AreaForm from '#/views/mes/wm/warehouse/area/modules/form.vue';
import { useGridColumns, useGridFormSchema } from './data';
const [AreaModal, areaModalApi] = useVbenModal({
connectedComponent: AreaForm,
destroyOnClose: true,
});
/** 处理冻结状态切换 */
async function handleFrozenChange(row: MesWmMaterialStockApi.MaterialStock) {
const text = row.frozen ? '冻结' : '解冻';
try {
await confirm(`确认要"${text}"该库存记录吗?`);
} catch {
return false;
}
await updateMaterialStockFrozen({
id: row.id!,
frozen: row.frozen!,
});
ElMessage.success(`${text}成功`);
return true;
}
/** 已选物料分类 */
const searchItemTypeId = ref<number>();
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(handleFrozenChange),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMaterialStockPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
itemTypeId: searchItemTypeId.value,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<MesWmMaterialStockApi.MaterialStock>,
});
/** 物料分类树节点点击 */
function handleTypeNodeClick(row: undefined | { id?: number }) {
searchItemTypeId.value = row?.id;
gridApi.query();
}
/** 打开库位详情弹窗 */
function handleOpenAreaDetail(row: MesWmMaterialStockApi.MaterialStock) {
if (!row.areaId) {
return;
}
areaModalApi.setData({ formType: 'detail', id: row.areaId }).open();
}
/** 导出表格 */
async function handleExport() {
const data = await exportMaterialStock({
...(await gridApi.formApi.getValues()),
itemTypeId: searchItemTypeId.value,
});
downloadFileFromBlobPart({ fileName: '库存台账.xls', source: data });
}
</script>
<template>
<Page auto-content-height>
<template #doc>
<DocAlert
title="【仓库】批次管理、库存现有量、库存事务"
url="https://doc.iocoder.cn/mes/wm/stock/"
/>
</template>
<AreaModal />
<div class="flex h-full gap-3">
<div class="bg-card w-1/6 rounded p-3">
<MdItemTypeTree @node-click="handleTypeNodeClick" />
</div>
<div class="w-5/6">
<Grid table-title="">
<template #toolbar-tools>
<TableAction
:actions="[
{
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['mes:wm-material-stock:export'],
onClick: handleExport,
},
]"
/>
</template>
<template #batchCode="{ row }">
<span v-if="row.batchId" :title="row.batchCode">
{{ row.batchCode }}
</span>
<span v-else>-</span>
</template>
<template #areaName="{ row }">
<ElButton
v-if="row.areaId"
link
size="small"
:title="row.areaName"
type="primary"
@click="handleOpenAreaDetail(row)"
>
{{ row.areaName }}
</ElButton>
<span v-else>-</span>
</template>
</Grid>
</div>
</div>
</Page>
</template>