云
2026-10-26 8ae325c7cbe1e4a0e8adf35aff06497baef319ab
feat(mes): 完善工艺路线产出配置功能

- 添加产出物料归属选择组件,支持通用和指定产品归属
- 在销售订单中增加已取消状态显示和筛选功能
- 优化工作台界面,显示工作站待报工任务数量
- 在工单工序中添加仓库、库区、库位配置字段
- 更新百度地图密钥配置,修复HTTP访问定位问题
- 重构工序产出配置逻辑,支持更精确的物料归属管理
- 添加工艺路线配置验证,确保末道工序成品归属正确
- 优化物料选择对话框界面布局和交互体验
已添加1个文件
已修改19个文件
551 ■■■■■ 文件已修改
.env 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/sale/order/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/route/process/index.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/task/index.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/workorder/process/index.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/data.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/modules/form.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/location-map-picker.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/md/item/components/select-dialog.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/index.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/components/output-attribution.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/data.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/process-form.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/route/modules/route-configurator.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | 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/workbench/components/WorkstationSelectDialog.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/workbench/components/tabs/FeedbackTab.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -38,4 +38,4 @@
# VITE_APP_API_ENCRYPT_RESPONSE_KEY = MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAOH8IfIFxL/MR9XIg1UDv5z1fGXQI93E8wrU4iPFovL/sEt9uSgSkjyidC2O7N+m7EKtoN6b1u7cEwXSkwf3kfK0jdWLSQaNpX5YshqXCBzbDfugDaxuyYrNA4/tIMs7mzZAk0APuRXB35Dmupou7Yw7TFW/7QhQmGfzeEKULQvnAgMBAAECgYAw8LqlQGyQoPv5p3gRxEMOCfgL0JzD3XBJKztiRd35RDh40Nx1ejgjW4dPioFwGiVWd2W8cAGHLzALdcQT2KDJh+T/tsd4SPmI6uSBBK6Ff2DkO+kFFcuYvfclQQKqxma5CaZOSqhgenacmgTMFeg2eKlY3symV6JlFNu/IKU42QJBAOhxAK/Eq3e61aYQV2JSguhMR3b8NXJJRroRs/QHEanksJtl+M+2qhkC9nQVXBmBkndnkU/l2tYcHfSBlAyFySMCQQD445tgm/J2b6qMQmuUGQAYDN8FIkHjeKmha+l/fv0igWm8NDlBAem91lNDIPBUzHL1X1+pcts5bjmq99YdOnhtAkAg2J8dN3B3idpZDiQbC8fd5bGPmdI/pSUudAP27uzLEjr2qrE/QPPGdwm2m7IZFJtK7kK1hKio6u48t/bg0iL7AkEAuUUs94h+v702Fnym+jJ2CHEkXvz2US8UDs52nWrZYiM1o1y4tfSHm8H8bv8JCAa9GHyriEawfBraILOmllFdLQJAQSRZy4wmlaG48MhVXodB85X+VZ9krGXZ2TLhz7kz9iuToy53l9jTkESt6L5BfBDCVdIwcXLYgK+8KFdHN5W7HQ==
# ç™¾åº¦åœ°å›¾
VITE_BAIDU_MAP_KEY=Y2aJXiswwPxy6mwFs1z9c7U5gwX9WfUN
VITE_BAIDU_MAP_KEY=5YhFUgsoi8KMm6tafbSljgz9NNpOYP1Z
src/api/erp/sale/order/index.ts
@@ -27,7 +27,7 @@
    contractId?: number; // å…³è” CRM åˆåŒç¼–号
    contractNo?: string; // CRM åˆåŒå•号
    needProduction?: number; // æ˜¯å¦éœ€è¦ç”Ÿäº§ï¼š0-不需要,1-需要
    productionStatus?: number; // ç”Ÿäº§çŠ¶æ€ï¼š0-未完成,1-已完成
    productionStatus?: number; // ç”Ÿäº§çŠ¶æ€ï¼š0-未完成,1-已完成,2-已取消
    productionFinishTime?: Date; // ç”Ÿäº§å®Œæˆæ—¶é—´
    hasSalesNotice?: boolean; // æ˜¯å¦å·²ç”Ÿæˆå‘货通知单
    canCreateSalesNotice?: boolean; // æ˜¯å¦å¯ä»¥ç”Ÿæˆå‘货通知单
src/api/mes/pro/route/process/index.ts
@@ -10,6 +10,19 @@
    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;
@@ -32,6 +45,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/task/index.ts
@@ -185,6 +185,13 @@
  );
}
/** æŒ‰å·¥ä½œç«™æŸ¥è¯¢å¾…报工任务数(key ä¸ºå·¥ä½œç«™ç¼–号) */
export function getPendingFeedbackCountByWorkstation() {
  return requestClient.get<Record<string, number>>(
    '/mes/pro/task/pending-count-by-workstation',
  );
}
/** æ ¹æ®å·¥ä½œç«™å’Œæ•°é‡è®¡ç®—生产时长 */
export function calculateTaskDuration(workstationId: number, quantity: number) {
  return requestClient.get<{ duration: number }>(
src/api/mes/pro/workorder/process/index.ts
@@ -34,6 +34,12 @@
    outputItemName?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆç¬¬ä¸€ä¸ªï¼‰
    outputItemIds?: number[]; // äº§å‡ºäº§å“ç‰©æ–™ç¼–号列表
    outputItemNames?: string; // äº§å‡ºäº§å“ç‰©æ–™åç§°ï¼ˆé€—号拼接)
    warehouseId?: number; // æŠ¥å·¥ä»“库编号
    warehouseName?: string; // æŠ¥å·¥ä»“库名称
    locationId?: number; // æŠ¥å·¥åº“区编号
    locationName?: string; // æŠ¥å·¥åº“区名称
    areaId?: number; // æŠ¥å·¥åº“位编号
    areaName?: string; // æŠ¥å·¥åº“位名称
    bomItems?: ProcessBomItem[]; // å·¥åºæŠ•æ–™BOM明细
    remark?: string; // å¤‡æ³¨
  }
src/views/erp/sale/order/data.ts
@@ -406,6 +406,7 @@
          { label: '无需生产', value: -1 },
          { label: '未完成', value: 0 },
          { label: '已完成', value: 1 },
          { label: '已取消', value: 2 },
        ],
        placeholder: '请选择生产状态',
        allowClear: true,
src/views/erp/sale/order/index.vue
@@ -325,6 +325,7 @@
        <template v-else>
          <Tag v-if="row.productionStatus === 0" color="warning">未完成</Tag>
          <Tag v-else-if="row.productionStatus === 1" color="success">已完成</Tag>
          <Tag v-else-if="row.productionStatus === 2" color="error">已取消</Tag>
        </template>
      </template>
      <template #actions="{ row }">
src/views/erp/sale/order/modules/form.vue
@@ -161,6 +161,7 @@
      <template #productionStatus="{ modelValue }">
        <Tag v-if="modelValue === 0" color="warning">未完成</Tag>
        <Tag v-else-if="modelValue === 1" color="success">已完成</Tag>
        <Tag v-else-if="modelValue === 2" color="error">已取消</Tag>
      </template>
      <template #items>
        <ItemForm
src/views/hrm/attendance/rule/modules/location-map-picker.vue
@@ -50,6 +50,10 @@
const env = (import.meta as any).env ?? {};
const baiduMapKey = String(env.VITE_BAIDU_MAP_KEY ?? '').trim();
const hasMapConfig = computed(() => Boolean(baiduMapKey));
// æµè§ˆå™¨ä»…在安全上下文(HTTPS æˆ– localhost)放开定位能力,HTTP è®¿é—®ä¸‹æŒ‰é’®ç‚¹äº†å¿…然失败
const canUseBrowserLocation = computed(
  () => Boolean(navigator.geolocation) && window.isSecureContext,
);
function outOfChina(longitude: number, latitude: number) {
  return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
@@ -324,8 +328,14 @@
        placeholder="输入省、市、区和具体地点,例如:乌鲁木齐市新高力厂区"
        @search="handleSearch"
      />
      <a-button @click="handleCurrentLocation">定位到当前位置</a-button>
      <a-button v-if="canUseBrowserLocation" @click="handleCurrentLocation">定位到当前位置</a-button>
    </div>
    <p
      v-if="!disabled && !canUseBrowserLocation"
      class="mb-2 text-xs text-gray-500"
    >
      å½“前通过 HTTP è®¿é—®ï¼Œæµè§ˆå™¨ä¸å…è®¸ç½‘页获取位置。请用「搜索地址」或直接点击地图选点;如需网页定位请改用 HTTPS è®¿é—®ã€‚
    </p>
    <div ref="mapContainer" class="h-[360px] w-full overflow-hidden rounded border border-gray-200"></div>
    <div class="mt-2 flex flex-wrap justify-between gap-2 text-xs text-gray-500">
      <span>{{ disabled ? '打卡位置及允许范围' : '点击地图选择打卡中心点;蓝色圆圈表示允许打卡范围' }}</span>
src/views/mes/md/item/components/select-dialog.vue
@@ -124,7 +124,7 @@
    },
    gridOptions: {
      columns: useItemSelectGridColumns(true),
      height: 560,
      height: '100%',
      keepSource: true,
      checkboxConfig: {
        highlight: true,
@@ -230,29 +230,31 @@
         :destroy-on-close="true"
         @ok="handleConfirm"
         @cancel="closeModal">
    <div class="flex h-[60vh] w-full">
      <Card
        v-if="!limitedToProducts"
        class="mr-4 h-full w-1/5"
        :body-style="{ height: '100%', overflow: 'hidden' }"
    <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"
      >
        <MdItemTypeTree ref="typeTreeRef"
                        @node-click="handleItemTypeNodeClick" />
      </Card>
      <div :class="limitedToProducts ? 'w-full' : 'w-4/5'">
        <Alert
          v-if="limitedToProducts"
          class="mb-3"
          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' }"
        >
          <template #action>
            <Tag color="blue">{{ productIds.length }} ä¸ªäº§å‡º</Tag>
          </template>
        </Alert>
        <Grid :table-title="tableTitle" />
          <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,133 @@
<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 && 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
          :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
@@ -26,7 +26,7 @@
  MdProductBomSelect,
} from '#/views/mes/md/item/components';
import { RouteColorPicker } from './components';
import { RouteColorPicker, RouteOutputAttribution } from './components';
/** è¡¨å•类型 */
export type FormType = 'create' | 'detail' | 'update';
@@ -183,7 +183,7 @@
/** å·¥è‰ºè·¯çº¿å·¥åºæ˜Žç»†è¡¨å• */
export function useRouteProcessFormSchema(
  processOptions: Array<{ label: string; value: number }>,
  productIds: number[] = [],
  products: MesProRouteProductApi.RouteProduct[] = [],
): VbenFormSchema[] {
  return [
    {
@@ -220,13 +220,11 @@
      rules: 'selectRequired',
    },
    {
      fieldName: 'outputItemIds',
      fieldName: 'outputs',
      label: '本工序产出',
      component: markRaw(MdItemSelect),
      component: markRaw(RouteOutputAttribution),
      componentProps: {
        multiple: true,
        productIds,
        placeholder: '可留空;形成半成品时选择,末道工序选择全部成品',
        products,
      },
      formItemClass: 'col-span-2',
    },
@@ -318,7 +316,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: 190 },
    {
      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',
src/views/mes/pro/route/modules/process-form.vue
@@ -48,10 +48,7 @@
    value: item.id!,
  }));
  const products = routeId ? await getRouteProductListByRoute(routeId) : [];
  return useRouteProcessFormSchema(
    options,
    products.map((item) => item.itemId!).filter(Boolean),
  );
  return useRouteProcessFormSchema(options, products);
}
const [Modal, modalApi] = useVbenModal({
@@ -124,7 +121,7 @@
    <Alert
      class="mx-4 mb-4"
      message="按实际生产节点配置产出"
      description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,末道工序必须选择全部成品。如开启报工自动扣料,报工时会按工序投料配置扣减库存。"
      description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,末道工序必须选择全部成品。归属表示该产出属于哪个产品:多产品共用的半成品选“通用”,产品专属(尤其是成品)归属到自身产品。如开启报工自动扣料,报工时会按工序投料配置扣减库存。"
      show-icon
      type="info"
    />
src/views/mes/pro/route/modules/route-configurator.vue
@@ -80,6 +80,18 @@
      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)),
@@ -299,7 +311,7 @@
          <Alert
            class="mb-4"
            message="按实际生产顺序编排工序"
            description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,最后一道工序必须选择路线绑定的全部成品。"
            description="中间工序没有独立产出时可留空;形成半成品时选择对应半成品,最后一道工序必须选择路线绑定的全部成品。多产品共用的半成品归属选“通用”,成品必须归属到自身产品。"
            show-icon
            type="info"
          />
src/views/mes/pro/workorder/data.ts
@@ -743,6 +743,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 },
    },
@@ -176,6 +224,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);
            },
          };
        },
      },
    },
    {
@@ -262,16 +384,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;
@@ -287,27 +402,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/workbench/components/WorkstationSelectDialog.vue
@@ -8,6 +8,7 @@
import { Input, Modal, Spin } from 'ant-design-vue';
import { getWorkstationPage } from '#/api/mes/md/workstation';
import { getPendingFeedbackCountByWorkstation } from '#/api/mes/pro/task';
defineOptions({ name: 'WorkbenchWorkstationSelectDialog' });
@@ -19,6 +20,8 @@
const loading = ref(false);
const workstations = ref<MesMdWorkstationApi.Workstation[]>([]);
const searchKeyword = ref('');
/** å·¥ä½œç«™å¾…报工任务数,key ä¸ºå·¥ä½œç«™ç¼–号;null è¡¨ç¤ºç»Ÿè®¡åŠ è½½å¤±è´¥ï¼Œä¸å±•ç¤º */
const pendingCounts = ref<null | Record<string, number>>(null);
/** æ‰“开弹窗 */
async function open() {
@@ -31,11 +34,20 @@
async function loadWorkstations() {
  loading.value = true;
  try {
    const result = await getWorkstationPage({ pageNo: 1, pageSize: 200 });
    const [result, counts] = await Promise.all([
      getWorkstationPage({ pageNo: 1, pageSize: 200 }),
      getPendingFeedbackCountByWorkstation().catch(() => null),
    ]);
    workstations.value = result.list ?? [];
    pendingCounts.value = counts;
  } finally {
    loading.value = false;
  }
}
/** è¯¥å·¥ä½œç«™çš„待完成(待报工)任务数 */
function getPendingCount(ws: MesMdWorkstationApi.Workstation) {
  return pendingCounts.value?.[String(ws.id)] ?? 0;
}
/** è¿‡æ»¤åŽçš„工作站 */
@@ -102,7 +114,18 @@
              {{ ws.processName }}
            </span>
          </div>
          <div class="ws-card__name">{{ ws.name }}</div>
          <div class="ws-card__name-row">
            <span class="ws-card__name">{{ ws.name }}</span>
            <span
              v-if="pendingCounts"
              class="ws-card__pending"
              :class="{ 'ws-card__pending--empty': getPendingCount(ws) === 0 }"
              :title="`该工作站待报工任务数:${getPendingCount(ws)}`"
            >
              <IconifyIcon icon="lucide:list-todo" class="ws-card__pending-icon" />
              å¾…完成 {{ getPendingCount(ws) }}
            </span>
          </div>
          <div class="ws-card__info">
            <div v-if="ws.workshopName" class="ws-card__info-row">
              <IconifyIcon icon="lucide:building-2" class="ws-card__info-icon" />
@@ -209,11 +232,51 @@
  color: #fff;
}
.ws-card__name-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.ws-card__name {
  flex: 1;
  min-width: 0;
  font-size: 15px;
  font-weight: 600;
  color: #1e3a5f;
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ws-card__pending {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  gap: 4px;
  padding: 3px 9px;
  border: 1px solid #fdba74;
  border-radius: 999px;
  background: #fff7ed;
  color: #c2410c;
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.4;
  &--empty {
    border-color: #cbd5e1;
    background: rgb(255 255 255 / 55%);
    color: #64748b;
    font-weight: 600;
  }
}
.ws-card__pending-icon {
  flex-shrink: 0;
  font-size: 13px;
}
.ws-card__info {
src/views/mes/workbench/components/tabs/FeedbackTab.vue
@@ -88,6 +88,10 @@
async function getAlignedData() {
  const data = (await formApi.getValues()) as any;
  // æ–°å»ºæ—¶ä¸èƒ½æºå¸¦ä¸Šä¸€å¼ æŠ¥å·¥å•残留的 id,否则后端按主键插入会冲突
  if (formType.value === 'create') {
    delete data.id;
  }
  alignQuantity(data);
  return data;
}
@@ -131,6 +135,7 @@
    itemSpecification: task.itemSpecification,
    unitMeasureName: task.unitMeasureName,
    scheduledQuantity: task.quantity,
    reportableQuantity: task.reportableQuantity,
    checkFlag,
  });