xiaoyi
昨天 286619b69e4004c6bcaff4cb19a40b77b053453d
feat: 前端补齐排产约束、风险监测与招投标比价界面

## 内容
### 需求13 排产数据
- 工单表单/列表新增排产优先级、内外销分类
- 物料表单新增外协提前期
- api/mdm/item、api/mes/pro/workorder 同步类型与字段

### 需求14 自动排产
- 自动排产弹窗展示缺料校验结果

### 需求20 隐患整改
- 隐患分派新增"通知接收账号"选择,支持责任人未绑定账号的场景
- api/safety 同步

### 需求21 风险监测
- 新增 api/safety/risk-source.ts 风险源接口

### 需求22 询报比价
- 招投标详情页完善比价与中标操作

### 其他
- 售后退货详情、工单表单、BPM 流程设计器相关调整

## 验证
前端构建待验证。
已添加2个文件
已修改19个文件
520 ■■■■■ 文件已修改
src/api/bpm/model/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mdm/item/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/index.ts 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/risk-source.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/return/detail.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/data.ts 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/modules/form.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/mdm/data.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/form/index.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/form/modules/bpm-model-editor.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/form/modules/form-design.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/bpm/model/form/modules/process-design.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/dv/repair/index.vue 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/data.ts 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/task/modules/auto-schedule.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/workorder/data.ts 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/hazard/index.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/risk-source/index.vue 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/detail.vue 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm/model/index.ts
@@ -95,7 +95,7 @@
/** åˆ›å»ºæµç¨‹æ¨¡åž‹ */
export async function createModel(data: BpmModelApi.Model) {
  return requestClient.post('/bpm/model/create', data);
  return requestClient.post<string>('/bpm/model/create', data);
}
/** åˆ é™¤æµç¨‹æ¨¡åž‹ */
src/api/mdm/item/index.ts
@@ -28,6 +28,8 @@
    salesPrice?: number;
    costPrice?: number;
    safetyStock?: number;
    leadTime?: number; // é‡‡è´­æå‰æœŸï¼ˆå¤©ï¼‰
    outsourceLeadTime?: number; // å¤–协提前期(天)
    minStock?: number;
    maxStock?: number;
    isBatchManaged: boolean;
src/api/mes/pro/workorder/index.ts
@@ -29,6 +29,9 @@
    vendorCode?: string; // ä¾›åº”商编码
    batchCode?: string; // æ‰¹æ¬¡å·
    requestDate?: number; // éœ€æ±‚日期
    schedulePriority?: number; // æŽ’产优先级,数值越大越优先
    marketType?: 'DOMESTIC' | 'EXPORT'; // ç”±ä¸šåŠ¡ç¡®è®¤çš„å†…å¤–é”€åˆ†ç±»
    deliveryDeviation?: string; // æŽ’产页按需求日期和实际完成日期计算的说明
    parentId?: number; // çˆ¶è®¢å•编号
    parentCode?: string; // çˆ¶è®¢å•编码
    finishDate?: number; // å®Œæˆæ—¶é—´
src/api/safety/index.ts
@@ -423,7 +423,7 @@
export function deleteHazard(id: number) {
  return requestClient.delete('/safety/hazard/delete', { params: { id } });
}
export function assignHazard(data: { id: number; assigneeId: number; rectifyDeadline?: string; remark?: string }) {
export function assignHazard(data: { id: number; assigneeId: number; notifyUserId?: number; rectifyDeadline?: string; remark?: string }) {
  return requestClient.put('/safety/hazard/assign', data);
}
export function rectifyHazard(data: { id: number; rectifyDesc: string; rectifyPhotoUrls?: string }) {
src/api/safety/risk-source.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export interface SafetyRiskSource {
  id?: number; name: string; category: 'MAJOR' | 'HIGH_VOLTAGE' | 'OTHER'; location?: string;
  metricName: string; unit?: string; lowerLimit?: number; upperLimit?: number;
  currentValue?: number; observedAt?: string; riskStatus?: 'NORMAL' | 'ALARM' | 'UNKNOWN'; remark?: string;
}
export function getRiskSourcePage(params: PageParam & { name?: string }) { return requestClient.get<PageResult<SafetyRiskSource>>('/safety/risk-source/page', { params }); }
export function createRiskSource(data: SafetyRiskSource) { return requestClient.post<number>('/safety/risk-source/create', data); }
export function updateRiskSource(data: SafetyRiskSource) { return requestClient.put<boolean>('/safety/risk-source/update', data); }
export function deleteRiskSource(id: number) { return requestClient.delete<boolean>('/safety/risk-source/delete', { params: { id } }); }
src/views/aftersales/return/detail.vue
@@ -1,7 +1,6 @@
<script lang="ts" setup>
import type { AftersalesReturnApi } from '#/api/aftersales/return';
import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -9,13 +8,12 @@
import { Page } from '@vben/common-ui';
import { AftersaleReturnStatusEnum } from '@vben/constants';
import { Button, Card, Descriptions, message, Popconfirm, Select, Tag } from 'ant-design-vue';
import { Button, Card, Descriptions, InputNumber, message, Popconfirm, Select, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { approveReturn, createReturn, getReturn } from '#/api/aftersales/return';
import { getTicket } from '#/api/aftersales/ticket';
import { AftersalesTicketSelect } from '#/views/aftersales/ticket/components';
import { getSaleOrder } from '#/api/erp/sale/order';
defineOptions({ name: 'AftersalesReturnDetail' });
@@ -25,7 +23,7 @@
const submitting = ref(false);
const returnData = ref<AftersalesReturnApi.Return>();
const ticketData = ref<AftersalesTicketApi.Ticket>();
const saleOrderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]);
const returnUnitPrices = ref<Record<number, number | undefined>>({});
const returnId = computed(() => route.params.id as string);
const ticketIdFromQuery = computed(() => Number(route.query.ticketId || 0));
@@ -121,26 +119,18 @@
  loading.value = true;
  try {
    ticketData.value = await getTicket(ticketIdFromQuery.value);
    saleOrderItems.value = [];
    if (ticketData.value?.saleOrderId) {
      const order = await getSaleOrder(ticketData.value.saleOrderId);
      saleOrderItems.value = order.items || [];
    }
    returnUnitPrices.value = {};
  } finally {
    loading.value = false;
  }
}
async function handleTicketChange(item: Record<string, unknown> | undefined) {
  saleOrderItems.value = [];
  returnUnitPrices.value = {};
  if (!item?.id) { ticketData.value = undefined; return; }
  loading.value = true;
  try {
    ticketData.value = await getTicket(item.id as number);
    if (ticketData.value?.saleOrderId) {
      const order = await getSaleOrder(ticketData.value.saleOrderId);
      saleOrderItems.value = order.items || [];
    }
  } finally {
    loading.value = false;
  }
@@ -165,6 +155,10 @@
      message.warning('请先在售后工单中填写有效的退货物料及数量');
      return;
    }
    if (needReturnItems.value.some((item) => item.id == null || returnUnitPrices.value[item.id] == null || !Number.isFinite(returnUnitPrices.value[item.id]) || returnUnitPrices.value[item.id]! < 0)) {
      message.warning('请为每条退货物料填写经确认的非负单价');
      return;
    }
    const newId = await createReturn({
      ticketId: ticketData.value!.id!,
      customerId: ticketData.value!.customerId!,
@@ -174,8 +168,7 @@
      defectCategory: formValues.defectCategory as string | undefined,
      remark: formValues.remark as string | undefined,
      items: needReturnItems.value.map((item) => {
        const orderItem = saleOrderItems.value.find((oi) => oi.productId === item.itemId);
        const unitPrice = orderItem?.productPrice || 0;
        const unitPrice = returnUnitPrices.value[item.id!]!;
        const returnQuantity = item.returnQuantity || 0;
        return {
          ticketItemId: item.id,
@@ -253,6 +246,7 @@
                <th class="border p-2 text-left">物料名称</th>
                <th class="border p-2 text-left">批次号</th>
                <th class="border p-2 text-right">退货数量</th>
                <th class="border p-2 text-right">退货单价</th>
              </tr>
            </thead>
            <tbody>
@@ -261,6 +255,7 @@
                <td class="border p-2">{{ item.itemName }}</td>
                <td class="border p-2">{{ item.batchCode || '-' }}</td>
                <td class="border p-2 text-right">{{ item.returnQuantity || '-' }}</td>
                <td class="border p-2 text-right"><InputNumber v-if="item.id != null" v-model:value="returnUnitPrices[item.id]" :min="0" :precision="2" placeholder="请输入单价" /></td>
              </tr>
            </tbody>
          </table>
src/views/aftersales/ticket/data.ts
@@ -1,7 +1,6 @@
import type { VbenFormApi, VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import { h, markRaw } from 'vue';
@@ -12,7 +11,6 @@
import { z } from '#/adapter/form';
import CrmCustomerSelect from '#/components/crm-customer-select.vue';
import { ErpSaleOrderSelect } from '#/views/erp/sale/order/components';
import { MdItemSelect } from '#/views/mes/md/item/components';
import { WmBatchSelect } from '#/views/wls/batch/components';
import { UserSelect } from '#/views/system/user/components';
@@ -42,8 +40,6 @@
/** å·¥å•表单 */
export function useTicketFormSchema(
  formType: TicketFormType,
  formApi?: VbenFormApi,
  onSaleOrderChange?: (order: Record<string, unknown> | undefined) => void,
): VbenFormSchema[] {
  const readonly = isHeaderReadonly(formType);
  return [
@@ -73,30 +69,10 @@
      rules: 'required',
    },
    {
      fieldName: 'saleOrderId',
      label: '销售订单号',
      component: markRaw(ErpSaleOrderSelect),
      componentProps: {
        placeholder: '请选择销售订单',
        outStatus: 3,
        onChange: (item: Record<string, unknown> | undefined) => {
          if (formApi) {
            formApi.setFieldValue('saleOrderNo', item?.no ?? '');
            if (item?.customerId) {
              formApi.setFieldValue('customerId', item.customerId);
            }
          }
          onSaleOrderChange?.(item);
        },
      },
    },
    {
      fieldName: 'saleOrderNo',
      label: '销售订单号',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
      componentProps: { placeholder: '请输入关联订单号(选填)' },
    },
    {
      fieldName: 'customerId',
@@ -375,38 +351,19 @@
export function useTicketItemFormSchema(
  clientId?: number,
  salesOrderCode?: string,
  orderItems?: ErpSaleOrderApi.SaleOrderItem[],
  formApi?: VbenFormApi,
): VbenFormSchema[] {
  const hasOrderItems = orderItems && orderItems.length > 0;
  return [
    {
      fieldName: 'itemId',
      label: '物料',
      component: hasOrderItems ? 'Select' : markRaw(MdItemSelect),
      componentProps: hasOrderItems
        ? {
            placeholder: '请选择物料',
            options: orderItems!.map((oi) => ({
              label: `${oi.productName || ''} (${oi.productBarCode || oi.productId})`,
              value: oi.productId,
            })),
            showSearch: true,
            onChange: (value: number) => {
              const oi = orderItems!.find((o) => o.productId === value);
              if (oi && formApi) {
                formApi.setValues({
                  itemName: oi.productName,
                  itemCode: oi.productBarCode || '',
                  saleQuantity: oi.count,
                  batchCode: oi.batchCode || '',
                });
              }
            },
          }
        : {
            placeholder: '请选择物料',
          },
      component: markRaw(MdItemSelect),
      componentProps: {
        placeholder: '请选择物料',
        onChange: (item: { code?: string; name?: string } | undefined) => {
          formApi?.setValues({ itemCode: item?.code || '', itemName: item?.name || '' });
        },
      },
      rules: 'selectRequired',
    },
    {
@@ -419,39 +376,18 @@
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
    },
    ...(hasOrderItems
      ? [
          {
            fieldName: 'batchCode',
            label: '批次',
            component: 'Input',
            componentProps: {
              disabled: true,
              placeholder: '自动从订单带入',
            },
          },
        ]
      : [
          {
            fieldName: 'batchId',
            label: '批次',
            component: markRaw(WmBatchSelect),
            componentProps: {
              clientId,
              placeholder: '请选择批次',
              salesOrderCode,
            },
            dependencies: {
              triggerFields: ['itemId'],
              componentProps: (values: Record<string, unknown>) => ({
                clientId,
                itemId: values.itemId,
                placeholder: '请选择批次',
                salesOrderCode,
              }),
            },
          },
        ]),
    {
      fieldName: 'batchId',
      label: '批次',
      component: markRaw(WmBatchSelect),
      componentProps: { clientId, placeholder: '请选择批次', salesOrderCode },
      dependencies: {
        triggerFields: ['itemId'],
        componentProps: (values: Record<string, unknown>) => ({
          clientId, itemId: values.itemId, placeholder: '请选择批次', salesOrderCode,
        }),
      },
    },
    {
      fieldName: 'saleQuantity',
      label: '销售数量',
src/views/aftersales/ticket/modules/form.vue
@@ -2,7 +2,6 @@
import type { TicketFormType } from '../data';
import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
import type { ErpSaleOrderApi } from '#/api/erp/sale/order';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { computed, nextTick, ref } from 'vue';
@@ -24,7 +23,6 @@
  resolveTicket,
  updateTicket,
} from '#/api/aftersales/ticket';
import { getSaleOrder } from '#/api/erp/sale/order';
import { $t } from '#/locales';
import { useTicketFormSchema, useTicketItemFormSchema, useTicketItemGridColumns } from '../data';
@@ -75,17 +73,6 @@
  return $t('ui.actionTitle.create', ['售后工单']);
});
// é”€å”®è®¢å•物料过滤
const orderItems = ref<ErpSaleOrderApi.SaleOrderItem[]>([]);
function handleSaleOrderChange(item: Record<string, unknown> | undefined) {
  if (!item?.id) {
    orderItems.value = [];
    return;
  }
  orderItems.value = (item.items as ErpSaleOrderApi.SaleOrderItem[]) || [];
}
// ä¸»è¡¨å•
const [Form, formApi] = useVbenForm({
  commonConfig: { componentProps: { class: 'w-full' }, formItemClass: 'col-span-1', labelWidth: 110 },
@@ -131,11 +118,11 @@
// Modal(工单表单弹框)必须在 ItemModal ä¹‹å‰ï¼Œç¡®ä¿ connectedComponent æ³¨å…¥åˆ°æ­£ç¡®çš„弹框
const [Modal, modalApi] = useVbenModal({
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) { formData.value = undefined; items.value = []; orderItems.value = []; showClassify.value = false; return; }
    if (!isOpen) { formData.value = undefined; items.value = []; showClassify.value = false; return; }
    const data = modalApi.getData<{ formType: TicketFormType; id?: number }>();
    formType.value = data.formType;
    itemsGridApi.setGridOptions({ columns: useTicketItemGridColumns(isEdit.value) });
    formApi.setState({ schema: useTicketFormSchema(formType.value, formApi, handleSaleOrderChange) });
    formApi.setState({ schema: useTicketFormSchema(formType.value) });
    formApi.setDisabled(!isEdit.value);
    modalApi.setState({ showConfirmButton: false, showCancelButton: false });
    if (data?.id) {
@@ -152,10 +139,6 @@
            status: 'done', id: item.id,
          }));
          await formApi.setFieldValue('blobIds', blobIds);
        }
        if (formData.value?.saleOrderId) {
          const order = await getSaleOrder(formData.value.saleOrderId);
          orderItems.value = (order as Record<string, unknown>).items as ErpSaleOrderApi.SaleOrderItem[] || [];
        }
      } finally { modalApi.unlock(); }
    } else {
@@ -183,7 +166,7 @@
  onOpenChange(isOpen: boolean) {
    if (!isOpen) { editingItemIndex.value = null; return; }
    const d = itemModalApi.getData<AftersalesTicketApi.TicketItem | null>();
    itemFormApi.setState({ schema: useTicketItemFormSchema(formData.value?.customerId, formData.value?.saleOrderNo, orderItems.value, itemFormApi) });
    itemFormApi.setState({ schema: useTicketItemFormSchema(formData.value?.customerId, formData.value?.saleOrderNo, itemFormApi) });
    if (d) { itemFormApi.setValues(d as Record<string, unknown>); } else { itemFormApi.resetForm(); }
  },
});
@@ -199,6 +182,10 @@
  submitting.value = true;
  try {
    const data = (await formApi.getValues()) as Record<string, unknown>;
    if (formData.value?.saleOrderId && data.saleOrderNo !== formData.value.saleOrderNo) {
      message.warning('此工单已关联历史订单,不能直接修改订单号');
      return;
    }
    if (formData.value?.id) {
      await updateTicket({ ...formData.value, ...data, items: items.value } as AftersalesTicketApi.Ticket);
    } else {
src/views/basicData/mdm/data.ts
@@ -289,6 +289,18 @@
      },
    },
    {
      component: 'InputNumber',
      fieldName: 'leadTime',
      label: '采购提前期(天)',
      componentProps: { class: '!w-full', min: 0, precision: 0, placeholder: '请输入采购提前期' },
    },
    {
      component: 'InputNumber',
      fieldName: 'outsourceLeadTime',
      label: '外协提前期(天)',
      componentProps: { class: '!w-full', min: 0, precision: 0, placeholder: '请输入外协提前期' },
    },
    {
      component: "InputNumber",
      fieldName: "minStock",
      label: "最低库存",
src/views/bpm/model/form/index.vue
@@ -59,6 +59,10 @@
const actionType = route.params.type as string; // æ“ä½œç±»åž‹ï¼šcreate、copy、update
const currentStep = ref(-1); // æ­¥éª¤æŽ§åˆ¶ã€‚-1 ç”¨äºŽï¼Œä¸€å¼€å§‹å…¨éƒ¨ä¸å±•示等当前页面数据初始化完成
const processVisited = ref(false);
watch(currentStep, (step) => {
  if (step === 2) processVisited.value = true;
}, { flush: 'sync' });
const steps = [
  { title: '基本信息', validator: validateBasic },
  { title: '表单设计', validator: validateForm },
@@ -251,6 +255,11 @@
  // æµç¨‹è®¾è®¡æ ¡éªŒ
  try {
    if (!processDesignRef.value) {
      currentStep.value = 2;
      message.warning('请先检查流程设计');
      return false;
    }
    await validateProcess();
  } catch {
    currentStep.value = 2;
@@ -335,7 +344,7 @@
    }
    deployLoading.value = true;
    // 1.2 æ ¡éªŒæ‰€æœ‰æ­¥éª¤
    await validateAllSteps();
    if (!(await validateAllSteps())) return;
    // 2.1 æ›´æ–°è¡¨å•数据
    const modelData = {
@@ -345,8 +354,7 @@
    if (formData.value.id) {
      await updateModel(modelData);
    } else {
      const result = await createModel(modelData);
      formData.value.id = result.id;
      formData.value.id = await createModel(modelData);
    }
    // 2.3 å‘布
    await deployModel(formData.value.id);
@@ -480,7 +488,7 @@
      <Card :body-style="{ padding: '10px' }" class="mb-4">
        <div class="mt-12">
          <!-- ç¬¬ä¸€æ­¥ï¼šåŸºæœ¬ä¿¡æ¯ -->
          <div v-if="currentStep === 0" class="mx-auto w-4/6">
          <div v-if="currentStep >= 0" v-show="currentStep === 0" class="mx-auto w-4/6">
            <BasicInfo
              v-model="formData"
              :category-list="categoryList"
@@ -490,7 +498,7 @@
            />
          </div>
          <!-- ç¬¬äºŒæ­¥ï¼šè¡¨å•设计  -->
          <div v-if="currentStep === 1" class="mx-auto w-4/6">
          <div v-if="currentStep >= 0" v-show="currentStep === 1" class="mx-auto w-4/6">
            <FormDesign
              v-model="formData"
              :form-list="formList"
@@ -500,7 +508,9 @@
          <!-- ç¬¬ä¸‰æ­¥ï¼šæµç¨‹è®¾è®¡ -->
          <ProcessDesign
            v-if="currentStep === 2"
            v-if="processVisited"
            v-show="currentStep === 2"
            :active="currentStep === 2"
            v-model="formData"
            ref="processDesignRef"
          />
src/views/bpm/model/form/modules/bpm-model-editor.vue
@@ -3,7 +3,7 @@
import type { BpmModelApi } from '#/api/bpm/model';
import { inject, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue';
import { inject, nextTick, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { BpmModelFormType } from '@vben/constants';
@@ -22,7 +22,8 @@
defineOptions({ name: 'BpmModelEditor' });
defineProps<{
const props = defineProps<{
  active: boolean;
  modelId?: string;
  modelKey: string;
  modelName: string;
@@ -40,6 +41,17 @@
const modelData = inject('modelData') as Ref; // æ³¨å…¥æ¨¡åž‹æ•°æ®
const modeler = shallowRef(); // BPMN Modeler
let fitted = false;
watch(() => [props.active, modeler.value] as const, async ([active, instance]) => {
  if (!active || !instance) return;
  await nextTick();
  const canvas = instance.get('canvas');
  canvas.resized();
  if (!fitted) {
    canvas.zoom('fit-viewport', 'auto');
    fitted = true;
  }
});
const controlForm = ref({
  simulation: true,
  labelEditing: false,
src/views/bpm/model/form/modules/form-design.vue
@@ -57,19 +57,21 @@
  ],
};
/** ç›‘听表单 ID å˜åŒ–,加载表单数据 */
/** è¡¨å•类型或 ID å˜åŒ–时同步预览,避免异步请求覆盖新的选择。 */
let formRequest = 0;
watch(
  () => modelData.value.formId,
  async (newFormId) => {
    if (newFormId && modelData.value.formType === BpmModelFormType.NORMAL) {
  () => [modelData.value.formId, modelData.value.formType] as const,
  async ([newFormId, newFormType]) => {
    const request = ++formRequest;
    formPreview.value.rule = [];
    if (newFormId && newFormType === BpmModelFormType.NORMAL) {
      const data = await getForm(newFormId);
      if (request !== formRequest) return;
      setConfAndFields2(formPreview.value, data.conf, data.fields);
      // è®¾ç½®åªè¯»
      formPreview.value.rule.forEach((item) => {
        item.props = { ...item.props, disabled: true };
      });
    } else {
      formPreview.value.rule = [];
    }
  },
  { immediate: true },
src/views/bpm/model/form/modules/process-design.vue
@@ -9,6 +9,7 @@
import SimpleModelDesign from './simple-model-design.vue';
const modelData = defineModel<any>(); // åˆ›å»ºæœ¬åœ°æ•°æ®å‰¯æœ¬
defineProps<{ active: boolean }>();
const processData = inject('processData') as Ref;
const simpleDesign = ref();
@@ -61,6 +62,7 @@
        :model-id="modelData.id"
        :model-key="modelData.key"
        :model-name="modelData.name"
        :active="active"
        @success="handleDesignSuccess"
      />
    </template>
src/views/mes/dv/repair/index.vue
@@ -1,19 +1,76 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MesDvRepairApi } from '#/api/mes/dv/repair';
import type { MesCollectApi } from '#/api/mes/dv/collect';
import { onBeforeUnmount, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { MesDvRepairStatusEnum } from '@vben/constants';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message } from 'ant-design-vue';
import { Alert, Button, Descriptions, message, Modal, Table } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteRepair, exportRepair, getRepairPage } from '#/api/mes/dv/repair';
import { getCollectAlarmPage, getCollectDeviceList } from '#/api/mes/dv/collect';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const diagnosisOpen = ref(false);
const diagnosisLoading = ref(false);
const diagnosisError = ref('');
const diagnosisRow = ref<MesDvRepairApi.Repair>();
const diagnosisDevice = ref<MesCollectApi.CollectDevice>();
const diagnosisAlarms = ref<MesCollectApi.CollectAlarm[]>([]);
let diagnosisSequence = 0;
let diagnosisTimer: ReturnType<typeof setInterval> | undefined;
async function refreshDiagnosis() {
  const row = diagnosisRow.value;
  if (!diagnosisOpen.value || !row?.machineryId || diagnosisLoading.value) return;
  const sequence = ++diagnosisSequence;
  diagnosisLoading.value = true;
  diagnosisError.value = '';
  try {
    const devices = await getCollectDeviceList();
    const device = devices.find((item) => item.machineryId === row.machineryId);
    const alarms = device?.collectDeviceCode
      ? await getCollectAlarmPage({ pageNo: 1, pageSize: 20, deviceCode: device.collectDeviceCode } as any)
      : undefined;
    if (sequence !== diagnosisSequence) return;
    diagnosisDevice.value = device;
    diagnosisAlarms.value = alarms?.list || [];
  } catch {
    if (sequence === diagnosisSequence) {
      diagnosisDevice.value = undefined;
      diagnosisAlarms.value = [];
      diagnosisError.value = '设备数采或告警数据加载失败,请检查权限和数采服务。';
    }
  } finally {
    if (sequence === diagnosisSequence) diagnosisLoading.value = false;
  }
}
function openDiagnosis(row: MesDvRepairApi.Repair) {
  diagnosisSequence++;
  diagnosisLoading.value = false;
  diagnosisRow.value = row;
  diagnosisDevice.value = undefined;
  diagnosisAlarms.value = [];
  diagnosisOpen.value = true;
  void refreshDiagnosis();
  if (diagnosisTimer) clearInterval(diagnosisTimer);
  diagnosisTimer = setInterval(() => void refreshDiagnosis(), 30000);
}
function closeDiagnosis() {
  diagnosisSequence++;
  diagnosisLoading.value = false;
  if (diagnosisTimer) clearInterval(diagnosisTimer);
  diagnosisTimer = undefined;
}
onBeforeUnmount(closeDiagnosis);
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
@@ -170,9 +227,33 @@
              auth: ['mes:dv-repair:query'],
              onClick: handleDetail.bind(null, row),
            },
            {
              label: '数采诊断',
              type: 'link',
              auth: ['mes:collect:query'],
              onClick: openDiagnosis.bind(null, row),
            },
          ]"
        />
      </template>
    </Grid>
    <Modal v-model:open="diagnosisOpen" title="设备数采诊断" :footer="null" width="800px" @cancel="closeDiagnosis">
      <Alert class="mb-4" type="info" show-icon message="显示最近采集数据和告警,30 ç§’刷新一次;通信离线不等同于机械故障。" />
      <Alert v-if="diagnosisError" class="mb-4" type="error" show-icon :message="diagnosisError" />
      <template v-else-if="diagnosisDevice">
        <Descriptions bordered size="small" :column="2">
          <Descriptions.Item label="设备">{{ diagnosisDevice.machineryName || diagnosisRow?.machineryName || '-' }}</Descriptions.Item>
          <Descriptions.Item label="在线状态">{{ diagnosisDevice.online == null ? '未知' : diagnosisDevice.online ? '在线' : '离线' }}</Descriptions.Item>
          <Descriptions.Item label="运行状态">{{ diagnosisDevice.stsName || '未上报' }}</Descriptions.Item>
          <Descriptions.Item label="最近采集">{{ diagnosisDevice.lastCollectTime || '暂无数据' }}</Descriptions.Item>
          <Descriptions.Item label="未消除告警">{{ diagnosisDevice.activeAlarmCount ?? '未知' }}</Descriptions.Item>
          <Descriptions.Item label="网关原始值">{{ diagnosisDevice.dataJson || '暂无数据' }}</Descriptions.Item>
        </Descriptions>
        <Table class="mt-4" :loading="diagnosisLoading" :data-source="diagnosisAlarms" :pagination="false" row-key="id" size="small"
          :columns="[{ title: '告警时间', dataIndex: 'wmTime' }, { title: '告警内容', dataIndex: 'wmMsg' }, { title: '状态', dataIndex: 'status' }]" />
      </template>
      <Alert v-else-if="!diagnosisLoading" type="warning" show-icon message="设备未关联数采或没有对应采集数据,无法进行远程数据诊断。" />
      <Button class="mt-4" :loading="diagnosisLoading" @click="refreshDiagnosis">刷新</Button>
    </Modal>
  </Page>
</template>
src/views/mes/pro/task/data.ts
@@ -159,6 +159,22 @@
      width: 100,
    },
    {
      field: 'schedulePriority',
      title: '排产优先级',
      width: 100,
    },
    {
      field: 'marketType',
      title: '内外销',
      width: 90,
      formatter: ({ cellValue }) => cellValue === 'DOMESTIC' ? '内销' : cellValue === 'EXPORT' ? '外销' : '未设置',
    },
    {
      field: 'deliveryDeviation',
      title: '交期偏差',
      width: 130,
    },
    {
      field: 'progressPercent',
      title: '状态进度',
      width: 150,
src/views/mes/pro/task/index.vue
@@ -12,6 +12,7 @@
} from '@vben/constants';
import { Button, Card, Progress, Tabs, message } from 'ant-design-vue';
import dayjs from 'dayjs';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getGanttTaskList } from '#/api/mes/pro/task';
@@ -106,6 +107,17 @@
  const quantity = row.quantity ?? 0;
  const produced = row.quantityProduced ?? 0;
  Object.assign(row, { completionProgressQuantity: isCompletedWorkOrder(row) ? Math.max(produced, quantity) : produced });
  if (row.status === MesProWorkOrderStatusEnum.CANCELED) {
    row.deliveryDeviation = '已取消';
  } else if (!row.requestDate || !dayjs(row.requestDate).isValid()) {
    row.deliveryDeviation = '未设置交期';
  } else if (isCompletedWorkOrder(row) && (!row.finishDate || !dayjs(row.finishDate).isValid())) {
    row.deliveryDeviation = '缺少实际完成时间';
  } else {
    const measured = isCompletedWorkOrder(row) ? dayjs(row.finishDate) : dayjs();
    const days = measured.startOf('day').diff(dayjs(row.requestDate).startOf('day'), 'day');
    row.deliveryDeviation = days > 0 ? `${isCompletedWorkOrder(row) ? '延期' : '已逾期'} ${days} å¤©` : isCompletedWorkOrder(row) ? '按期完成' : '未逾期';
  }
}
const [Grid, gridApi] = useVbenVxeGrid({
src/views/mes/pro/task/modules/auto-schedule.vue
@@ -126,7 +126,7 @@
  <Modal title="一键排产" class="w-[min(96vw,1200px)]">
    <div class="space-y-5 px-1">
      <Alert type="info" show-icon message="先校验与预览,再确认排产"
        description="按交期优先及工序顺序安排工作站与设备;每天09:00–18:00,排除周末和已配置节假日,非工作时间自动顺延。已部分排产的工单只补齐剩余数量,最多30个工单、200个新任务。" />
        description="按工单 BOM å’Œå½“前可用库存检查齐套,扣除冻结、占用及启用的安全库存,不含虚拟线边库;先按排产优先级、再按交期安排设备。提交时重新检查,排产不会预占库存;父子工单的未来产出不计作现有库存。" />
      <div class="flex flex-wrap items-end gap-3">
        <label class="flex flex-col gap-2">
          <span class="font-medium">最早开工时间</span>
src/views/mes/pro/workorder/data.ts
@@ -251,6 +251,21 @@
      help: '预计交付/完成日期,用于排产、进度跟踪与交期预警。',
      rules: 'required',
    },
    {
      fieldName: 'schedulePriority',
      label: '排产优先级',
      component: 'InputNumber',
      componentProps: { min: 0, max: 100, precision: 0, disabled: headerReadonly, class: '!w-full' },
      defaultValue: 0,
      help: '数字越大越先排;相同优先级按交期排序,父工单仍需等待子工单。',
    },
    {
      fieldName: 'marketType',
      label: '市场类型',
      component: 'Select',
      componentProps: { disabled: headerReadonly, allowClear: true, options: [{ label: '内销', value: 'DOMESTIC' }, { label: '外销', value: 'EXPORT' }] },
      help: '按业务实际选择;排产优先级由上方字段单独维护。',
    },
    // {
    //   fieldName: 'feedbackRole',
    //   label: '报工角色',
src/views/safety/hazard/index.vue
@@ -38,6 +38,7 @@
  uploadSafetyFiles,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select-dropdown.vue';
import { UserSelect } from '#/views/system/user/components';
import {
  HAZARD_LEVEL_COLOR,
@@ -99,7 +100,7 @@
};
const assignOpen = ref(false);
const assignModel = reactive<{ id: number; assigneeId?: number; rectifyDeadline?: string; remark?: string }>({
const assignModel = reactive<{ id: number; assigneeId?: number; notifyUserId?: number; rectifyDeadline?: string; remark?: string }>({
  id: 0,
  assigneeId: undefined,
});
@@ -215,6 +216,7 @@
function openAssign(row: SafetyApi.Hazard) {
  assignModel.id = row.id!;
  assignModel.assigneeId = row.assigneeId;
  assignModel.notifyUserId = undefined;
  assignModel.rectifyDeadline = row.rectifyDeadline;
  assignModel.remark = '';
  assignOpen.value = true;
@@ -230,6 +232,7 @@
    await assignHazard({
      id: assignModel.id,
      assigneeId: assignModel.assigneeId,
      notifyUserId: assignModel.notifyUserId,
      rectifyDeadline: assignModel.rectifyDeadline,
      remark: assignModel.remark,
    });
@@ -498,6 +501,10 @@
        <FormItem label="整改责任人" required>
          <EmployeeSelect v-model="assignModel.assigneeId" placeholder="输入姓名或工号搜索整改责任人" />
        </FormItem>
        <FormItem label="通知接收账号">
          <UserSelect v-model="assignModel.notifyUserId" placeholder="员工未绑定账号时,请明确选择通知接收人" />
          <div class="text-xs text-gray-500">员工已绑定账号时自动通知本人;未绑定时可指定代收人,需自行确认通知送达。</div>
        </FormItem>
        <FormItem label="整改期限">
          <DatePicker v-model:value="assignModel.rectifyDeadline" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
src/views/safety/risk-source/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { SafetyRiskSource } from '#/api/safety/risk-source';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Alert, Button, Card, Form, FormItem, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag } from 'ant-design-vue';
import { createRiskSource, deleteRiskSource, getRiskSourcePage, updateRiskSource } from '#/api/safety/risk-source';
defineOptions({ name: 'SafetyRiskSource' });
const rows=ref<SafetyRiskSource[]>([]); const loading=ref(false); const saving=ref(false); const open=ref(false);
const filters=reactive<{name?:string}>({}); const paging=reactive({current:1,pageSize:10,total:0});
const blank=():SafetyRiskSource=>({name:'',category:'MAJOR',location:'',metricName:'',unit:'',lowerLimit:undefined,upperLimit:undefined,currentValue:undefined,remark:''});
const model=reactive<SafetyRiskSource>(blank());
const categories:Record<string,string>={MAJOR:'重大危险源',HIGH_VOLTAGE:'高压试验区域',OTHER:'其他'};
const states:Record<string,string>={NORMAL:'正常',ALARM:'超限',UNKNOWN:'未知'};
const columns=[{title:'危险源名称',dataIndex:'name'},{title:'类别',key:'category'},{title:'位置',dataIndex:'location'},
 {title:'监测指标',dataIndex:'metricName'},{title:'当前值',key:'value'},{title:'阈值',key:'limits'},
 {title:'数值变更时间',dataIndex:'observedAt'},{title:'状态',key:'status'},{title:'操作',key:'action',width:150}];
async function load(reset=false){if(reset)paging.current=1;loading.value=true;try{const r=await getRiskSourcePage({pageNo:paging.current,pageSize:paging.pageSize,...filters});rows.value=r.list;paging.total=r.total;}finally{loading.value=false;}}
function edit(row?:SafetyRiskSource){Object.assign(model,blank(),{id:undefined},row||{});open.value=true;}
async function save(){
 if(!model.name.trim()||!model.metricName.trim()){message.warning('请填写危险源名称和监测指标');return;}
 if(model.lowerLimit!=null&&model.upperLimit!=null&&model.lowerLimit>model.upperLimit){message.warning('下限不能大于上限');return;}
 saving.value=true;try{if(model.id)await updateRiskSource(model);else await createRiskSource(model);message.success('保存成功');open.value=false;await load();}finally{saving.value=false;}
}
async function remove(id:number){await deleteRiskSource(id);message.success('已删除');await load();}
onMounted(()=>load());
</script>
<template>
 <Page auto-content-height>
  <Card title="危险源台账">
   <Alert class="mb-4" type="info" show-icon message="人工维护当前监测值和阈值;此页面不代表现场自动采集,亦不保存历史观测序列。" />
   <Space class="mb-3"><Input v-model:value="filters.name" placeholder="危险源名称" allow-clear /><Button @click="load(true)">查询</Button><Button v-access:code="['safety:risk:create']" type="primary" @click="edit()">新增危险源</Button></Space>
   <Table :columns="columns" :data-source="rows" :loading="loading" :pagination="paging" row-key="id" :scroll="{x:1100}" @change="p=>{paging.current=p.current||1;paging.pageSize=p.pageSize||10;load();}">
    <template #bodyCell="{column,record}">
     <template v-if="column.key==='category'">{{categories[record.category]||record.category}}</template>
     <template v-else-if="column.key==='value'">{{record.currentValue??'未录入'}} {{record.unit||''}}</template>
     <template v-else-if="column.key==='limits'">{{record.lowerLimit??'不限'}} ï½ž {{record.upperLimit??'不限'}} {{record.unit||''}}</template>
     <Tag v-else-if="column.key==='status'" :color="record.riskStatus==='ALARM'?'red':record.riskStatus==='NORMAL'?'green':'default'">{{states[record.riskStatus]||'未知'}}</Tag>
     <Space v-else-if="column.key==='action'"><Button v-access:code="['safety:risk:update']" type="link" @click="edit(record as SafetyRiskSource)">编辑</Button><Popconfirm title="确认删除该危险源?" @confirm="remove(record.id)"><Button v-access:code="['safety:risk:delete']" type="link" danger>删除</Button></Popconfirm></Space>
    </template>
   </Table>
  </Card>
  <Modal v-model:open="open" :title="model.id?'编辑危险源':'新增危险源'" :confirm-loading="saving" @ok="save">
   <Form layout="vertical">
    <FormItem label="名称" required><Input v-model:value="model.name" :maxlength="120" /></FormItem>
    <FormItem label="类别" required><Select v-model:value="model.category" :options="Object.entries(categories).map(([value,label])=>({value,label}))" /></FormItem>
    <FormItem label="位置"><Input v-model:value="model.location" :maxlength="160" /></FormItem>
    <FormItem label="监测指标" required><Input v-model:value="model.metricName" :maxlength="80" /></FormItem>
    <FormItem label="单位"><Input v-model:value="model.unit" :maxlength="32" /></FormItem>
    <FormItem label="下限(可空)"><InputNumber v-model:value="model.lowerLimit" class="w-full" /></FormItem>
    <FormItem label="上限(可空)"><InputNumber v-model:value="model.upperLimit" class="w-full" /></FormItem>
    <FormItem label="当前值(人工录入)"><InputNumber v-model:value="model.currentValue" class="w-full" /></FormItem>
    <FormItem label="备注"><Input.TextArea v-model:value="model.remark" :maxlength="500" /></FormItem>
   </Form>
  </Modal>
 </Page>
</template>
src/views/srm/tender/detail.vue
@@ -142,6 +142,9 @@
// ========== æŠ¥ä»·ç®¡ç† ==========
const quoteList = ref<SrmTenderApi.QuoteVO[]>([]);
const quoteLoading = ref(false);
const compareLoading = ref(false);
const compareQuotes = ref<Record<number, SrmTenderApi.QuoteVO[]>>({});
const compareLoaded = ref(false);
const quoteFormVisible = ref(false);
const quoteForm = ref<SrmTenderApi.QuoteVO>({ bidId: undefined!, tenderMaterialId: undefined! });
const selectedBidId = ref<number>();
@@ -176,8 +179,50 @@
    else await createQuote(quoteForm.value);
    quoteFormVisible.value = false;
    await loadQuotes(selectedBidId.value);
    compareLoaded.value = false;
  } catch { /* è¯·æ±‚层已提示错误,保留表单 */ }
}
/** æŒ‰æŠ•标分别读取报价,缺报物料不参与完整报价比较。 */
async function loadComparison() {
  compareLoading.value = true;
  compareLoaded.value = false;
  try {
    await Promise.all([loadBids(), loadMaterials()]);
    const bids = bidList.value.filter((bid) => bid.id != null && bid.bidStatus !== BID_STATUS.WITHDRAWN);
    const entries = await Promise.all(bids.map(async (bid) => [bid.id!, await getQuoteList(bid.id!)] as const));
    compareQuotes.value = Object.fromEntries(entries);
    compareLoaded.value = true;
  } catch {
    compareQuotes.value = {};
    message.error('比价数据加载失败,请重试');
  } finally {
    compareLoading.value = false;
  }
}
const comparisonRows = computed(() => bidList.value
  .filter((bid) => bid.id != null && bid.bidStatus !== BID_STATUS.WITHDRAWN)
  .map((bid) => {
    const quotes = compareQuotes.value[bid.id!] || [];
    const byMaterial = new Map(quotes.map((quote) => [quote.tenderMaterialId, quote]));
    const valid = materialList.value.every((material) => material.id != null && byMaterial.get(material.id)?.quotePrice != null);
    return { bid, quotes: byMaterial, complete: materialList.value.length > 0 && valid };
  }));
const comparableLowestPrices = computed(() => {
  const result = new Map<number, number>();
  for (const material of materialList.value) {
    if (material.id == null) continue;
    const quotes = comparisonRows.value.map((row) => row.quotes.get(material.id!))
      .filter((quote): quote is SrmTenderApi.QuoteVO => quote?.quotePrice != null);
    if (quotes.length < 2 || quotes.some((quote) => quote.taxRate == null)
      || new Set(quotes.map((quote) => quote.taxRate)).size !== 1) continue;
    const prices = quotes.map((quote) => Number(quote.quotePrice)).filter(Number.isFinite);
    if (prices.length === quotes.length) result.set(material.id, Math.min(...prices));
  }
  return result;
});
function handleAddQuote() {
  quoteForm.value = { bidId: selectedBidId.value!, tenderMaterialId: undefined! };
  quoteFormVisible.value = true;
@@ -752,6 +797,41 @@
              </a-empty>
            </a-tab-pane>
            <a-tab-pane v-if="isSimpleMode" key="comparison">
              <template #tab>
                <a-space :size="4"><IconifyIcon icon="ant-design:table-outlined" />供应商比价</a-space>
              </template>
              <div class="mb-3 flex items-center gap-3">
                <a-button :loading="compareLoading" @click="loadComparison">{{ compareLoaded ? '刷新比价' : '加载比价' }}</a-button>
                <span class="text-gray-500">按物料展示报价;至少两家税率一致时标记最低单价,缺报或税率不同需人工核对,定标还需考虑商务条件。</span>
              </div>
              <div v-if="compareLoaded" class="overflow-x-auto">
                <table class="w-full border-collapse text-sm">
                  <thead><tr>
                    <th class="border p-2 text-left">供应商 / æŠ•标编号</th>
                    <th v-for="material in materialList" :key="material.id" class="border p-2 text-left">
                      {{ material.productName }}({{ material.productCode }}) / {{ material.quantity }} {{ material.unit }}
                    </th>
                    <th class="border p-2 text-left">完整性 / æŠ•标总额</th>
                  </tr></thead>
                  <tbody><tr v-for="row in comparisonRows" :key="row.bid.id">
                    <td class="border p-2">{{ row.bid.supplierName }}<br />{{ row.bid.bidNo }}</td>
                    <td v-for="material in materialList" :key="material.id" class="border p-2">
                      <template v-if="material.id != null && row.quotes.get(material.id)?.quotePrice != null">
                        å•ä»· {{ row.quotes.get(material.id)?.quotePrice }}<br />
                        <a-tag v-if="comparableLowestPrices.has(material.id) && Number(row.quotes.get(material.id)?.quotePrice) === comparableLowestPrices.get(material.id)" color="green">最低单价</a-tag>
                        ç¨Žçއ {{ row.quotes.get(material.id)?.taxRate != null ? `${row.quotes.get(material.id)?.taxRate}%` : '未填' }}<br />
                        äº¤æœŸ {{ row.quotes.get(material.id)?.deliveryCycle != null ? `${row.quotes.get(material.id)?.deliveryCycle} å¤©` : '未填' }}
                      </template>
                      <span v-else class="text-orange-600">未报价</span>
                    </td>
                    <td class="border p-2">{{ row.complete ? '已报全部物料' : '报价不完整' }}<br />总额 {{ row.bid.bidTotalAmount ?? '未维护' }}</td>
                  </tr></tbody>
                </table>
                <a-empty v-if="!comparisonRows.length" description="暂无有效投标" />
              </div>
            </a-tab-pane>
            <!-- Tab4: å¼€æ ‡ -->
            <a-tab-pane v-if="!isSimpleMode" key="bidOpen">
              <template #tab>