<script lang="ts" setup>
|
import type { MesMdItemApi } from '#/api/mes/md/item';
|
import type { MesMdProductBomApi } from '#/api/mes/md/item/productBom';
|
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
|
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
|
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
|
|
import { computed, ref, watch } from 'vue';
|
|
import { useVbenModal } from '@vben/common-ui';
|
import { DICT_TYPE } from '@vben/constants';
|
import { getDictLabel } from '@vben/hooks';
|
|
import {
|
Button,
|
Card,
|
Empty,
|
Input,
|
InputNumber,
|
message,
|
Popconfirm,
|
Table,
|
} from 'ant-design-vue';
|
|
import {
|
deleteRouteProcess,
|
getRouteProcessListByRoute,
|
updateRouteProcess,
|
} from '#/api/mes/pro/route/process';
|
import {
|
createRouteProductBom,
|
deleteRouteProductBom,
|
getRouteProductBomList,
|
updateRouteProductBom,
|
} from '#/api/mes/pro/route/productbom';
|
import { $t } from '#/locales';
|
import { MdItemSelect, MdProductBomSelect } from '#/views/mes/md/item/components';
|
|
import type { FormType } from '../data';
|
import ProcessForm from './process-form.vue';
|
|
defineOptions({ name: 'MesRouteWorkbench' });
|
|
const props = withDefaults(
|
defineProps<{
|
routeId: number;
|
formType: FormType;
|
product?: MesProRouteProductApi.RouteProduct;
|
}>(),
|
{ product: undefined },
|
);
|
|
const editable = computed(() => props.formType !== 'detail');
|
|
const processes = ref<MesProRouteProcessApi.RouteProcess[]>([]); // 工序列表
|
const selectedProcessId = ref<number>(); // 当前选中工序
|
const outputItemIds = ref<number[]>([]); // 选中工序的产出半成品
|
const bomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]); // 选中工序在当前产品下的投入物料
|
const adding = ref(false); // 是否显示新增投入物料行
|
|
const selectedProcess = computed(() =>
|
processes.value.find((item) => item.processId === selectedProcessId.value),
|
);
|
const productItemId = computed(() => props.product?.itemId);
|
const linkTypeLabel = computed(() =>
|
selectedProcess.value?.linkType != null
|
? getDictLabel(DICT_TYPE.MES_PRO_LINK_TYPE, selectedProcess.value.linkType)
|
: '-',
|
);
|
|
const [ProcessFormModal, processFormModalApi] = useVbenModal({
|
connectedComponent: ProcessForm,
|
destroyOnClose: true,
|
});
|
|
/** 加载工序列表(保持选中,失效时回退第一个) */
|
async function loadProcesses() {
|
const list = (await getRouteProcessListByRoute(props.routeId)) || [];
|
processes.value = list;
|
if (list.length === 0) {
|
selectedProcessId.value = undefined;
|
return;
|
}
|
if (!list.some((item) => item.processId === selectedProcessId.value)) {
|
selectedProcessId.value = list[0]!.processId;
|
}
|
}
|
|
/** 根据选中工序同步待编辑的产出半成品 */
|
function syncOutputItemIds() {
|
outputItemIds.value = selectedProcess.value?.outputItemIds ?? [];
|
}
|
|
/** 加载选中工序在当前产品下的投入物料 */
|
async function loadBomList() {
|
const processId = selectedProcess.value?.processId;
|
if (!processId || !productItemId.value) {
|
bomList.value = [];
|
return;
|
}
|
bomList.value =
|
(await getRouteProductBomList({
|
routeId: props.routeId,
|
processId,
|
productId: productItemId.value,
|
})) || [];
|
// 清理已不在当前投入清单中的行内备注草稿,避免切换后残留旧输入
|
const draftIds = new Set(
|
bomList.value
|
.map((item) => item.id)
|
.filter((id): id is number => id != null),
|
);
|
Object.keys(remarkEditValues.value).forEach((key) => {
|
if (!draftIds.has(Number(key))) {
|
delete remarkEditValues.value[Number(key)];
|
}
|
});
|
}
|
|
watch(
|
() => props.routeId,
|
() => loadProcesses(),
|
{ immediate: true },
|
);
|
|
watch(
|
() => selectedProcessId.value,
|
() => {
|
syncOutputItemIds();
|
loadBomList();
|
},
|
);
|
|
watch(
|
() => productItemId.value,
|
() => {
|
outputItemIds.value = [];
|
loadBomList();
|
},
|
);
|
|
/** 新增工序 */
|
function openProcessCreate() {
|
const maxSort = processes.value.length
|
? Math.max(...processes.value.map((item) => item.sort || 0))
|
: 0;
|
processFormModalApi
|
.setData({ maxSort, routeId: props.routeId, formType: props.formType })
|
.open();
|
}
|
|
/** 编辑工序信息 */
|
function openProcessEdit(row: MesProRouteProcessApi.RouteProcess) {
|
processFormModalApi
|
.setData({ id: row.id, routeId: props.routeId, formType: props.formType })
|
.open();
|
}
|
|
/** 删除工序 */
|
async function handleProcessDelete(row: MesProRouteProcessApi.RouteProcess) {
|
await deleteRouteProcess(row.id!);
|
message.success($t('ui.actionMessage.operationSuccess'));
|
await loadProcesses();
|
}
|
|
/** 产出半成品:选择变化即保存 */
|
async function handleOutputChange(
|
items: MesMdItemApi.Item | MesMdItemApi.Item[] | undefined,
|
) {
|
if (!selectedProcess.value) {
|
return;
|
}
|
const ids = (
|
Array.isArray(items) ? items : items ? [items] : []
|
)
|
.map((item) => item.id)
|
.filter((id): id is number => id != null);
|
outputItemIds.value = ids;
|
try {
|
await updateRouteProcess({
|
id: selectedProcess.value.id,
|
routeId: selectedProcess.value.routeId,
|
outputItemIds: ids,
|
});
|
message.success($t('ui.actionMessage.operationSuccess'));
|
} catch {
|
// 保存失败:重载工序列表回退本地值
|
await loadProcesses();
|
syncOutputItemIds();
|
}
|
}
|
|
/** 用量修改:变化即保存(携带完整关联字段,通过后端必填校验) */
|
async function handleQuantityChange(
|
record: MesProRouteProductBomApi.RouteProductBom,
|
value: number | string | null,
|
) {
|
if (value == null || record.id == null) {
|
return;
|
}
|
const quantity = Number(value);
|
if (Number.isNaN(quantity)) {
|
return;
|
}
|
record.quantity = quantity;
|
try {
|
await updateRouteProductBom(toUpdatePayload(record));
|
} catch {
|
await loadBomList();
|
}
|
}
|
|
const remarkEditValues = ref<Record<number, string>>({}); // 行内备注编辑的暂存值
|
|
/** 备注输入:暂存值,不立即提交 */
|
function handleRemarkInput(
|
record: MesProRouteProductBomApi.RouteProductBom,
|
value: string,
|
) {
|
if (record.id != null) {
|
remarkEditValues.value[record.id] = value;
|
}
|
}
|
|
/** 读取行内备注草稿,无编辑时回退数据库值 */
|
function getRemarkDraft(record: MesProRouteProductBomApi.RouteProductBom) {
|
const draft =
|
record.id != null ? remarkEditValues.value[record.id] : undefined;
|
return draft ?? record.remark ?? '';
|
}
|
|
/** 备注修改:失焦/回车即保存 */
|
async function handleRemarkChange(
|
record: MesProRouteProductBomApi.RouteProductBom,
|
value: string,
|
) {
|
if (record.id == null) {
|
return;
|
}
|
const remark = typeof value === 'string' ? value.trim() : '';
|
// 同步暂存值,保证回车后再触发的 blur 读到的是提交后的值,不会用旧值回写
|
remarkEditValues.value[record.id] = remark;
|
if (record.remark === remark) {
|
return;
|
}
|
record.remark = remark;
|
try {
|
await updateRouteProductBom(toUpdatePayload(record));
|
} catch {
|
await loadBomList();
|
}
|
}
|
|
/** 行内编辑提交:仅携带后端校验所需的关联字段 */
|
function toUpdatePayload(record: MesProRouteProductBomApi.RouteProductBom) {
|
return {
|
id: record.id,
|
routeId: record.routeId,
|
processId: record.processId,
|
productId: record.productId,
|
itemId: record.itemId,
|
quantity: record.quantity,
|
remark: record.remark,
|
};
|
}
|
|
/** 选中 BOM 后批量入库(支持多选,已存在的物料自动跳过) */
|
async function handleBomSelected(
|
boms?: MesMdProductBomApi.ProductBom | MesMdProductBomApi.ProductBom[],
|
) {
|
if (!selectedProcess.value || !productItemId.value) {
|
return;
|
}
|
const selectedList = Array.isArray(boms) ? boms : boms ? [boms] : [];
|
if (selectedList.length === 0) {
|
return;
|
}
|
// 跳过已在投入清单中的物料,避免重复入库
|
const existingIds = new Set(
|
bomList.value
|
.map((item) => item.itemId)
|
.filter((id): id is number => id != null),
|
);
|
let created = 0;
|
for (const bom of selectedList) {
|
if (!bom.bomItemId || existingIds.has(bom.bomItemId)) {
|
continue;
|
}
|
await createRouteProductBom({
|
routeId: props.routeId,
|
processId: selectedProcess.value.processId,
|
productId: productItemId.value,
|
itemId: bom.bomItemId,
|
quantity: bom.quantity ?? 1,
|
});
|
created++;
|
}
|
if (created > 0) {
|
message.success($t('ui.actionMessage.operationSuccess'));
|
}
|
adding.value = false;
|
await loadBomList();
|
}
|
|
/** 删除投入物料 */
|
async function handleBomDelete(record: MesProRouteProductBomApi.RouteProductBom) {
|
await deleteRouteProductBom(record.id!);
|
message.success($t('ui.actionMessage.operationSuccess'));
|
await loadBomList();
|
}
|
|
/** 投入物料表格列 */
|
const columns = [
|
{ title: '物料', key: 'item' as const, width: 220 },
|
{ title: '用量', key: 'quantity' as const, width: 110 },
|
{ title: '单位', key: 'unit' as const, dataIndex: 'unitName', width: 70 },
|
{ title: '备注', key: 'remark' as const, dataIndex: 'remark', minWidth: 160 },
|
{ title: '操作', key: 'action' as const, width: 70 },
|
];
|
</script>
|
|
<template>
|
<ProcessFormModal @success="loadProcesses" />
|
<div class="flex gap-4">
|
<!-- 左侧:工序列表 -->
|
<div
|
class="w-72 shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-white"
|
>
|
<div
|
class="flex h-10 items-center justify-between border-b border-gray-100 bg-gray-50 px-3"
|
>
|
<span class="text-sm font-medium">工序列表</span>
|
<Button
|
v-if="editable"
|
size="small"
|
type="link"
|
@click="openProcessCreate"
|
>
|
新增工序
|
</Button>
|
</div>
|
<ul class="max-h-[560px] overflow-auto">
|
<li
|
v-for="item in processes"
|
:key="item.processId"
|
class="group flex cursor-pointer items-center gap-2 px-3 py-2.5 text-sm transition-colors"
|
:class="
|
selectedProcess?.processId === item.processId
|
? 'bg-blue-50 text-blue-600'
|
: 'hover:bg-gray-50'
|
"
|
@click="selectedProcessId = item.processId"
|
>
|
<span class="w-6 shrink-0 text-right text-xs text-gray-400">
|
{{ item.sort }}
|
</span>
|
<span
|
class="h-3 w-3 shrink-0 rounded-full border border-gray-200"
|
:style="{ backgroundColor: item.colorCode }"
|
></span>
|
<span class="min-w-0 flex-1 truncate">{{ item.processName }}</span>
|
<span v-if="item.keyFlag" class="shrink-0 text-amber-500" title="关键工序">★</span>
|
<span v-if="item.checkFlag" class="shrink-0 text-green-600" title="质检确认">◎</span>
|
<span v-if="item.backflushFlag" class="shrink-0 text-cyan-600" title="倒冲">↻</span>
|
<Popconfirm
|
v-if="editable"
|
title="确认删除该工序?"
|
ok-text="确定"
|
cancel-text="取消"
|
@confirm="handleProcessDelete(item)"
|
@click.stop
|
>
|
<Button
|
class="opacity-0 transition-opacity group-hover:opacity-100"
|
size="small"
|
type="text"
|
danger
|
>
|
删
|
</Button>
|
</Popconfirm>
|
</li>
|
<li v-if="processes.length === 0" class="px-3 py-10 text-center text-xs text-gray-400">
|
尚未配置工序,点击右上角「新增工序」开始
|
</li>
|
</ul>
|
</div>
|
|
<!-- 右侧:选中工序的配置面板 -->
|
<div class="min-w-0 flex-1 space-y-3">
|
<template v-if="selectedProcess">
|
<!-- 工序信息 -->
|
<Card :bordered="false" class="!border border-gray-200">
|
<div class="flex items-start justify-between gap-3">
|
<div class="min-w-0">
|
<div class="flex items-center gap-2">
|
<span class="text-base font-medium">
|
{{ selectedProcess.processName }}
|
</span>
|
<span class="text-xs text-gray-400">
|
{{ selectedProcess.processCode }}
|
</span>
|
<span v-if="selectedProcess.keyFlag" class="text-amber-500" title="关键工序">★</span>
|
<span v-if="selectedProcess.checkFlag" class="text-green-600" title="质检确认">◎</span>
|
<span v-if="selectedProcess.backflushFlag" class="text-cyan-600" title="倒冲">↻</span>
|
</div>
|
<div class="mt-1 flex flex-wrap gap-x-5 gap-y-1 text-sm text-gray-500">
|
<span>序号:{{ selectedProcess.sort }}</span>
|
<span>准备时间:{{ selectedProcess.prepareTime ?? 0 }} 分</span>
|
<span>等待时间:{{ selectedProcess.waitTime ?? 0 }} 分</span>
|
<span v-if="selectedProcess.linkType != null">
|
与下道工序:{{ linkTypeLabel }}
|
</span>
|
</div>
|
<div v-if="selectedProcess.remark" class="mt-1 text-xs text-gray-400">
|
备注:{{ selectedProcess.remark }}
|
</div>
|
</div>
|
<Button
|
v-if="editable"
|
type="link"
|
@click="openProcessEdit(selectedProcess)"
|
>
|
编辑信息
|
</Button>
|
</div>
|
</Card>
|
|
<!-- 产出 -->
|
<Card :bordered="false" class="!border border-gray-200">
|
<template #title>
|
<span class="text-sm font-medium">产出</span>
|
</template>
|
<MdItemSelect
|
:model-value="outputItemIds"
|
multiple
|
:disabled="!editable"
|
placeholder="留空则默认为工单成品"
|
@change="handleOutputChange"
|
/>
|
<p class="mt-1 text-xs text-gray-400">
|
留空表示该工序产出工单成品;选择多个表示一个工序产出多种半成品。
|
</p>
|
</Card>
|
|
<!-- 投入物料 -->
|
<Card :bordered="false" class="!border border-gray-200">
|
<template #title>
|
<span class="text-sm font-medium">
|
投入物料{{ productItemId ? `(${props.product?.itemName})` : '' }}
|
</span>
|
</template>
|
<Empty
|
v-if="!productItemId"
|
:image="Empty.PRESENTED_IMAGE_SIMPLE"
|
description="请先在顶部绑定生产产品"
|
/>
|
<template v-else>
|
<Table
|
:data-source="bomList"
|
:columns="columns"
|
:pagination="false"
|
size="small"
|
row-key="id"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.key === 'item'">
|
<div class="leading-4">
|
<div class="text-sm">{{ record.itemName }}</div>
|
<div class="text-xs text-gray-400">{{ record.itemCode }}</div>
|
</div>
|
</template>
|
<template v-else-if="column.key === 'quantity'">
|
<InputNumber
|
v-if="editable"
|
:value="record.quantity"
|
:min="0"
|
:precision="2"
|
size="small"
|
class="!w-24"
|
@change="(value) => handleQuantityChange(record, value)"
|
/>
|
<span v-else>{{ record.quantity }}</span>
|
</template>
|
<template v-else-if="column.key === 'remark'">
|
<span v-if="!editable">{{ record.remark }}</span>
|
<Input
|
v-else
|
:value="getRemarkDraft(record)"
|
:maxlength="255"
|
size="small"
|
placeholder="备注"
|
@update:value="(value: string) => handleRemarkInput(record, value)"
|
@blur="() => handleRemarkChange(record, getRemarkDraft(record))"
|
@pressEnter="() => handleRemarkChange(record, getRemarkDraft(record))"
|
/>
|
</template>
|
<template v-else-if="column.key === 'action'">
|
<Popconfirm
|
v-if="editable"
|
title="确认删除该投入物料?"
|
ok-text="确定"
|
cancel-text="取消"
|
@confirm="handleBomDelete(record)"
|
>
|
<Button type="link" size="small" danger> 删除 </Button>
|
</Popconfirm>
|
</template>
|
</template>
|
</Table>
|
<div class="mt-2">
|
<template v-if="editable && adding">
|
<div class="flex items-center gap-2">
|
<MdProductBomSelect
|
:item-id="productItemId"
|
multiple
|
placeholder="选择该产品的 BOM 物料(可多选)"
|
@change="handleBomSelected"
|
/>
|
<Button size="small" @click="adding = false">取消</Button>
|
</div>
|
</template>
|
<div v-else-if="editable" class="flex items-center gap-3">
|
<Button size="small" type="dashed" block @click="adding = true">
|
+ 新增投入物料
|
</Button>
|
<span v-if="bomList.length === 0" class="text-xs text-orange-500">
|
该工序尚未配置投入物料;标为「倒冲」的工序报工时会按此处用量自动扣料
|
</span>
|
</div>
|
</div>
|
</template>
|
</Card>
|
</template>
|
|
<!-- 无工序空态 -->
|
<div
|
v-else
|
class="flex h-56 items-center justify-center rounded-lg border border-dashed border-gray-300 text-sm text-gray-400"
|
>
|
请先在左侧新增并选中一道工序
|
</div>
|
</div>
|
</div>
|
</template>
|