云
2026-10-26 bae93b624d8d68ed2fe540c381a58b47082c1309
feat(mes): 完善工艺路线管理功能

- 新增工艺路线导入导出功能,支持批量导入完整工艺路线
- 实现工序产出物料归属配置,支持多产品通用和产品专属设置
- 添加报工仓库、库区、库位配置,优化库存管理流程
- 集成报工自动扣料功能,提升生产执行效率
- 更新表单验证规则,增强数据录入准确性
- 重构界面布局,采用标签页方式组织工序和产品配置
- 优化API接口设计,完善仓库相关数据查询功能
- 调整字段显示格式,统一日期时间显示规范
- 增加工序收率说明帮助信息,提升用户体验
- 实现出产物料与仓库信息的联动选择功能
已添加5个文件
已修改21个文件
1848 ■■■■■ 文件已修改
src/api/mes/pro/route/index.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/process/index.ts 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/product/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/process/index.ts 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/import-modal/import-modal.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/import-modal/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select-dialog.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/output-attribution.vue 135 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/data.ts 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/index.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/material-input-list.vue 145 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-form.vue 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-list.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-bom-list.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-form.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/product-list.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/route-configurator.vue 425 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-form.vue 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/modules/process-list.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/data.ts 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/index.vue 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/form.vue 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/process-form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/route/modules/product-form.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/index.ts
@@ -61,3 +61,26 @@
export function exportRoute(params: any) {
  return requestClient.download('/mes/pro/route/export-excel', { params });
}
/** ä¸‹è½½å·¥è‰ºè·¯çº¿å¯¼å…¥æ¨¡æ¿ */
export function importRouteTemplate() {
  return requestClient.download('/mes/pro/route/get-import-template');
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
export function importRoute(file: File, updateSupport: boolean) {
  return requestClient.upload(
    `/mes/pro/route/import?updateSupport=${updateSupport}`,
    { file },
  );
}
/** ä¸‹è½½åŒ…含路线、产品、工序、工序产出、工序投料五张工作表的模板 */
export function importCompleteRouteTemplate() {
  return requestClient.download('/mes/pro/route/get-complete-import-template');
}
/** æ‰¹é‡å¯¼å…¥å®Œæ•´å·¥è‰ºè·¯çº¿ */
export function importCompleteRoute(file: File) {
  return requestClient.upload('/mes/pro/route/import-complete', { file });
}
src/api/mes/pro/route/process/index.ts
@@ -10,13 +10,25 @@
    remark?: string;
  }
  /** äº§å‡ºç‰©æ–™å½’属 */
  export interface OutputItem {
    id?: number;
    routeProcessId?: number;
    productId?: number; // å½’属产品编号,为空表示多产品通用
    productName?: string;
    itemId?: number;
    itemCode?: string;
    itemName?: string;
    sort?: number;
    remark?: string;
  }
  /** MES å·¥è‰ºè·¯çº¿å·¥åº */
  export interface RouteProcess {
    id?: number;
    routeId?: number;
    processId?: number;
    paramTemplateId?: number;
    /** å·¥åºæ”¶çŽ‡ï¼ˆäº§å‡º/投入,0~1) */
    yieldRate?: number;
    processCode?: string;
    processName?: string;
@@ -35,6 +47,13 @@
    outputItemName?: string;
    outputItemIds?: number[];
    outputItemNames?: string;
    outputs?: OutputItem[]; // äº§å‡ºç‰©æ–™å½’属配置
    warehouseId?: number; // é»˜è®¤æŠ¥å·¥ä»“库编号
    warehouseName?: string; // é»˜è®¤æŠ¥å·¥ä»“库名称
    locationId?: number; // é»˜è®¤æŠ¥å·¥åº“区编号
    locationName?: string; // é»˜è®¤æŠ¥å·¥åº“区名称
    areaId?: number; // é»˜è®¤æŠ¥å·¥åº“位编号
    areaName?: string; // é»˜è®¤æŠ¥å·¥åº“位名称
    remark?: string;
    bomItems?: ProcessBomItem[];
    createTime?: Date;
src/api/mes/pro/route/product/index.ts
@@ -8,6 +8,12 @@
    itemId?: number;
    itemCode?: string;
    itemName?: string;
    warehouseId?: number;
    warehouseName?: string;
    locationId?: number;
    locationName?: string;
    areaId?: number;
    areaName?: string;
    specification?: string;
    unitName?: string;
    quantity?: number;
src/api/mes/pro/workorder/process/index.ts
@@ -1,5 +1,3 @@
import type { MesProWorkOrderProcessApi } from './index';
import { requestClient } from '#/api/request';
export namespace MesProWorkOrderProcessApi {
@@ -35,6 +33,12 @@
    outputItemName?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆç¬¬ä¸€ä¸ªï¼‰
    outputItemIds?: number[]; // äº§å‡ºäº§å“ç‰©æ–™ç¼–号列表
    outputItemNames?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆé€—号拼接)
    warehouseId?: number; // æŠ¥å·¥ä»“库编号
    warehouseName?: string; // æŠ¥å·¥ä»“库名称
    locationId?: number; // æŠ¥å·¥åº“区编号
    locationName?: string; // æŠ¥å·¥åº“区名称
    areaId?: number; // æŠ¥å·¥åº“位编号
    areaName?: string; // æŠ¥å·¥åº“位名称
    bomItems?: ProcessBomItem[]; // å·¥åºæŠ•æ–™BOM明细
    remark?: string; // å¤‡æ³¨
  }
@@ -79,4 +83,4 @@
/** åˆ é™¤å·¥å•工序 */
export function deleteWorkOrderProcess(id: number) {
  return requestClient.delete(`/mes/pro/work-order-process/delete?id=${id}`);
}
}
src/components/import-modal/import-modal.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { VbenFormSchema } from '#/adapter/form';
import { h, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Modal as AntModal, Upload } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { $t } from '#/locales';
export interface ImportModalProps {
  /** å¼¹çª—标题,如「导入用户」 */
  title: string;
  /** ä¸‹è½½çš„æ¨¡æ¿æ–‡ä»¶åï¼Œå¦‚「用户导入模板.xls」 */
  fileName: string;
  /** æ‰§è¡Œå¯¼å…¥çš„æŽ¥å£å‡½æ•° (file, updateSupport) => Promise */
  importFn: (file: File, updateSupport: boolean) => Promise<unknown>;
  /** ä¸‹è½½æ¨¡æ¿çš„æŽ¥å£å‡½æ•° () => Promise(blob) */
  templateFn: () => Promise<BlobPart>;
  /** å®Œæ•´è·¯çº¿åªæ”¯æŒæ–°å¢žï¼Œä¸å±•示覆盖开关 */
  showUpdateSupport?: boolean;
}
const emit = defineEmits(['success']);
const config = ref<ImportModalProps>({
  title: '',
  fileName: '',
  importFn: async () => {},
  templateFn: () => Promise.resolve(new Blob()),
});
function createSchema(showUpdateSupport = true): VbenFormSchema[] {
  return [
    {
      fieldName: 'file',
      label: '数据文件',
      component: 'Upload' as const,
      rules: 'required',
      help: '仅允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶',
    },
    ...(showUpdateSupport ? [{
      fieldName: 'updateSupport',
      label: '是否覆盖',
      component: 'Switch' as const,
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
      help: '是否更新已经存在的数据',
    }] : []),
  ];
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
    formItemClass: 'col-span-2',
    labelWidth: 120,
  },
  layout: 'horizontal',
  schema: createSchema(),
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (isOpen) {
      config.value = modalApi.getData<ImportModalProps>() ?? config.value;
      await formApi.updateSchema(createSchema(config.value.showUpdateSupport !== false));
      await formApi.resetForm();
    }
  },
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    // æäº¤è¡¨å•
    const data = await formApi.getValues();
    try {
      const result = await config.value.importFn(data.file, data.updateSupport ?? false) as {
        createCodes?: string[];
        updateCodes?: string[];
        failureCodes?: Record<string, string>;
      } | undefined;
      // å…³é—­å¹¶æç¤º
      await modalApi.close();
      emit('success');
      const failures = Object.entries(result?.failureCodes ?? {});
      if (failures.length > 0) {
        AntModal.warning({
          title: `导入完成:成功 ${(result?.createCodes?.length ?? 0) + (result?.updateCodes?.length ?? 0)} æ¡ï¼Œå¤±è´¥ ${failures.length} æ¡`,
          content: h('div', { style: { whiteSpace: 'pre-wrap', maxHeight: '50vh', overflowY: 'auto' } },
            failures.map(([code, reason]) => `${code}:${reason}`).join('\n')),
        });
      } else {
        message.success($t('ui.actionMessage.operationSuccess'));
      }
    } finally {
      modalApi.unlock();
    }
  },
});
/** ä¸Šä¼ å‰ */
function beforeUpload(file: FileType) {
  formApi.setFieldValue('file', file);
  return false;
}
/** ä¸‹è½½æ¨¡ç‰ˆ */
async function handleDownload() {
  const data = await config.value.templateFn();
  downloadFileFromBlobPart({ fileName: config.value.fileName, source: data });
}
</script>
<template>
  <Modal :title="config.title" class="w-1/3">
    <Form class="mx-4">
      <template #file>
        <div class="w-full">
          <Upload
            :max-count="1"
            accept=".xls,.xlsx"
            :before-upload="beforeUpload"
          >
            <Button type="primary"> é€‰æ‹© Excel æ–‡ä»¶ </Button>
          </Upload>
        </div>
      </template>
    </Form>
    <template #prepend-footer>
      <div class="flex flex-auto items-center">
        <Button @click="handleDownload"> ä¸‹è½½å¯¼å…¥æ¨¡æ¿ </Button>
      </div>
    </template>
  </Modal>
</template>
src/components/import-modal/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1 @@
export { default } from './import-modal.vue';
src/views/mes/md/item/components/select-dialog.vue
@@ -3,11 +3,11 @@
  import type { MesMdItemApi } from "#/api/mes/md/item";
  import type { MesMdItemTypeApi } from "#/api/mes/md/item/type";
  import { nextTick, ref } from "vue";
  import { computed, nextTick, ref } from "vue";
  import { CommonStatusEnum } from "@vben/constants";
  import { Card, message, Modal } from "ant-design-vue";
  import { Alert, Card, message, Modal, Tag } from "ant-design-vue";
  import { useVbenVxeGrid } from "#/adapter/vxe-table";
  import { getItemPage } from "#/api/mes/md/item";
@@ -27,6 +27,9 @@
  const selectedItemTypeId = ref<number>(); // å½“前筛选分类编号
  const preSelectedIds = ref<number[]>([]); // é¢„选物料编号列表
  const productIds = ref<number[]>([]); // é™å®šå¯é€‰çš„物料编号列表(为空则不限制)
  const limitedToProducts = computed(() => productIds.value.length > 0);
  const dialogTitle = computed(() => limitedToProducts.value ? "路线产出选择" : "物料产品选择");
  const tableTitle = computed(() => limitedToProducts.value ? "已配置的产出产品" : "物料产品列表");
  const typeTreeRef = ref<InstanceType<typeof MdItemTypeTree>>(); // ç‰©æ–™åˆ†ç±»æ ‘
  /** èŽ·å–å½“å‰è¡¨æ ¼æ•°æ® */
@@ -161,7 +164,7 @@
    },
    gridOptions: {
      columns: useItemSelectGridColumns(true),
      height: 560,
      height: '100%',
      keepSource: true,
      checkboxConfig: {
        highlight: true,
@@ -265,18 +268,29 @@
<template>
  <Modal v-model:open="open"
         title="物料产品选择"
         :title="dialogTitle"
         width="80%"
         :destroy-on-close="true"
         @ok="handleConfirm"
         @cancel="closeModal">
    <div class="flex h-full w-full">
      <Card class="mr-4 h-full w-1/5">
        <MdItemTypeTree ref="typeTreeRef"
                        @node-click="handleItemTypeNodeClick" />
      </Card>
      <div class="w-4/5">
        <Grid table-title="物料产品列表" />
    <div class="flex h-[calc(100vh-300px)] min-h-[380px] w-full flex-col">
      <Alert
        v-if="limitedToProducts"
        class="mb-3 shrink-0"
        message="仅可选择本产品在工艺路线中已配置的产出物料"
        description="如需增加可选项,请先在工艺路线对应工序中配置产出并保存。"
        show-icon
        type="info"
      >
        <template #action><Tag color="blue">{{ productIds.length }} ä¸ªäº§å‡º</Tag></template>
      </Alert>
      <div class="flex min-h-0 w-full flex-1 gap-4">
        <Card v-if="!limitedToProducts" class="h-full w-1/5 shrink-0" :body-style="{ height: '100%', overflow: 'hidden' }">
          <MdItemTypeTree ref="typeTreeRef" @node-click="handleItemTypeNodeClick" />
        </Card>
        <div class="h-full min-h-0 min-w-0 flex-1">
          <Grid class="h-full" :table-title="tableTitle" />
        </div>
      </div>
    </div>
  </Modal>
src/views/mes/pro/route/components/index.ts
@@ -1 +1,2 @@
export { default as RouteColorPicker } from './color-picker.vue';
export { default as RouteOutputAttribution } from './output-attribution.vue';
src/views/mes/pro/route/components/output-attribution.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,135 @@
<script lang="ts" setup>
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { computed } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Button, Select } from 'ant-design-vue';
defineOptions({ name: 'RouteOutputAttribution' });
/** å½’属选择中代表“多产品通用”的哨兵值(产品编号均为正数) */
const GENERAL_PRODUCT = 0;
const props = withDefaults(
  defineProps<{
    disabled?: boolean;
    modelValue?: MesProRouteProcessApi.OutputItem[];
    products?: MesProRouteProductApi.RouteProduct[];
  }>(),
  {
    disabled: false,
    modelValue: () => [],
    products: () => [],
  },
);
const emit = defineEmits<{
  'update:modelValue': [value: MesProRouteProcessApi.OutputItem[]];
}>();
const rows = computed(() => props.modelValue ?? []);
const productOptions = computed(() =>
  props.products
    .filter((product) => product.itemId != null)
    .map((product) => ({
      label: product.itemName ?? product.itemCode ?? String(product.itemId),
      value: product.itemId!,
    })),
);
const attributionOptions = computed(() => [
  { label: '通用(所有产品)', value: GENERAL_PRODUCT },
  ...productOptions.value,
]);
function commit(next: MesProRouteProcessApi.OutputItem[]) {
  emit('update:modelValue', next);
}
function updateRow(
  index: number,
  patch: Partial<MesProRouteProcessApi.OutputItem>,
) {
  commit(rows.value.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
/** é€‰æ‹©äº§å‡ºç‰©æ–™æ—¶ï¼Œé»˜è®¤å½’属到自身产品(半成品共用时再手动改成“通用”) */
function handleItemChange(index: number, itemId: number | undefined) {
  const current = rows.value[index];
  const patch: Partial<MesProRouteProcessApi.OutputItem> = { itemId };
  // åŽŸæ¥å½’å±žè‡ªèº«æ—¶ï¼Œåˆ‡æ¢äº§å‡ºç‰©æ–™ä¹Ÿè¦åŒæ­¥å½’å±žï¼Œé¿å…ç•™ä¸‹æ—§äº§å“ç¼–å·ã€‚
  if ((current?.productId == null || current.productId === current.itemId) && itemId != null) {
    patch.productId = itemId;
  }
  updateRow(index, patch);
}
function handleAttributionChange(index: number, value: number | undefined) {
  updateRow(index, {
    productId: value === GENERAL_PRODUCT || value == null ? undefined : value,
  });
}
function addRow() {
  commit([...rows.value, { itemId: undefined, productId: undefined }]);
}
function removeRow(index: number) {
  commit(rows.value.filter((_, i) => i !== index));
}
</script>
<template>
  <div class="w-full">
    <div class="flex flex-col gap-2">
      <div
        v-for="(row, index) in rows"
        :key="index"
        class="flex items-center gap-2"
      >
        <Select
          :disabled="disabled"
          :options="productOptions"
          :placeholder="'选择产出物料'"
          :value="row.itemId"
          class="min-w-0 flex-1"
          option-filter-prop="label"
          show-search
          @change="(value) => handleItemChange(index, value as number | undefined)"
        />
        <Select
          :disabled="disabled || row.itemId == null"
          :options="attributionOptions"
          :value="row.productId ?? GENERAL_PRODUCT"
          class="min-w-0 flex-1"
          @change="(value) => handleAttributionChange(index, value as number | undefined)"
        />
        <Button
          :aria-label="`删除第 ${index + 1} æ¡äº§å‡ºé…ç½®`"
          :disabled="disabled"
          danger
          size="small"
          type="text"
          @click="removeRow(index)"
        >
          <IconifyIcon class="size-4" icon="lucide:trash-2" />
        </Button>
      </div>
      <div v-if="rows.length === 0" class="text-sm text-gray-500">
        æš‚无产出;形成半成品或成品时添加
      </div>
    </div>
    <Button
      v-if="!disabled"
      class="mt-2"
      size="small"
      type="dashed"
      @click="addRow"
    >
      <IconifyIcon class="mr-1 size-4" icon="lucide:plus" />
      æ·»åŠ äº§å‡º
    </Button>
  </div>
</template>
src/views/mes/pro/route/data.ts
@@ -19,12 +19,15 @@
import { z } from '#/adapter/form';
import { generateAutoCode } from '#/api/mes/md/autocode/record';
import { getTemplatePage } from '#/api/mes/pro/processdesign/template';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
import {
  MdItemSelect,
  MdProductBomSelect,
} from '#/views/mes/md/item/components';
import { RouteColorPicker } from './components';
import { RouteColorPicker, RouteOutputAttribution } from './components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
@@ -167,7 +170,7 @@
      field: 'createTime',
      title: '创建时间',
      width: 180,
      formatter: 'formatDate',
      formatter: 'formatDateTime',
    },
    {
      title: '操作',
@@ -181,6 +184,7 @@
/** å·¥è‰ºè·¯çº¿å·¥åºæ˜Žç»†è¡¨å• */
export function useRouteProcessFormSchema(
  processOptions: Array<{ label: string; value: number }>,
  products: MesProRouteProductApi.RouteProduct[] = [],
): VbenFormSchema[] {
  return [
    {
@@ -231,36 +235,38 @@
    },
    {
      fieldName: 'yieldRate',
      label: '收率',
      label: '工序收率',
      component: 'InputNumber',
      help: '产出量 Ã· æŠ•入量;未填写按 100% å‚与排产计算。',
      componentProps: {
        class: '!w-full',
        min: 0.0001,
        max: 1,
        step: 0.01,
        precision: 4,
        placeholder: '产出/投入,如 0.98',
        placeholder: '如 0.98(98%)',
      },
      rules: z.number().min(0.0001).max(1).nullish(),
    },
    {
      fieldName: 'outputs',
      label: '本工序产出',
      component: markRaw(RouteOutputAttribution),
      componentProps: {
        products,
      },
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'linkType',
      label: '与下道工序关系',
      component: 'Select',
      help: () =>
        h(
          'div',
          {
            style:
              'white-space: pre-line; max-width: 320px; line-height: 1.6;',
          },
          '0=开始-开始(SS):本工序开始后,下道工序才能开始(可重叠并行开工)\n1=结束-结束(FF):下道工序必须等到本工序结束才能结束(并行但同步完工)\n2=开始-结束(SF):本工序开始后,下道工序才能结束\n3=结束-开始(FS):本工序结束后,下道工序才能开始(默认值)',
        ),
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_LINK_TYPE, 'number'),
        placeholder: '请选择',
      },
      rules: z.number().optional(),
      rules: z.number().default(3),
    },
    {
      fieldName: 'colorCode',
@@ -284,6 +290,16 @@
      componentProps: {
        checkedChildren: '是',
        unCheckedChildren: '否',
      },
      rules: z.boolean().default(false),
    },
    {
      fieldName: 'backflushFlag',
      label: '报工自动扣料',
      component: 'Switch',
      componentProps: {
        checkedChildren: '自动',
        unCheckedChildren: '手动',
      },
      rules: z.boolean().default(false),
    },
@@ -329,6 +345,25 @@
    { field: 'sort', title: '序号', width: 70, align: 'center', fixed: 'left' },
    { field: 'processCode', title: '工序编码', width: 140, fixed: 'left' },
    { field: 'processName', title: '工序名称', width: 140, fixed: 'left' },
    {
      field: 'outputItemNames',
      title: '本工序产出',
      minWidth: 240,
      formatter: ({ cellValue, row }) => {
        const outputs = row.outputs ?? [];
        if (!outputs.length) {
          return cellValue ?? '';
        }
        return outputs
          .map((output) => {
            const name = output.itemName ?? output.itemCode ?? output.itemId;
            return output.productId == null
              ? `${name}(通用)`
              : `${name}(${output.productName ?? output.productId})`;
          })
          .join('、');
      },
    },
    { field: 'nextProcessName', title: '下一道工序', width: 140 },
    {
      field: 'linkType',
@@ -357,6 +392,15 @@
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    {
      field: 'backflushFlag',
      title: '自动扣料',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.INFRA_BOOLEAN_STRING },
      },
    },
    { field: 'prepareTime', title: '准备时间(分)', width: 110 },
    { field: 'waitTime', title: '等待时间(分)', width: 110 },
    {
@@ -378,11 +422,14 @@
/** å·¥è‰ºè·¯çº¿äº§å“åˆ—表字段 */
export function useRouteProductGridColumns(): VxeTableGridOptions<MesProRouteProductApi.RouteProduct>['columns'] {
  return [
    { field: 'itemCode', title: '产品物料编码', width: 150 },
    { field: 'itemName', title: '产品物料名称', width: 150 },
    { field: 'itemCode', title: '产出编码', width: 150 },
    { field: 'itemName', title: '产出名称', width: 150 },
    { field: 'specification', title: '规格型号', width: 150 },
    { field: 'unitName', title: '单位', width: 80 },
    { field: 'quantity', title: '生产数量', width: 100 },
    { field: 'warehouseName', title: '报工入库仓', width: 140 },
    { field: 'locationName', title: '入库库区', width: 120 },
    { field: 'areaName', title: '入库库位', width: 120 },
    {
      field: 'productionTime',
      title: '生产用时',
@@ -402,8 +449,8 @@
/** å·¥è‰ºè·¯çº¿äº§å“ BOM åˆ—表字段 */
export function useRouteProductBomGridColumns(): VxeTableGridOptions<MesProRouteProductBomApi.RouteProductBom>['columns'] {
  return [
    { field: 'itemCode', title: 'BOM ç‰©æ–™ç¼–码', width: 150 },
    { field: 'itemName', title: 'BOM ç‰©æ–™åç§°', width: 150 },
    { field: 'itemCode', title: '投料编码', width: 150 },
    { field: 'itemName', title: '投料名称', width: 150 },
    { field: 'specification', title: '规格型号', width: 150 },
    { field: 'unitName', title: '单位', width: 80 },
    { field: 'quantity', title: '用料比例', width: 100 },
@@ -420,6 +467,7 @@
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å• */
export function useRouteProductFormSchema(
  onItemChange?: (item: any) => void,
  getFormApi?: () => VbenFormApi,
): VbenFormSchema[] {
  return [
    {
@@ -435,11 +483,12 @@
    // äº§å“ç‰©æ–™ï¼šä½¿ç”¨ä¸šåŠ¡è‡ªå®šä¹‰é€‰æ‹©å™¨ï¼Œchange å›žå¡«ç¼–码/名称/规格/单位
    {
      fieldName: 'itemId',
      label: '产品',
      label: '产出物料',
      component: markRaw(MdItemSelect),
      componentProps: {
        onChange: onItemChange,
      },
      help: '可选择主数据类型为成品或半成品的物料,也兼容 MES æˆå“/半成品分类;已有路线产品可继续修改仓位。',
      formItemClass: 'col-span-2',
      rules: 'selectRequired',
    },
@@ -455,6 +504,63 @@
      rules: z.number().default(1),
    },
    {
      fieldName: 'warehouseId',
      label: '报工仓库',
      component: 'ApiSelect',
      componentProps: {
        api: getWarehouseSimpleList,
        labelField: 'name',
        valueField: 'id',
        placeholder: '请选择报工产出仓库',
        onChange: async () => {
          await getFormApi?.().setFieldValue('locationId', undefined);
          await getFormApi?.().setFieldValue('areaId', undefined);
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'locationId',
      label: '报工库区',
      component: 'Select',
      dependencies: {
        triggerFields: ['warehouseId'],
        disabled: (values) => !values.warehouseId,
        async componentProps(values) {
          const options = values.warehouseId
            ? await getWarehouseLocationSimpleList(values.warehouseId)
            : [];
          return {
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工产出库区',
            onChange: async () => getFormApi?.().setFieldValue('areaId', undefined),
          };
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'areaId',
      label: '报工库位',
      component: 'Select',
      dependencies: {
        triggerFields: ['locationId'],
        disabled: (values) => !values.locationId,
        async componentProps(values) {
          const options = values.locationId
            ? await getWarehouseAreaSimpleList(values.locationId)
            : [];
          return {
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工产出库位',
          };
        },
      },
      rules: 'selectRequired',
    },
    {
      fieldName: 'productionTime',
      label: '生产用时',
      component: 'InputNumber',
src/views/mes/pro/route/index.vue
@@ -8,25 +8,33 @@
import { getDictLabel } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tooltip } from 'ant-design-vue';
import { Alert, Button, message, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteRoute,
  exportRoute,
  getRoutePage,
  importRoute,
  importRouteTemplate,
  updateRouteStatus,
} from '#/api/mes/pro/route';
import ImportModalComponent from '#/components/import-modal';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import Form from './modules/route-configurator.vue';
const { hasAccessByCodes } = useAccess();
const statusEditable = hasAccessByCodes(['mes:pro-route:update']); // æ˜¯å¦å¯åˆ‡æ¢çŠ¶æ€
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportModalComponent,
  destroyOnClose: true,
});
@@ -38,6 +46,18 @@
/** åˆ›å»ºå·¥è‰ºè·¯çº¿ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
function handleImport() {
  importModalApi
    .setData({
      title: $t('ui.actionTitle.import', ['工艺路线']),
      fileName: '工艺路线导入模板.xls',
      importFn: importRoute,
      templateFn: importRouteTemplate,
    })
    .open();
}
/** ç¼–辑工艺路线(仅停用状态可编辑) */
@@ -57,7 +77,9 @@
): Promise<boolean | undefined> {
  try {
    await confirm(
      `确认要将"${row.name}"工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
      newStatus === CommonStatusEnum.ENABLE
        ? `确认启用“${row.name}”吗?系统将检查产出入库位置、工序产出和工序投料是否完整。`
        : `确认要将“${row.name}”工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
    );
  } catch {
    return false;
@@ -118,6 +140,14 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Alert
      class="mb-3"
      message="路线配置顺序"
      description="基本信息 â†’ äº§å‡ºäº§å“åŠå…¥åº“仓 â†’ å·¥åºåŠå„工序产出 â†’ å·¥åºæŠ•æ–™ â†’ æ ¡éªŒå¯ç”¨"
      show-icon
      type="info"
    />
    <Grid table-title="工艺路线列表">
      <template #toolbar-tools>
        <TableAction
@@ -130,6 +160,12 @@
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.import', ['工艺路线']),
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/pro/route/modules/material-input-list.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,145 @@
<script lang="ts" setup>
import type { FormType } from '../data';
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 { Alert, Button, Empty, Tag } from 'ant-design-vue';
import { getRouteProductBomList } from '#/api/mes/pro/route/productbom';
import ProductBomList from './product-bom-list.vue';
const props = defineProps<{
  formType: FormType;
  processes: MesProRouteProcessApi.RouteProcess[];
  products: MesProRouteProductApi.RouteProduct[];
  routeId: number;
}>();
const emit = defineEmits<{
  change: [list: MesProRouteProductBomApi.RouteProductBom[]];
}>();
const activeProductId = ref<number>();
const allBomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]);
const activeProduct = computed(() =>
  props.products.find((item) => item.itemId === activeProductId.value),
);
function materialCount(productId?: number) {
  if (!productId) return 0;
  return allBomList.value.filter((item) => item.productId === productId).length;
}
async function loadAll() {
  allBomList.value = (await getRouteProductBomList({ routeId: props.routeId })) || [];
  emit('change', allBomList.value);
}
watch(
  () => props.products.map((item) => item.itemId).filter(Boolean),
  (productIds) => {
    if (!productIds.length) {
      activeProductId.value = undefined;
      return;
    }
    if (!activeProductId.value || !productIds.includes(activeProductId.value)) {
      activeProductId.value = productIds[0];
    }
  },
  { immediate: true },
);
watch(
  () => props.routeId,
  (value) => value && loadAll(),
  { immediate: true },
);
</script>
<template>
  <Alert
    class="mb-4"
    message="按产出产品配置每道工序需要投入的物料"
    description="先选择产出产品,再切换到具体工序添加投料物料和用料比例。开启“报工自动扣料”的工序,会在报工时按这里的配置自动扣减库存。"
    show-icon
    type="info"
  />
  <Empty
    v-if="!products.length || !processes.length"
    :description="!products.length ? '请先完成产出产品配置' : '请先完成工序配置'"
  />
  <div v-else class="material-layout">
    <aside class="product-selector" aria-label="选择要配置投料的产出产品">
      <div class="selector-heading">
        <strong>产出产品</strong>
        <span>{{ products.length }} ä¸ª</span>
      </div>
      <Button
        v-for="product in products"
        :key="product.itemId"
        :class="['product-button', { active: product.itemId === activeProductId }]"
        type="text"
        @click="activeProductId = product.itemId"
      >
        <span class="product-copy">
          <strong>{{ product.itemName }}</strong>
          <small>{{ product.itemCode }}</small>
        </span>
        <Tag :color="materialCount(product.itemId) ? 'success' : 'warning'">
          {{ materialCount(product.itemId) ? `${materialCount(product.itemId)} æ¡` : '未配置' }}
        </Tag>
      </Button>
    </aside>
    <section v-if="activeProduct" class="material-detail">
      <header class="material-detail-header">
        <div>
          <h3>{{ activeProduct.itemName }} Â· å·¥åºæŠ•æ–™</h3>
          <p>依次切换工序,维护该产品在该工序实际消耗的物料。</p>
        </div>
        <Tag color="blue">{{ processes.length }} é“工序</Tag>
      </header>
      <ProductBomList
        :form-type="formType"
        :product-id="activeProduct.itemId!"
        :product-name="activeProduct.itemName"
        :route-id="routeId"
        @change="loadAll"
      />
    </section>
  </div>
</template>
<style scoped>
.material-layout { display: grid; grid-template-columns: minmax(210px, 260px) minmax(0, 1fr); gap: 18px; }
.product-selector { overflow: hidden; align-self: start; border: 1px solid rgb(226 232 240); border-radius: 12px; background: rgb(248 250 252); }
.selector-heading { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgb(226 232 240); color: rgb(15 23 42); }
.selector-heading span { color: rgb(100 116 139); font-size: 12px; }
.product-button { display: flex; width: 100%; height: auto; min-height: 62px; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 0; text-align: left; }
.product-button + .product-button { border-top: 1px solid rgb(226 232 240); }
.product-button.active { background: rgb(219 234 254); color: rgb(29 78 216); }
.product-copy { display: grid; min-width: 0; gap: 2px; }
.product-copy strong, .product-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.product-copy small { color: rgb(100 116 139); }
.material-detail { min-width: 0; padding: 18px; border: 1px solid rgb(226 232 240); border-radius: 12px; }
.material-detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.material-detail-header h3 { margin: 0; color: rgb(15 23 42); font-size: 16px; font-weight: 650; }
.material-detail-header p { margin: 4px 0 0; color: rgb(100 116 139); font-size: 13px; }
:global(.dark) .product-selector, :global(.dark) .material-detail { border-color: rgb(51 65 85); background: rgb(15 23 42); }
:global(.dark) .selector-heading, :global(.dark) .product-button + .product-button { border-color: rgb(51 65 85); color: rgb(241 245 249); }
:global(.dark) .product-button.active { background: rgb(30 64 175 / 35%); color: rgb(147 197 253); }
:global(.dark) .material-detail-header h3 { color: rgb(241 245 249); }
@media (max-width: 900px) {
  .material-layout { grid-template-columns: 1fr; }
  .product-selector { display: flex; overflow-x: auto; }
  .selector-heading { display: none; }
  .product-button { min-width: 210px; border-top: 0 !important; border-right: 1px solid rgb(226 232 240); }
}
</style>
src/views/mes/pro/route/modules/process-form.vue
@@ -6,7 +6,7 @@
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
@@ -15,6 +15,7 @@
  getRouteProcess,
  updateRouteProcess,
} from '#/api/mes/pro/route/process';
import { getRouteProductListByRoute } from '#/api/mes/pro/route/product';
import { $t } from '#/locales';
import { useRouteProcessFormSchema } from '../data';
@@ -31,7 +32,7 @@
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 150,
    labelWidth: 130,
  },
  layout: 'horizontal',
  schema: [],
@@ -40,13 +41,14 @@
});
/** åŠ è½½å·¥åºé€‰é¡¹åŽå†ç”Ÿæˆ schema(避免下拉空选项) */
async function loadSchema(): Promise<VbenFormSchema[]> {
async function loadSchema(routeId: number): Promise<VbenFormSchema[]> {
  const list = await getProcessSimpleList();
  const options = (list || []).map((item) => ({
    label: item.name ?? '',
    value: item.id!,
  }));
  return useRouteProcessFormSchema(options);
  const products = routeId ? await getRouteProductListByRoute(routeId) : [];
  return useRouteProcessFormSchema(options, products);
}
const [Modal, modalApi] = useVbenModal({
@@ -76,15 +78,6 @@
      formData.value = undefined;
      return;
    }
    modalApi.lock();
    try {
      // å·¥åºä¸‹æ‹‰ä¾èµ–远程数据,schema åœ¨å¼¹çª—打开时再生成
      const schema = await loadSchema();
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      maxSort?: number;
@@ -93,6 +86,15 @@
    if (!data) {
      return;
    }
    modalApi.lock();
    try {
      // å·¥åºä¸‹æ‹‰ä¾èµ–远程数据,schema åœ¨å¼¹çª—打开时再生成
      const schema = await loadSchema(data.routeId);
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    // åŠ è½½æ•°æ®
    if (!data.id) {
      // æ–°å¢žæ—¶ï¼Œé»˜è®¤åºå· = maxSort + 1,并给一个默认甘特图颜色
      await formApi.setValues({
@@ -116,6 +118,13 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      class="mx-4 mb-4"
      message="按实际生产节点配置产出"
      description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,末道工序必须选择全部成品。归属表示该产出属于哪个产品:多产品共用的半成品选“通用”,产品专属(尤其是成品)归属到自身产品。如开启报工自动扣料,报工时会按工序投料配置扣减库存。"
      show-icon
      type="info"
    />
    <Form class="mx-4" />
  </Modal>
</template>
src/views/mes/pro/route/modules/process-list.vue
@@ -4,11 +4,11 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message, Tag } from 'ant-design-vue';
import { TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -25,8 +25,15 @@
  routeId: number;
}>();
const isEditable = ref(props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const emit = defineEmits<{
  change: [list: MesProRouteProcessApi.RouteProcess[]];
}>();
const isEditable = computed(() => props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const list = ref<MesProRouteProcessApi.RouteProcess[]>([]); // å·¥è‰ºè·¯çº¿å·¥åºåˆ—表
const sortedList = computed(() =>
  [...list.value].sort((a, b) => (a.sort || 0) - (b.sort || 0)),
);
const [ProcessFormModal, processFormModalApi] = useVbenModal({
  connectedComponent: ProcessForm,
@@ -53,6 +60,7 @@
  try {
    list.value = await getRouteProcessListByRoute(props.routeId);
    gridApi.setGridOptions({ data: list.value });
    emit('change', list.value);
  } finally {
    gridApi.setLoading(false);
  }
@@ -89,6 +97,16 @@
<template>
  <ProcessFormModal @success="getList" />
  <div v-if="sortedList.length" class="mb-4 flex flex-wrap items-center gap-2 rounded-lg bg-slate-50 p-3 dark:bg-slate-900">
    <template v-for="(item, index) in sortedList" :key="item.id">
      <div class="flex items-center gap-2 rounded-md border border-slate-200 bg-white px-3 py-2 dark:border-slate-700 dark:bg-slate-800">
        <span class="text-xs font-semibold text-blue-600">{{ item.sort }}</span>
        <span class="font-medium">{{ item.processName }}</span>
        <Tag v-if="index === sortedList.length - 1" color="blue">末道</Tag>
      </div>
      <span v-if="index < sortedList.length - 1" class="text-slate-400">→</span>
    </template>
  </div>
  <div v-if="isEditable" class="mb-3 flex items-center justify-start">
    <TableAction
      :actions="[
@@ -100,7 +118,7 @@
      ]"
    />
  </div>
  <Grid class="w-full" table-title="组成工序">
  <Grid class="w-full" table-title="工序顺序及各工序产出">
    <template #colorCode="{ row }">
      <div v-if="row.colorCode" class="flex items-center justify-center gap-1">
        <div
src/views/mes/pro/route/modules/product-bom-list.vue
@@ -1,9 +1,10 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProcessApi } from '#/api/mes/pro/route/process';
import type { MesProRouteProductBomApi } from '#/api/mes/pro/route/productbom';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -21,10 +22,17 @@
import BomForm from './bom-form.vue';
const props = defineProps<{
  formType: FormType;
  productId: number;
  productName?: string;
  routeId: number;
}>();
const emit = defineEmits<{
  change: [];
}>();
const isEditable = computed(() => props.formType !== 'detail');
const processList = ref<MesProRouteProcessApi.RouteProcess[]>([]); // å·¥åºåˆ—表(用于 Tab)
const activeProcessId = ref<number>(); // å½“前选中的工序 Tab
@@ -112,6 +120,12 @@
  await deleteRouteProductBom(row.id!);
  message.success($t('ui.actionMessage.deleteSuccess', ['BOM ç‰©æ–™']));
  await getList();
  emit('change');
}
async function handleSaved() {
  await getList();
  emit('change');
}
watch(
@@ -126,7 +140,7 @@
</script>
<template>
  <BomFormModal @success="getList" />
  <BomFormModal @success="handleSaved" />
  <Tabs v-model:active-key="activeProcessId" @change="getList">
    <TabPane
      v-for="item in processList"
@@ -134,11 +148,11 @@
      :tab="item.processName"
    />
  </Tabs>
  <div class="mb-3 flex items-center justify-start">
  <div v-if="isEditable" class="mb-3 flex items-center justify-start">
    <TableAction
      :actions="[
        {
          label: $t('ui.actionTitle.create', ['BOM ç‰©æ–™']),
          label: $t('ui.actionTitle.create', ['投料物料']),
          type: 'primary',
          disabled: !activeProcessId,
          onClick: handleCreate,
@@ -146,21 +160,23 @@
      ]"
    />
  </div>
  <Grid class="w-full" table-title="产品 BOM">
  <Grid class="w-full" table-title="当前工序投料">
    <template #actions="{ row }">
      <TableAction
        :actions="[
          {
            label: $t('common.edit'),
            type: 'link',
            ifShow: () => isEditable,
            onClick: handleEdit.bind(null, row),
          },
          {
            label: $t('common.delete'),
            type: 'link',
            danger: true,
            ifShow: () => isEditable,
            popConfirm: {
              title: $t('ui.actionMessage.deleteConfirm', ['BOM ç‰©æ–™']),
              title: $t('ui.actionMessage.deleteConfirm', ['投料物料']),
              confirm: handleDelete.bind(null, row),
            },
          },
src/views/mes/pro/route/modules/product-form.vue
@@ -5,7 +5,7 @@
import { useVbenModal } from '@vben/common-ui';
import { Divider, message } from 'ant-design-vue';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -16,7 +16,6 @@
import { $t } from '#/locales';
import { useRouteProductFormSchema } from '../data';
import ProductBomList from './product-bom-list.vue';
const emit = defineEmits(['success']);
const formData = ref<MesProRouteProductApi.RouteProduct>();
@@ -46,7 +45,7 @@
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: useRouteProductFormSchema(handleItemChange),
  schema: useRouteProductFormSchema(handleItemChange, () => formApi),
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
@@ -64,14 +63,10 @@
    try {
      if (formData.value?.id) {
        await updateRouteProduct(data);
        // ç”¨æœ€æ–°è¡¨å•值同步 formData,确保产品 BOM å­è¡¨ç»‘定的 itemId ä¸Žè¡¨å•一致
        formData.value = { ...formData.value, ...data };
      } else {
        // æ–°å¢žæˆåŠŸåŽåˆ‡æ¢åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤äº§å“ BOM
        const id = await createRouteProduct(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
        await createRouteProduct(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
@@ -109,17 +104,13 @@
<template>
  <Modal :title="getTitle" class="w-3/5">
    <Alert
      class="mx-4 mb-4"
      message="这里维护产出及其报工入库位置"
      description="投料物料将在路线配置的“工序投料”步骤中按产品、按工序统一维护。"
      show-icon
      type="info"
    />
    <Form class="mx-4" />
    <!-- ç¼–辑模式下展示产品 BOM å­è¡¨ï¼Œæ–°å¢žæ¨¡å¼ä¸‹éšè— -->
    <template v-if="formData?.id && formData?.itemId">
      <Divider class="!my-3" orientation="left">产品 BOM é…ç½®</Divider>
      <div class="mx-4">
        <ProductBomList
          :route-id="formData.routeId!"
          :product-id="formData.itemId"
          :product-name="formData.itemName"
        />
      </div>
    </template>
  </Modal>
</template>
src/views/mes/pro/route/modules/product-list.vue
@@ -4,7 +4,7 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
@@ -27,7 +27,11 @@
  routeId: number;
}>();
const isEditable = ref(props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const emit = defineEmits<{
  change: [list: MesProRouteProductApi.RouteProduct[]];
}>();
const isEditable = computed(() => props.formType !== 'detail'); // æ˜¯å¦å¯ç¼–辑
const list = ref<MesProRouteProductApi.RouteProduct[]>([]); // å·¥è‰ºè·¯çº¿äº§å“åˆ—表
const [ProductFormModal, productFormModalApi] = useVbenModal({
@@ -55,6 +59,7 @@
  try {
    list.value = await getRouteProductListByRoute(props.routeId);
    gridApi.setGridOptions({ data: list.value });
    emit('change', list.value);
  } finally {
    gridApi.setLoading(false);
  }
@@ -94,14 +99,14 @@
    <TableAction
      :actions="[
        {
          label: $t('ui.actionTitle.create', ['关联产品']),
          label: $t('ui.actionTitle.create', ['产出产品']),
          type: 'primary',
          onClick: handleCreate,
        },
      ]"
    />
  </div>
  <Grid class="w-full" table-title="关联产品">
  <Grid class="w-full" table-title="产出产品及报工入库位置">
    <template #productionTime="{ row }">
      <span v-if="row.productionTime">
        {{ row.productionTime }}
src/views/mes/pro/route/modules/route-configurator.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,425 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { MesProRouteApi } from '#/api/mes/pro/route';
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 } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, Button, Empty, message, Steps, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createRoute, getRoute, updateRoute } from '#/api/mes/pro/route';
import { getRouteProcessListByRoute } from '#/api/mes/pro/route/process';
import { getRouteProductListByRoute } from '#/api/mes/pro/route/product';
import { getRouteProductBomList } from '#/api/mes/pro/route/productbom';
import { useFormSchema } from '../data';
import MaterialInputList from './material-input-list.vue';
import ProcessList from './process-list.vue';
import ProductList from './product-list.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const formData = ref<MesProRouteApi.Route>();
const activeStep = ref(0);
const productList = ref<MesProRouteProductApi.RouteProduct[]>([]);
const processList = ref<MesProRouteProcessApi.RouteProcess[]>([]);
const bomList = ref<MesProRouteProductBomApi.RouteProductBom[]>([]);
const isDetail = computed(() => formType.value === 'detail');
const isNew = computed(() => !formData.value?.id);
const routeId = computed(() => formData.value?.id);
const sortedProcessList = computed(() =>
  [...processList.value].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
);
const title = computed(() =>
  isDetail.value ? '查看工艺路线' : isNew.value ? '新建工艺路线' : '编辑工艺路线',
);
const stepItems = [
  { title: '基本信息', description: '路线名称与说明' },
  { title: '产出与入库', description: '产品及报工仓位' },
  { title: '工序与产出', description: '顺序和半成品/成品' },
  { title: '工序投料', description: '按产品与工序配置' },
  { title: '启用前检查', description: '集中检查缺失项' },
];
const productsWithWarehouse = computed(() =>
  productList.value.filter((item) => item.warehouseId && item.locationId && item.areaId).length,
);
const processesWithOutput = computed(() =>
  processList.value.filter((item) => item.outputItemIds?.length).length,
);
const lastProcess = computed(() => sortedProcessList.value.at(-1));
const configuredProductIds = computed(
  () => new Set(bomList.value.map((item) => item.productId).filter(Boolean)),
);
const productsWithBom = computed(() => configuredProductIds.value.size);
const reviewIssues = computed(() => {
  const issues: Array<{ step: number; text: string }> = [];
  if (!productList.value.length) issues.push({ step: 1, text: '还没有配置产出产品' });
  const missingWarehouse = productList.value.filter(
    (item) => !item.warehouseId || !item.locationId || !item.areaId,
  );
  if (missingWarehouse.length) {
    issues.push({
      step: 1,
      text: `${missingWarehouse.length} ä¸ªäº§å‡ºäº§å“æœªé…ç½®å®Œæ•´ä»“库、库区和库位`,
    });
  }
  if (!processList.value.length) issues.push({ step: 2, text: '还没有配置生产工序' });
  if (processList.value.length && !processList.value.some((item) => item.keyFlag)) {
    issues.push({ step: 2, text: '未设置关键工序' });
  }
  if (lastProcess.value && !lastProcess.value.outputItemIds?.length) {
    issues.push({
      step: 2,
      text: `末道工序“${lastProcess.value.processName || lastProcess.value.processCode || lastProcess.value.sort}”必须配置成品产出`,
    });
  }
  const lastOutputs = lastProcess.value?.outputs ?? [];
  if (
    lastOutputs.length &&
    lastOutputs.some(
      (output) => output.productId == null || output.productId !== output.itemId,
    )
  ) {
    issues.push({
      step: 2,
      text: '末道工序的成品产出必须归属到自身产品,不能使用“通用”',
    });
  }
  const productIds = new Set(productList.value.map((item) => item.itemId));
  const invalidOutputCount = processList.value.filter((process) =>
    process.outputItemIds?.some((itemId) => !productIds.has(itemId)),
  ).length;
  if (invalidOutputCount) {
    issues.push({
      step: 2,
      text: `${invalidOutputCount} é“工序的产出未包含在路线产出产品中`,
    });
  }
  const noBomProducts = productList.value.filter(
    (item) => item.itemId && !configuredProductIds.value.has(item.itemId),
  );
  if (noBomProducts.length) {
    issues.push({ step: 3, text: `${noBomProducts.length} ä¸ªäº§å“è¿˜æ²¡æœ‰é…ç½®ä»»ä½•工序投料` });
  }
  return issues;
});
const primaryText = computed(() => {
  if (activeStep.value === stepItems.length - 1) return isDetail.value ? '关闭' : '完成配置';
  if (activeStep.value === 0 && isNew.value) return '保存并配置产出';
  return '下一步';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 100,
  },
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
  wrapperClass: 'grid-cols-2',
});
async function loadConfiguration() {
  if (!routeId.value) return;
  const [products, processes, boms] = await Promise.all([
    getRouteProductListByRoute(routeId.value),
    getRouteProcessListByRoute(routeId.value),
    getRouteProductBomList({ routeId: routeId.value }),
  ]);
  productList.value = products || [];
  processList.value = processes || [];
  bomList.value = boms || [];
}
async function saveBaseInformation() {
  const { valid } = await formApi.validate();
  if (!valid) return false;
  modalApi.lock();
  try {
    const data = (await formApi.getValues()) as MesProRouteApi.Route;
    if (!routeId.value) {
      const id = await createRoute(data);
      formData.value = { ...data, id };
      await formApi.setFieldValue('id', id);
      formType.value = 'update';
      message.success('基本信息已保存,请继续配置产出产品和入库位置');
    } else {
      await updateRoute({ ...data, id: routeId.value });
      formData.value = { ...formData.value, ...data, id: routeId.value };
    }
    emit('success');
    return true;
  } finally {
    modalApi.unlock();
  }
}
function validateProducts() {
  if (!productList.value.length) {
    message.warning('请至少添加一个产出产品,再配置工序');
    return false;
  }
  if (productsWithWarehouse.value !== productList.value.length) {
    message.warning('请为每个产出产品配置完整的仓库、库区和库位');
    return false;
  }
  return true;
}
function validateProcesses() {
  if (!processList.value.length) {
    message.warning('请至少添加一道生产工序');
    return false;
  }
  return true;
}
function validateBom() {
  if (productsWithBom.value !== productList.value.length) {
    message.warning('每个产出产品至少要配置一条工序投料');
    return false;
  }
  return true;
}
async function goToStep(target: number) {
  if (target < 0 || target >= stepItems.length) return;
  if (target <= activeStep.value || isDetail.value) {
    activeStep.value = target;
    return;
  }
  if (!(await saveBaseInformation())) return;
  await loadConfiguration();
  if (target >= 2 && !validateProducts()) return void (activeStep.value = 1);
  if (target >= 3 && !validateProcesses()) return void (activeStep.value = 2);
  if (target >= 4 && !validateBom()) return void (activeStep.value = 3);
  activeStep.value = target;
}
async function handlePrimaryAction() {
  if (activeStep.value === stepItems.length - 1) {
    if (!isDetail.value && reviewIssues.value.length) {
      message.warning('仍有缺失配置,请根据检查结果补充后再完成');
      return;
    }
    await modalApi.close();
    return;
  }
  await goToStep(activeStep.value + 1);
}
const [Modal, modalApi] = useVbenModal({
  showConfirmButton: false,
  cancelText: '关闭',
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      productList.value = [];
      processList.value = [];
      bomList.value = [];
      activeStep.value = 0;
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(isDetail.value);
    if (!data.id) return;
    modalApi.lock();
    try {
      formData.value = await getRoute(data.id);
      await formApi.setValues(formData.value);
      await loadConfiguration();
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="title" class="w-[94vw] max-w-[1560px]">
    <div class="route-config-shell">
      <header class="route-config-header">
        <div class="min-w-0">
          <div class="flex flex-wrap items-center gap-2">
            <h2 class="m-0 text-lg font-semibold text-gray-900 dark:text-gray-100">
              {{ formData?.name || '新工艺路线' }}
            </h2>
            <Tag :color="isDetail ? 'default' : 'processing'">
              {{ isDetail ? '只读查看' : '配置中' }}
            </Tag>
          </div>
          <p class="mb-0 mt-1 text-sm text-gray-600 dark:text-gray-300">
            å·¥åºæŠ•料依赖产出产品和工序,因此在工序编排完成后集中配置。
          </p>
        </div>
        <div class="route-config-summary" aria-label="当前配置概览">
          <span>产出 {{ productList.length }}</span>
          <span>工序 {{ processList.length }}</span>
          <span>投料 {{ bomList.length }}</span>
        </div>
      </header>
      <Steps
        :current="activeStep"
        :items="stepItems"
        class="route-config-steps"
        responsive
        size="small"
        type="navigation"
        @change="goToStep"
      />
      <main class="route-config-content">
        <section v-show="activeStep === 0" class="route-step-panel">
          <div class="route-step-copy">
            <IconifyIcon icon="lucide:route" />
            <div>
              <h3>先定义这条路线是什么</h3>
              <p>编码用于系统识别,名称和说明帮助现场人员快速选择。</p>
            </div>
          </div>
          <Form class="route-base-form" />
        </section>
        <section v-if="routeId" v-show="activeStep === 1" class="route-step-panel">
          <Alert
            class="mb-4"
            message="先维护所有可能的工序产出"
            description="实际形成的中间半成品和末道工序的成品都要加入,并分别指定报工后进入的仓库、库区和库位。"
            show-icon
            type="info"
          />
          <ProductList
            :form-type="formType"
            :route-id="routeId"
            @change="productList = $event"
          />
        </section>
        <section v-if="routeId" v-show="activeStep === 2" class="route-step-panel">
          <Alert
            class="mb-4"
            message="按实际生产顺序编排工序"
            description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,最后一道工序必须选择路线绑定的全部成品。多产品共用的半成品归属选“通用”,成品必须归属到自身产品。"
            show-icon
            type="info"
          />
          <ProcessList
            :form-type="formType"
            :route-id="routeId"
            @change="processList = $event"
          />
        </section>
        <section v-if="routeId" v-show="activeStep === 3" class="route-step-panel">
          <MaterialInputList
            :form-type="formType"
            :processes="sortedProcessList"
            :products="productList"
            :route-id="routeId"
            @change="bomList = $event"
          />
        </section>
        <section v-show="activeStep === 4" class="route-step-panel">
          <div class="review-heading">
            <div>
              <h3>启用前检查</h3>
              <p>确认产出、仓位、工序和投料形成闭环后,再回到列表启用路线。</p>
            </div>
            <Tag :color="reviewIssues.length ? 'warning' : 'success'">
              {{ reviewIssues.length ? `${reviewIssues.length} é¡¹å¾…处理` : '配置完整' }}
            </Tag>
          </div>
          <div class="review-metrics">
            <div><strong>{{ productsWithWarehouse }}/{{ productList.length }}</strong><span>产品仓位完整</span></div>
            <div><strong>{{ processesWithOutput }}</strong><span>配置产出的工序</span></div>
            <div><strong>{{ productsWithBom }}/{{ productList.length }}</strong><span>产品已配置投料</span></div>
          </div>
          <div v-if="reviewIssues.length" class="issue-list" role="status">
            <button
              v-for="issue in reviewIssues"
              :key="issue.text"
              class="issue-item"
              type="button"
              @click="activeStep = issue.step"
            >
              <IconifyIcon icon="lucide:circle-alert" />
              <span>{{ issue.text }}</span>
              <IconifyIcon class="ml-auto" icon="lucide:chevron-right" />
            </button>
          </div>
          <Empty v-else description="所有必需配置均已完成">
            <template #image>
              <div class="review-success-icon"><IconifyIcon icon="lucide:badge-check" /></div>
            </template>
          </Empty>
        </section>
      </main>
    </div>
    <template #prepend-footer>
      <div class="flex flex-auto items-center justify-between gap-3">
        <Button v-if="activeStep > 0" @click="activeStep--">
          <IconifyIcon class="mr-1" icon="lucide:arrow-left" />上一步
        </Button>
        <span v-else class="text-sm text-gray-500">新建路线默认停用,配置完整后再启用</span>
        <Button type="primary" @click="handlePrimaryAction">
          {{ primaryText }}
          <IconifyIcon
            v-if="activeStep < stepItems.length - 1"
            class="ml-1"
            icon="lucide:arrow-right"
          />
        </Button>
      </div>
    </template>
  </Modal>
</template>
<style scoped>
.route-config-shell { min-height: min(720px, 72vh); background: rgb(248 250 252); color: rgb(15 23 42); }
.route-config-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px 24px 14px; background: white; border-bottom: 1px solid rgb(226 232 240); }
.route-config-summary { display: flex; flex-shrink: 0; gap: 8px; }
.route-config-summary span { padding: 5px 10px; border-radius: 8px; background: rgb(239 246 255); color: rgb(29 78 216); font-size: 12px; font-weight: 600; }
.route-config-steps { padding: 12px 20px 0; background: white; }
.route-config-content { padding: 20px 24px 28px; }
.route-step-panel { min-height: 460px; padding: 22px; border-radius: 14px; background: white; box-shadow: 0 10px 28px rgb(15 23 42 / 6%); }
.route-step-copy, .review-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 22px; }
.route-step-copy { justify-content: flex-start; }
.route-step-copy > svg { width: 38px; height: 38px; padding: 9px; border-radius: 10px; background: rgb(219 234 254); color: rgb(29 78 216); }
.route-step-copy h3, .review-heading h3 { margin: 0; color: rgb(15 23 42); font-size: 17px; font-weight: 650; }
.route-step-copy p, .review-heading p { margin: 4px 0 0; color: rgb(71 85 105); font-size: 14px; }
.route-base-form { max-width: 1040px; margin: 34px auto 0; }
.review-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 24px 0; }
.review-metrics > div { display: flex; align-items: baseline; gap: 10px; padding: 18px; border-radius: 12px; background: rgb(248 250 252); }
.review-metrics strong { color: rgb(29 78 216); font-size: 24px; }
.review-metrics span { color: rgb(71 85 105); font-size: 13px; }
.issue-list { display: grid; gap: 10px; }
.issue-item { display: flex; width: 100%; min-height: 48px; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid rgb(253 230 138); border-radius: 10px; background: rgb(255 251 235); color: rgb(146 64 14); cursor: pointer; text-align: left; transition: background-color 180ms ease, border-color 180ms ease; }
.issue-item:hover, .issue-item:focus-visible { border-color: rgb(245 158 11); background: rgb(254 243 199); outline: none; }
.review-success-icon { display: grid; width: 72px; height: 72px; place-items: center; margin: 0 auto; border-radius: 50%; background: rgb(220 252 231); color: rgb(22 163 74); font-size: 38px; }
:global(.dark) .route-config-shell { background: rgb(15 23 42); color: rgb(226 232 240); }
:global(.dark) .route-config-header, :global(.dark) .route-config-steps, :global(.dark) .route-step-panel { background: rgb(30 41 59); border-color: rgb(51 65 85); }
:global(.dark) .route-step-copy h3, :global(.dark) .review-heading h3 { color: rgb(241 245 249); }
:global(.dark) .route-step-copy p, :global(.dark) .review-heading p, :global(.dark) .review-metrics span { color: rgb(203 213 225); }
:global(.dark) .review-metrics > div { background: rgb(15 23 42); }
@media (max-width: 900px) {
  .route-config-header { align-items: flex-start; flex-direction: column; }
  .review-metrics { grid-template-columns: 1fr; }
  .route-step-panel { padding: 16px; }
}
</style>
src/views/mes/pro/workorder/data.ts
@@ -760,6 +760,17 @@
      width: 160,
    },
    {
      field: 'warehouseName',
      title: '报工仓库',
      width: 130,
      formatter: ({ cellValue, row }) => {
        if (!cellValue) return '-';
        return row.areaName
          ? `${cellValue}/${row.locationName ?? ''}/${row.areaName}`
          : cellValue;
      },
    },
    {
      field: 'remark',
      title: '备注',
      minWidth: 120,
src/views/mes/pro/workorder/modules/process-form.vue
@@ -10,11 +10,15 @@
import { useVbenForm } from '#/adapter/form';
import { getProcessSimpleList } from '#/api/mes/pro/process';
import { getRouteProcessListByProduct } from '#/api/mes/pro/route/process';
import {
  createWorkOrderProcess,
  getWorkOrderProcess,
  updateWorkOrderProcess,
} from '#/api/mes/pro/workorder/process';
import { getWarehouseSimpleList } from '#/api/mes/wm/warehouse';
import { getWarehouseAreaSimpleList } from '#/api/mes/wm/warehouse/area';
import { getWarehouseLocationSimpleList } from '#/api/mes/wm/warehouse/location';
import { $t } from '#/locales';
import { MdItemSelect } from '#/views/mes/md/item/components';
@@ -23,6 +27,7 @@
const workOrderId = ref<number>();
const processList = ref<MesProWorkOrderProcessApi.WorkOrderProcess[]>([]);
const isCreateMode = ref(false);
const allowedOutputItemIds = ref<number[]>([]);
const getTitle = computed(() => {
  return formData.value?.id
@@ -39,9 +44,37 @@
  }));
}
/**
 * è½½å…¥æœ¬å·¥å•产品可选的产出物料范围
 *
 * å–工艺路线按产出归属过滤后的产出(通用半成品 + æœ¬äº§å“ä¸“属成品),
 * å¹¶å¹¶å…¥è¯¥å·¥åºå·²é…ç½®çš„产出,避免历史数据在重新选择时被误清空。
 */
async function loadAllowedOutputItemIds(
  productId?: number,
  currentOutputItemIds: number[] = [],
): Promise<number[]> {
  if (!productId) {
    return [];
  }
  const ids = new Set<number>([productId, ...currentOutputItemIds]);
  const routeProcessList = await getRouteProcessListByProduct(productId);
  (routeProcessList || []).forEach((process) => {
    (process.outputItemIds || []).forEach((id) => {
      if (id) {
        ids.add(id);
      }
    });
  });
  return [...ids];
}
/** è¡¨å• schema */
async function loadSchema(): Promise<VbenFormSchema[]> {
  const processOptions = await loadProcessOptions();
  const [processOptions, warehouseList] = await Promise.all([
    loadProcessOptions(),
    getWarehouseSimpleList(),
  ]);
  return [
    {
      fieldName: 'id',
@@ -50,6 +83,21 @@
    },
    {
      fieldName: 'workOrderId',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'warehouseName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'locationName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    {
      fieldName: 'areaName',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
@@ -185,6 +233,80 @@
      componentProps: {
        multiple: true,
        placeholder: '请选择产出物料',
        productIds: allowedOutputItemIds.value,
      },
    },
    {
      fieldName: 'warehouseId',
      label: '报工仓库',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: (warehouseList || []).map((item) => ({
          label: item.name ?? '',
          value: item.id!,
        })),
        placeholder: '默认取工艺路线配置,可修改',
        onChange: async (value: number) => {
          const warehouse = (warehouseList || []).find(
            (item) => item.id === value,
          );
          await formApi.setFieldValue('warehouseName', warehouse?.name);
          await formApi.setFieldValue('locationId', undefined);
          await formApi.setFieldValue('locationName', undefined);
          await formApi.setFieldValue('areaId', undefined);
          await formApi.setFieldValue('areaName', undefined);
        },
      },
    },
    {
      fieldName: 'locationId',
      label: '报工库区',
      component: 'Select',
      dependencies: {
        triggerFields: ['warehouseId'],
        disabled: (values) => !values.warehouseId,
        async componentProps(values) {
          const options = values.warehouseId
            ? await getWarehouseLocationSimpleList(values.warehouseId)
            : [];
          return {
            allowClear: true,
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工库区',
            onChange: async (value: number) => {
              const location = options.find((item) => item.id === value);
              await formApi.setFieldValue('locationName', location?.name);
              await formApi.setFieldValue('areaId', undefined);
              await formApi.setFieldValue('areaName', undefined);
            },
          };
        },
      },
    },
    {
      fieldName: 'areaId',
      label: '报工库位',
      component: 'Select',
      dependencies: {
        triggerFields: ['locationId'],
        disabled: (values) => !values.locationId,
        async componentProps(values) {
          const options = values.locationId
            ? await getWarehouseAreaSimpleList(values.locationId)
            : [];
          return {
            allowClear: true,
            fieldNames: { label: 'name', value: 'id' },
            options,
            placeholder: '请选择报工库位',
            onChange: async (value: number) => {
              const area = options.find((item) => item.id === value);
              await formApi.setFieldValue('areaName', area?.name);
            },
          };
        },
      },
    },
    {
@@ -271,16 +393,9 @@
      formData.value = undefined;
      processList.value = [];
      isCreateMode.value = false;
      allowedOutputItemIds.value = [];
      return;
    }
    modalApi.lock();
    try {
      const schema = await loadSchema();
      formApi.setState({ schema });
    } finally {
      modalApi.unlock();
    }
    const data = modalApi.getData<{
      id?: number;
      productId?: number;
@@ -296,27 +411,33 @@
    processList.value = data.processList || [];
    isCreateMode.value = !data.workOrderId;
    if (data.row) {
      // ç¼–辑临时列表中的工序
      formData.value = data.row;
      await formApi.setValues(data.row);
    } else if (data.id) {
      // ç¼–辑已保存的工序
      modalApi.lock();
      try {
        formData.value = await getWorkOrderProcess(data.id);
    modalApi.lock();
    try {
      // å…ˆå–工序数据:编辑已保存工序时用于回显,也用于保留其已有产出
      const savedProcess = !data.row && data.id
        ? await getWorkOrderProcess(data.id)
        : undefined;
      formData.value = data.row ?? savedProcess;
      allowedOutputItemIds.value = await loadAllowedOutputItemIds(
        data.productId,
        formData.value?.outputItemIds || [],
      );
      formApi.setState({ schema: await loadSchema() });
      if (formData.value) {
        // ç¼–辑临时列表中的工序 / ç¼–辑已保存的工序
        await formApi.setValues(formData.value);
      } finally {
        modalApi.unlock();
      } else {
        // æ–°å¢ž
        const maxSort = Math.max(0, ...processList.value.map((item) => item.sort || 0));
        await formApi.setValues({
          colorCode: '#00AEF3',
          sort: maxSort + 1,
        });
      }
    } else {
      // æ–°å¢ž
      formData.value = undefined;
      const maxSort = Math.max(0, ...processList.value.map((item) => item.sort || 0));
      await formApi.setValues({
        colorCode: '#00AEF3',
        sort: maxSort + 1,
      });
    } finally {
      modalApi.unlock();
    }
  },
});
src/views/mes/pro/workorder/modules/process-list.vue
@@ -158,6 +158,12 @@
          backflushFlag: item.backflushFlag,
          outputItemIds: item.outputItemIds,
          outputItemNames: item.outputItemNames,
          warehouseId: item.warehouseId,
          warehouseName: item.warehouseName,
          locationId: item.locationId,
          locationName: item.locationName,
          areaId: item.areaId,
          areaName: item.areaName,
          remark: item.remark,
          bomItems: item.bomItems,
        }));
src/views/mes/process-design/route/data.ts
@@ -108,6 +108,16 @@
      },
    },
    {
      fieldName: "productKeyword",
      label: "产品",
      component: "Input",
      componentProps: {
        allowClear: true,
        maxLength: 100,
        placeholder: "输入产品名称或编码",
      },
    },
    {
      fieldName: "status",
      label: "状态",
      component: "Select",
@@ -154,7 +164,7 @@
      field: "createTime",
      title: "创建时间",
      width: 180,
      formatter: 'formatDate',
      formatter: "formatDateTime",
    },
    {
      title: "操作",
@@ -205,7 +215,7 @@
      fieldName: "yieldRate",
      label: "工序收率",
      component: "InputNumber",
      help: "产出量 Ã· æŠ•入量。填写 0.98 è¡¨ç¤º 98%,1 è¡¨ç¤ºæ— æŸè€—;未填写按 100% å‚与排产计算。",
      help: "产出量 Ã· æŠ•入量。填写 0.98 è¡¨ç¤º 98%,未填写按 100% å‚与排产计算。",
      componentProps: {
        class: "!w-full",
        min: 0.0001,
@@ -220,21 +230,12 @@
      fieldName: "linkType",
      label: "与下道工序关系",
      component: "Select",
      help: () =>
        h(
          "div",
          {
            style:
              "white-space: pre-line; max-width: 320px; line-height: 1.6;",
          },
          "0=开始-开始(SS):本工序开始后,下道工序才能开始(可重叠并行开工)\n1=结束-结束(FF):下道工序必须等到本工序结束才能结束(并行但同步完工)\n2=开始-结束(SF):本工序开始后,下道工序才能结束\n3=结束-开始(FS):本工序结束后,下道工序才能开始(默认值)",
        ),
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.MES_PRO_LINK_TYPE, "number"),
        placeholder: "请选择",
      },
      rules: z.number().optional(),
      rules: z.number().default(3),
    },
    {
      fieldName: "colorCode",
@@ -425,11 +426,8 @@
  ];
}
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å•ï¼›multiple = true(新增模式)时产品可多选,一次性绑定多个 */
export function useRouteProductFormSchema(
  onItemChange?: (item: any) => void,
  multiple = false,
): VbenFormSchema[] {
/** å·¥è‰ºè·¯çº¿äº§å“è¡¨å• */
export function useRouteProductFormSchema(onItemChange?: (item: any) => void): VbenFormSchema[] {
  return [
    {
      fieldName: "id",
@@ -441,29 +439,17 @@
      component: "Input",
      dependencies: { triggerFields: [""], show: () => false },
    },
    // æ–°å¢žï¼ˆå¤šé€‰ï¼‰ï¼šä¸€æ¬¡ç»‘定多个产品;编辑(单选):change å›žå¡«ç¼–码/名称/规格/单位
    multiple
      ? {
          fieldName: "itemIds",
          label: "产品(可多选)",
          component: markRaw(MdItemSelect),
          componentProps: {
            multiple: true,
            placeholder: "请选择要绑定的产品(可多选)",
          },
          formItemClass: "col-span-2",
          rules: "selectRequired",
        }
      : {
          fieldName: "itemId",
          label: "产品",
          component: markRaw(MdItemSelect),
          componentProps: {
            onChange: onItemChange,
          },
          formItemClass: "col-span-2",
          rules: "selectRequired",
        },
    // äº§å“ç‰©æ–™ï¼šä½¿ç”¨ä¸šåŠ¡è‡ªå®šä¹‰é€‰æ‹©å™¨ï¼Œchange å›žå¡«ç¼–码/名称/规格/单位
    {
      fieldName: "itemId",
      label: "产品",
      component: markRaw(MdItemSelect),
      componentProps: {
        onChange: onItemChange,
      },
      formItemClass: "col-span-2",
      rules: "selectRequired",
    },
    {
      fieldName: "quantity",
      label: "生产数量",
src/views/mes/process-design/route/index.vue
@@ -8,19 +8,26 @@
import { getDictLabel } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tooltip } from 'ant-design-vue';
import { Alert, Button, message, Tooltip } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteRoute,
  exportRoute,
  getRoutePage,
  importRoute,
  importRouteTemplate,
  importCompleteRoute,
  importCompleteRouteTemplate,
  updateRouteStatus,
} from '#/api/mes/pro/route';
import { $t } from '#/locales';
import ImportModalComponent from '#/components/import-modal';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
// å·¥è‰ºè®¾è®¡ä¸Žç”Ÿäº§åŸºç¡€æ•°æ®å…±ç”¨åŒä¸€ä¸ªé…ç½®å™¨ï¼Œé¿å…ä¸¤å¥—页面能力不一致。
import Form from '#/views/mes/pro/route/modules/route-configurator.vue';
const { hasAccessByCodes } = useAccess();
const statusEditable = hasAccessByCodes(['mes:pro-route:update']); // æ˜¯å¦å¯åˆ‡æ¢çŠ¶æ€
@@ -30,9 +37,38 @@
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportModalComponent,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** å¯¼å…¥å·¥è‰ºè·¯çº¿ */
function handleImport() {
  importModalApi
    .setData({
      title: $t('ui.actionTitle.import', ['工艺路线']),
      fileName: '工艺路线导入模板.xls',
      importFn: importRoute,
      templateFn: importRouteTemplate,
    })
    .open();
}
function handleCompleteImport() {
  importModalApi
    .setData({
      title: '批量导入完整工艺路线',
      fileName: '完整工艺路线导入模板.xlsx',
      importFn: importCompleteRoute,
      templateFn: importCompleteRouteTemplate,
      showUpdateSupport: false,
    })
    .open();
}
/** åˆ›å»ºå·¥è‰ºè·¯çº¿ */
@@ -57,7 +93,9 @@
): Promise<boolean | undefined> {
  try {
    await confirm(
      `确认要将"${row.name}"工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
      newStatus === CommonStatusEnum.ENABLE
        ? `确认启用“${row.name}”吗?系统将检查产出入库位置、工序产出和工序投料是否完整。`
        : `确认要将“${row.name}”工艺路线切换为【${getDictLabel(DICT_TYPE.COMMON_STATUS, newStatus)}】吗?`,
    );
  } catch {
    return false;
@@ -118,6 +156,14 @@
<template>
  <Page auto-content-height><FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Alert
      class="mb-3"
      message="路线配置顺序"
      description="基本信息 â†’ äº§å‡ºäº§å“åŠå…¥åº“仓 â†’ å·¥åºåŠå„工序产出 â†’ å·¥åºæŠ•æ–™ â†’ æ ¡éªŒå¯ç”¨"
      show-icon
      type="info"
    />
    <Grid table-title="工艺路线列表">
      <template #toolbar-tools>
        <TableAction
@@ -136,6 +182,20 @@
              auth: ['mes:pro-route:export'],
              onClick: handleExport,
            },
            {
              label: $t('ui.actionTitle.import', ['工艺路线']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleImport,
            },
            {
              label: '批量导入完整路线',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['mes:pro-route:import'],
              onClick: handleCompleteImport,
            },
          ]"
        />
      </template>
src/views/mes/process-design/route/modules/form.vue
@@ -2,28 +2,24 @@
import type { FormType } from '../data';
import type { MesProRouteApi } from '#/api/mes/pro/route';
import type { MesProRouteProductApi } from '#/api/mes/pro/route/product';
import { computed, ref } from 'vue';
import { confirm, useVbenModal } from '@vben/common-ui';
import { useVbenModal } from '@vben/common-ui';
import { Button, Divider, message, Select } from 'ant-design-vue';
import { message, TabPane, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createRoute, getRoute, updateRoute } from '#/api/mes/pro/route';
import {
  deleteRouteProduct,
  getRouteProductListByRoute,
} from '#/api/mes/pro/route/product';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import ProductForm from './product-form.vue';
import Workbench from './workbench.vue';
import ProcessList from './process-list.vue';
import ProductList from './product-list.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create'); // è¡¨å•模式
const subTab = ref('process'); // å½“前激活的子表 Tab
const formData = ref<MesProRouteApi.Route>();
const isDetail = computed(() => formType.value === 'detail'); // æ˜¯å¦æŸ¥çœ‹æ¨¡å¼
@@ -36,72 +32,6 @@
    : $t('ui.actionTitle.create', ['工艺路线']);
});
// ==================== äº§å“ç»‘定 ====================
const products = ref<MesProRouteProductApi.RouteProduct[]>([]); // å…³è”产品列表
const selectedProductItemId = ref<number>(); // å½“前选中的产品物料编号
const selectedProduct = computed(() =>
  products.value.find((item) => item.itemId === selectedProductItemId.value),
);
const productOptions = computed(() =>
  products.value.map((item) => ({ label: item.itemName, value: item.itemId })),
);
async function loadProducts() {
  const routeId = formData.value?.id;
  if (!routeId) {
    products.value = [];
    return;
  }
  products.value = (await getRouteProductListByRoute(routeId)) || [];
  if (
    !products.value.some((item) => item.itemId === selectedProductItemId.value)
  ) {
    selectedProductItemId.value = products.value[0]?.itemId;
  }
}
const [ProductFormModal, productFormModalApi] = useVbenModal({
  connectedComponent: ProductForm,
  destroyOnClose: true,
});
/** æ‰“开产品弹窗(绑定 / ç¼–辑) */
function openProductForm(row?: MesProRouteProductApi.RouteProduct) {
  if (!formData.value?.id) {
    return;
  }
  productFormModalApi
    .setData(
      row
        ? { id: row.id, routeId: formData.value.id }
        : {
            routeId: formData.value.id,
            boundItemIds: products.value
              .map((item) => item.itemId)
              .filter((id): id is number => id != null),
          },
    )
    .open();
}
/** ç§»é™¤äº§å“ï¼ˆä¼šçº§è”删除该产品的投入物料配置) */
async function handleRemoveProduct() {
  if (!selectedProduct.value?.id) {
    return;
  }
  try {
    await confirm(
      `确认将产品「${selectedProduct.value.itemName}」从该工艺路线移除?其全部投入物料配置将一并删除。`,
    );
  } catch {
    return;
  }
  await deleteRouteProduct(selectedProduct.value.id);
  message.success($t('ui.actionMessage.operationSuccess'));
  await loadProducts();
}
// ==================== åŸºæœ¬ä¿¡æ¯è¡¨å• ====================
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: { class: 'w-full' },
@@ -129,12 +59,11 @@
    const data = (await formApi.getValues()) as MesProRouteApi.Route;
    try {
      if (formType.value === 'create') {
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œè¿›å…¥ã€Œäº§å“ä¸Žå·¥åºé…ç½®ã€å·¥ä½œå°
        // æ–°å¢žæˆåŠŸåŽåˆ‡åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤ç»„æˆå·¥åºã€å…³è”äº§å“
        const id = await createRoute(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
        formType.value = 'update';
        await loadProducts();
      } else {
        await updateRoute(data);
        formData.value = { ...formData.value, ...data };
@@ -153,11 +82,10 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      products.value = [];
      selectedProductItemId.value = undefined;
      return;
    }
    formApi.setState({ schema: useFormSchema(formApi) });
    subTab.value = 'process';
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
@@ -171,7 +99,6 @@
      formData.value = await getRoute(data.id);
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
      await loadProducts();
    } finally {
      modalApi.unlock();
    }
@@ -180,49 +107,20 @@
</script>
<template>
  <Modal :title="getTitle" class="w-11/12">
  <Modal :title="getTitle" class="w-4/5">
    <Form class="mx-4" />
    <!-- ç¼–辑/详情模式下展示「产品与工序配置」工作台,新增模式下隐藏 -->
    <template v-if="formData?.id">
      <div class="mx-4 mt-4">
        <Divider class="!my-2" orientation="left">
          <span class="text-sm">产品与工序配置</span>
        </Divider>
        <div class="flex flex-wrap items-center gap-3">
          <span class="shrink-0 text-sm font-medium">生产产品</span>
          <Select
            v-model:value="selectedProductItemId"
            class="!w-64"
            allow-clear
            :disabled="isDetail"
            placeholder="请选择或绑定产品"
            :options="productOptions"
          />
          <template v-if="!isDetail">
            <Button type="primary" @click="openProductForm()"> ç»‘定产品 </Button>
            <Button
              :disabled="!selectedProduct"
              @click="openProductForm(selectedProduct)"
            >
              ç¼–辑产品
            </Button>
            <Button danger :disabled="!selectedProduct" @click="handleRemoveProduct">
              ç§»é™¤äº§å“
            </Button>
          </template>
        </div>
        <p v-if="products.length === 0" class="mt-2 text-xs text-gray-400">
          å…ˆç»‘定要生产的物料,再为每道工序配置「投入 + äº§å‡ºã€
        </p>
        <div class="mt-3">
          <Workbench
            :form-type="formType"
            :product="selectedProduct"
            :route-id="formData.id"
          />
        </div>
      </div>
    </template>
    <!-- ç¼–辑/详情模式下展示子表 Tab,新增模式下隐藏 -->
    <Tabs
      v-if="formType !== 'create' && formData?.id"
      v-model:active-key="subTab"
      class="mx-4 mt-4"
    >
      <TabPane key="process" tab="组成工序">
        <ProcessList :form-type="formType" :route-id="formData.id" />
      </TabPane>
      <TabPane key="product" tab="关联产品">
        <ProductList :form-type="formType" :route-id="formData.id" />
      </TabPane>
    </Tabs>
  </Modal>
  <ProductFormModal @success="loadProducts" />
</template>
</template>
src/views/mes/process-design/route/modules/process-form.vue
@@ -31,7 +31,7 @@
  commonConfig: {
    componentProps: { class: 'w-full' },
    formItemClass: 'col-span-1',
    labelWidth: 150,
    labelWidth: 130,
  },
  layout: 'horizontal',
  schema: [],
src/views/mes/process-design/route/modules/product-form.vue
@@ -5,7 +5,7 @@
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { Divider, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -16,17 +16,17 @@
import { $t } from '#/locales';
import { useRouteProductFormSchema } from '../data';
import ProductBomList from './product-bom-list.vue';
const emit = defineEmits(['success']);
const formData = ref<MesProRouteProductApi.RouteProduct>();
const boundItemIds = ref<number[]>([]); // å·²ç»‘定本路线的产品物料编号(新增多选时跳过)
const getTitle = computed(() =>
  formData.value?.id
    ? $t('ui.actionTitle.edit', ['工艺路线产品'])
    : $t('ui.actionTitle.create', ['工艺路线产品']),
);
/** MdItemSelect å•选 change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
/** MdItemSelect change å›žè°ƒï¼šæŠŠç‰©æ–™ç¼–码/名称/规格/单位回填到 form */
async function handleItemChange(item?: any) {
  if (!item) {
    return;
@@ -58,62 +58,22 @@
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as MesProRouteProductApi.RouteProduct & {
      itemIds?: number[];
    };
    // æäº¤è¡¨å•
    const data =
      (await formApi.getValues()) as MesProRouteProductApi.RouteProduct;
    try {
      // ç¼–辑模式:更新单个产品
      if (formData.value?.id) {
        await updateRouteProduct(data);
        await modalApi.close();
        emit('success');
        message.success($t('ui.actionMessage.operationSuccess'));
        return;
        // ç”¨æœ€æ–°è¡¨å•值同步 formData,确保产品 BOM å­è¡¨ç»‘定的 itemId ä¸Žè¡¨å•一致
        formData.value = { ...formData.value, ...data };
      } else {
        // æ–°å¢žæˆåŠŸåŽåˆ‡æ¢åˆ°ç¼–è¾‘æ¨¡å¼ï¼Œæ–¹ä¾¿ç»§ç»­ç»´æŠ¤äº§å“ BOM
        const id = await createRouteProduct(data);
        formData.value = { ...data, id };
        await formApi.setFieldValue('id', id);
      }
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ä¸€æ¬¡æ€§ç»‘定,跳过已在路线中的产品
      const itemIds = Array.isArray(data.itemIds) ? data.itemIds : [];
      if (itemIds.length === 0) {
        message.warning('请至少选择一个产品');
        return;
      }
      let created = 0;
      let skipped = 0;
      const failedIds: number[] = [];
      for (const itemId of itemIds) {
        if (itemId == null) {
          continue;
        }
        if (boundItemIds.value.includes(itemId)) {
          skipped++;
          continue;
        }
        try {
          await createRouteProduct({
            routeId: data.routeId,
            itemId,
            quantity: data.quantity,
            productionTime: data.productionTime,
            timeUnitType: data.timeUnitType,
            remark: data.remark,
          });
          created++;
        } catch {
          failedIds.push(itemId);
        }
      }
      if (skipped > 0) {
        message.warning(`已跳过 ${skipped} ä¸ªå·²ç»‘定的产品`);
      }
      if (failedIds.length > 0) {
        message.error(
          `有 ${failedIds.length} ä¸ªäº§å“ç»‘定失败(可能已被其它工艺路线占用)`,
        );
      }
      if (created > 0) {
        message.success(`成功绑定 ${created} ä¸ªäº§å“`);
        await modalApi.close();
        emit('success');
      }
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
@@ -121,42 +81,24 @@
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = undefined;
      boundItemIds.value = [];
      return;
    }
    // åŠ è½½æ•°æ®
    const data = modalApi.getData<{
      id?: number;
      routeId: number;
      boundItemIds?: number[];
    }>();
    if (!data) {
      return;
    }
    boundItemIds.value = data.boundItemIds || [];
    if (!data.id) {
      // æ–°å¢žæ¨¡å¼ï¼šå¤šé€‰äº§å“ç»‘定
      formData.value = undefined;
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, true),
      });
      await formApi.setValues({
        routeId: data.routeId,
        itemIds: [],
        quantity: 1,
        productionTime: 1,
        timeUnitType: 'MINUTE',
        remark: undefined,
      });
      await formApi.setValues({ routeId: data.routeId });
      return;
    }
    // ç¼–辑模式:加载单个产品回填
    modalApi.lock();
    try {
      formData.value = await getRouteProduct(data.id);
      await formApi.setState({
        schema: useRouteProductFormSchema(handleItemChange, false),
      });
      // è®¾ç½®åˆ° values
      await formApi.setValues(formData.value);
    } finally {
      modalApi.unlock();
@@ -166,7 +108,18 @@
</script>
<template>
  <Modal :title="getTitle" class="w-2/5">
  <Modal :title="getTitle" class="w-3/5">
    <Form class="mx-4" />
    <!-- ç¼–辑模式下展示产品 BOM å­è¡¨ï¼Œæ–°å¢žæ¨¡å¼ä¸‹éšè— -->
    <template v-if="formData?.id && formData?.itemId">
      <Divider class="!my-3" orientation="left">产品 BOM é…ç½®</Divider>
      <div class="mx-4">
        <ProductBomList
          :route-id="formData.routeId!"
          :product-id="formData.itemId"
          :product-name="formData.itemName"
        />
      </div>
    </template>
  </Modal>
</template>
</template>