tuqin
18 小时以前 adf55edef271d0392599167456ca0677f2bc5110
feat(mes): 生产计划页新增「从销售订单生成」入口

配套后端 /erp/sale-order/pending-production-list 与 /generate-mps。

在「生产管控 → 生产计划(MPS)」工具栏新增「从销售订单生成」按钮,弹窗内:
- 列出已审核且未安排生产的销售订单明细
- 关键列:订购数量、可用库存(>0 绿色、=0 红色)、建议生产
- 库存充足的行标「库存够」标签且默认不勾选,需要备货可手工勾选
- 计划数量可编辑,默认等于建议生产数量;改动后自动同步勾选状态
- 顶部实时显示待安排条数、库存充足条数、已选条数与合计计划数量
- 确认后批量生成 MPS 并刷新列表

这样车间在一个页面里就能「看销售订单 + 看库存 + 转生产」,不用再两个页面来回切。

注:本期只提交代码,未部署。
已添加1个文件
已修改2个文件
256 ■■■■■ 文件已修改
src/api/erp/sale/order/index.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/pro/mps/modules/from-sale-order.vue 205 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/sale/order/index.ts
@@ -156,3 +156,35 @@
export function importSaleOrder(file: File) {
  return requestClient.upload<ImportResult>('/erp/sale-order/import', { file });
}
/** å¾…安排生产的销售订单明细(含可用库存与建议生产数量) */
export interface PendingProductionItem {
  saleOrderId?: number;
  saleOrderNo?: string;
  saleOrderItemId?: number;
  customerId?: number;
  customerName?: string;
  productId?: number;
  productCode?: string;
  productName?: string;
  productSpecification?: string;
  productUnitName?: string;
  orderCount?: number;
  orderTime?: string;
  stockQuantity?: number;
  suggestQuantity?: number;
}
/** æŸ¥è¯¢å¾…安排生产的销售订单明细 */
export function getPendingProductionList() {
  return requestClient.get<PendingProductionItem[]>(
    '/erp/sale-order/pending-production-list',
  );
}
/** ä»Žé”€å”®è®¢å•批量生成生产计划 */
export function generateMpsFromSaleOrder(
  items: Array<{ quantity: number; saleOrderItemId: number }>,
) {
  return requestClient.post<number>('/erp/sale-order/generate-mps', { items });
}
src/views/mes/pro/mps/index.vue
@@ -23,6 +23,7 @@
import { MpsStatusEnum, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import FromSaleOrder from './modules/from-sale-order.vue';
/** MPS ç”Ÿäº§ä¸»è®¡åˆ’列表 */
defineOptions({ name: 'MesProMps' });
@@ -33,6 +34,16 @@
  connectedComponent: Form,
  destroyOnClose: true,
});
/** ä»Žé”€å”®è®¢å•生成生产计划(生产车间视角:结合库存判断是否需要生产) */
const [FromSaleOrderModal, fromSaleOrderModalApi] = useVbenModal({
  connectedComponent: FromSaleOrder,
  destroyOnClose: true,
});
function handleCreateFromSaleOrder() {
  fromSaleOrderModalApi.open();
}
const checkedIds = ref<number[]>([]);
const checkedRows = ref<MesProMpsApi.Mps[]>([]);
@@ -203,6 +214,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <FromSaleOrderModal @success="handleRefresh" />
    <Grid table-title="生产主计划(MPS)列表">
      <template #toolbar-tools>
        <TableAction
@@ -215,6 +227,13 @@
              onClick: handleCreate,
            },
            {
              label: '从销售订单生成',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['mes:pro-mps:create'],
              onClick: handleCreateFromSaleOrder,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
src/views/mes/pro/mps/modules/from-sale-order.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,205 @@
<script lang="ts" setup>
import type { TableColumnsType } from 'ant-design-vue';
import type { PendingProductionItem } from '#/api/erp/sale/order';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, InputNumber, message, Spin, Table, Tag } from 'ant-design-vue';
import {
  generateMpsFromSaleOrder,
  getPendingProductionList,
} from '#/api/erp/sale/order';
const emit = defineEmits(['success']);
interface RowItem extends PendingProductionItem {
  /** è®¡åˆ’生产数量(可编辑) */
  planQuantity?: number;
  /** æ˜¯å¦å‹¾é€‰ */
  selected?: boolean;
}
const loading = ref(false);
const submitting = ref(false);
const list = ref<RowItem[]>([]);
const columns: TableColumnsType = [
  { title: '销售订单号', dataIndex: 'saleOrderNo', width: 150, fixed: 'left' },
  { title: '客户', dataIndex: 'customerName', width: 130, ellipsis: true },
  { title: '产品编码', dataIndex: 'productCode', width: 120 },
  { title: '产品名称', dataIndex: 'productName', width: 140, ellipsis: true },
  { title: '规格', dataIndex: 'productSpecification', width: 100 },
  { title: '订购数量', dataIndex: 'orderCount', width: 100, align: 'right' },
  { title: '订单日期', dataIndex: 'orderTime', width: 160 },
  {
    title: '可用库存',
    dataIndex: 'stockQuantity',
    width: 100,
    align: 'right',
  },
  {
    title: '建议生产',
    dataIndex: 'suggestQuantity',
    width: 110,
    align: 'right',
  },
  { title: '计划数量', dataIndex: 'planQuantity', width: 130, key: 'plan' },
];
/** å·²å‹¾é€‰çš„行 */
const selectedRows = computed(() => list.value.filter((r) => r.selected));
/** åˆè®¡è®¡åˆ’数量 */
const totalPlanQuantity = computed(() =>
  selectedRows.value.reduce((sum, r) => sum + (r.planQuantity ?? 0), 0),
);
/** åº“存充足(无需生产)的行数 */
const enoughStockCount = computed(
  () => list.value.filter((r) => (r.suggestQuantity ?? 0) <= 0).length,
);
function handleSelectAll(checked: boolean) {
  list.value.forEach((row) => {
    // åº“存够的默认不勾选,但允许手工勾选(客户可能要求备货)
    row.selected = checked;
  });
}
async function loadList() {
  loading.value = true;
  try {
    const data = await getPendingProductionList();
    list.value = (data ?? []).map((item) => {
      const suggest = item.suggestQuantity ?? 0;
      return {
        ...item,
        // é»˜è®¤å‹¾é€‰éœ€è¦ç”Ÿäº§çš„(建议生产 > 0)
        selected: suggest > 0,
        planQuantity: suggest > 0 ? suggest : undefined,
      };
    });
  } finally {
    loading.value = false;
  }
}
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const items = selectedRows.value
      .filter((r) => (r.planQuantity ?? 0) > 0)
      .map((r) => ({
        saleOrderItemId: r.saleOrderItemId!,
        quantity: r.planQuantity!,
      }));
    if (items.length === 0) {
      message.warning('请至少勾选一条并填写大于 0 çš„计划数量');
      return;
    }
    submitting.value = true;
    try {
      const count = await generateMpsFromSaleOrder(items);
      emit('success');
      message.success(`已生成 ${count} æ¡ç”Ÿäº§è®¡åˆ’`);
      await modalApi.close();
    } finally {
      submitting.value = false;
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      list.value = [];
      return;
    }
    await loadList();
  },
});
</script>
<template>
  <Modal title="从销售订单生成生产计划" class="w-4/5">
    <Spin :spinning="loading || submitting">
      <div class="mx-4 flex flex-col gap-3">
        <Alert
          type="info"
          show-icon
          message="按「可用库存」判断是否需要生产:库存充足的可不安排,建议生产数量 = è®¢è´­æ•°é‡ âˆ’ å¯ç”¨åº“存。勾选后可直接修改计划数量。"
        />
        <div class="flex items-center gap-4 text-gray-600">
          <span>共 <b>{{ list.length }}</b> æ¡å¾…安排明细</span>
          <span v-if="enoughStockCount > 0" class="text-green-600">
            å…¶ä¸­ {{ enoughStockCount }} æ¡åº“存充足
          </span>
          <span>
            å·²é€‰ <b>{{ selectedRows.length }}</b> æ¡ï¼Œåˆè®¡è®¡åˆ’数量
            <b>{{ totalPlanQuantity }}</b>
          </span>
        </div>
        <Table
          :data-source="list"
          :columns="columns"
          :pagination="false"
          :row-selection="{
            selectedRowKeys: selectedRows.map((r) => r.saleOrderItemId),
            onSelect: (record: RowItem, checked: boolean) => {
              record.selected = checked;
              if (checked && !record.planQuantity) {
                record.planQuantity =
                  (record.suggestQuantity ?? 0) > 0
                    ? record.suggestQuantity
                    : undefined;
              }
            },
            onSelectAll: (checked: boolean) => handleSelectAll(checked),
          }"
          :scroll="{ y: 420, x: 1400 }"
          row-key="saleOrderItemId"
          size="small"
        >
          <template #bodyCell="{ column, record }">
            <template v-if="column.dataIndex === 'stockQuantity'">
              <span
                :class="
                  (record.stockQuantity ?? 0) > 0
                    ? 'font-semibold text-green-600'
                    : 'text-red-500'
                "
              >
                {{ record.stockQuantity ?? 0 }}
              </span>
            </template>
            <template v-else-if="column.dataIndex === 'suggestQuantity'">
              <Tag
                v-if="(record.suggestQuantity ?? 0) > 0"
                color="warning"
              >
                {{ record.suggestQuantity }}
              </Tag>
              <Tag v-else color="success">库存够</Tag>
            </template>
            <template v-else-if="column.key === 'plan'">
              <InputNumber
                v-model:value="record.planQuantity"
                :min="0"
                :precision="3"
                class="w-full"
                placeholder="填写计划数量"
                @change="
                  (val: any) => {
                    record.selected = (val ?? 0) > 0;
                  }
                "
              />
            </template>
          </template>
        </Table>
      </div>
    </Spin>
  </Modal>
</template>