gaoluyang
6 天以前 80ef3a1fb42f37cde2e9753317e118c50379b095
天津-潜宇塑胶
1.设备、生产、BOM、质量、产品、仓储物流模块需求更改
已添加20个文件
已修改19个文件
4725 ■■■■■ 文件已修改
docs/.analysis/build-code-docx.cjs 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/inventoryManagement/stockCheck.js 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/inventoryManagement/stockInventory.js 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/procurementManagement/procurementDemand.js 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/costAccounting.js 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/materialConsumption.js 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/pieceRateConfig.js 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productBom.js 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productionStat.js 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/qualityManagement/qualityTrace.js 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/basicData/product/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/inventoryManagement/stockCheck/components/CheckDetail.vue 383 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/inventoryManagement/stockCheck/index.vue 511 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/inventoryManagement/stockManagement/StockWarn.vue 190 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/inventoryManagement/stockManagement/index.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/procurementManagement/procurementDemand/index.vue 308 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/costAccounting/index.vue 371 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/materialConsumption/index.vue 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/outputStatistics/index.vue 350 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/pieceRateConfig/index.vue 450 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/KitCheckDialog.vue 216 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productStructure/index.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionCosting/index.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionReporting/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/productionTraceability/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/workOrder/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/productionManagement/workOrderManagement/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/finalInspection/components/formDia.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/finalInspection/index.vue 22 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/nonconformingManagement/components/formDia.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/nonconformingManagement/components/inspectionFormDia.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/processInspection/components/formDia.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/processInspection/index.vue 22 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/qualityTraceability/components/ForwardResult.vue 432 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/qualityTraceability/components/ReverseTable.vue 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/qualityTraceability/index.vue 305 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/rawMaterialInspection/components/formDia.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/qualityManagement/rawMaterialInspection/index.vue 22 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/.analysis/build-code-docx.cjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
const fs = require('fs');
const path = require('path');
const { Document, Packer, Paragraph, TextRun, HeadingLevel } = require('docx');
const ROOT = path.join(__dirname, '..', '..');
const OUT = path.join(ROOT, '系统模块源代码_纯页面.docx');
const viewDirs = [
  'src/views/basicData',
  'src/views/salesManagement',
  'src/views/procurementManagement',
  'src/views/personnelManagement',
  'src/views/inventoryManagement',
  'src/views/equipmentManagement',
];
const CODE_FONT = { ascii: 'Consolas', hAnsi: 'Consolas', eastAsia: '微软雅黑' };
const PATH_FONT = { ascii: 'Consolas', hAnsi: 'Consolas', eastAsia: '微软雅黑' };
function collectVue(relDir) {
  const abs = path.join(ROOT, relDir);
  const result = [];
  (function walk(d) {
    for (const e of fs.readdirSync(d, { withFileTypes: true })) {
      const full = path.join(d, e.name);
      if (e.isDirectory()) walk(full);
      else if (e.name.endsWith('.vue')) result.push(full);
    }
  })(abs);
  return result;
}
// åŽ»æŽ‰ <style> ... </style> æ ·å¼å—
function stripStyle(code) {
  return code.replace(/<style[\s\S]*?<\/style\s*>/g, '');
}
function codeParagraph(code) {
  const lines = code.split(/\r?\n/);
  while (lines.length && lines[lines.length - 1] === '') lines.pop();
  const runs = lines.map((line, i) => {
    const last = i === lines.length - 1;
    return new TextRun({ text: line, font: CODE_FONT, size: 16, break: last ? undefined : 1 });
  });
  return new Paragraph({ children: runs, spacing: { before: 40, after: 200 } });
}
async function main() {
  const files = [];
  for (const d of viewDirs) {
    for (const abs of collectVue(d)) {
      files.push({ abs, rel: path.relative(ROOT, abs).replace(/\\/g, '/') });
    }
  }
  files.sort((a, b) => a.rel.localeCompare(b.rel));
  const children = [];
  for (const f of files) {
    children.push(new Paragraph({
      heading: HeadingLevel.HEADING_1,
      children: [new TextRun({ text: f.rel, font: PATH_FONT })],
    }));
    children.push(codeParagraph(stripStyle(fs.readFileSync(f.abs, 'utf8'))));
  }
  const doc = new Document({
    styles: { default: { document: { run: { font: CODE_FONT, size: 16 } } } },
    sections: [{ properties: {}, children }],
  });
  const buf = await Packer.toBuffer(doc);
  fs.writeFileSync(OUT, buf);
  console.log('已生成:', OUT);
  console.log('文件数:', files.length);
  console.log('大小:', (buf.length / 1024).toFixed(0), 'KB');
}
main().catch((e) => { console.error(e); process.exit(1); });
src/api/inventoryManagement/stockCheck.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
// åº“存盘点接口
import request from "@/utils/request";
// å‘起盘点
export function start(data) {
  return request({
    url: "/stockCheck/start",
    method: "post",
    data: data,
  });
}
// ç›˜ç‚¹å•分页
export function listPage(query) {
  return request({
    url: "/stockCheck/listPage",
    method: "get",
    params: query,
  });
}
// ç›˜ç‚¹å•详情
export function getById(id) {
  return request({
    url: "/stockCheck/" + id,
    method: "get",
  });
}
// ç›˜ç‚¹æ˜Žç»†åˆ†é¡µ
export function detailPage(query) {
  return request({
    url: "/stockCheck/detailPage",
    method: "get",
    params: query,
  });
}
// å½•入实盘
export function entry(data) {
  return request({
    url: "/stockCheck/entry",
    method: "put",
    data: data,
  });
}
// å®Œæˆç›˜ç‚¹
export function finish(data) {
  return request({
    url: "/stockCheck/finish",
    method: "post",
    data: data,
  });
}
// å–消盘点
export function cancel(data) {
  return request({
    url: "/stockCheck/cancel",
    method: "post",
    data: data,
  });
}
// åˆ é™¤ç›˜ç‚¹å•(body ä¸º id æ•°ç»„,单个删除传 [id])
export function remove(ids) {
  return request({
    url: "/stockCheck/delete",
    method: "delete",
    data: ids,
  });
}
// å·®å¼‚分析列表
export function diffList(query) {
  return request({
    url: "/stockCheck/diffList",
    method: "get",
    params: query,
  });
}
// å·®å¼‚分析导出
export function diffListExport(query) {
  return request({
    url: "/stockCheck/diffList/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
// ç›˜ç‚¹æ˜Žç»†å¯¼å‡º
export function detailExport(query) {
  return request({
    url: "/stockCheck/detailExport",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
// ç›˜ç‚¹å•导出
export function exportList(query) {
  return request({
    url: "/stockCheck/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
src/api/inventoryManagement/stockInventory.js
@@ -103,3 +103,22 @@
    });
};
// åº“存预警分页
export const warnPage = (params) => {
    return request({
        url: "/stockInventory/warnPage",
        method: "get",
        params,
    });
};
// åº“存预警导出
export const warnExport = (params) => {
    return request({
        url: "/stockInventory/warnExport",
        method: "get",
        params,
        responseType: "blob",
    });
};
src/api/procurementManagement/procurementDemand.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
// é‡‡è´­éœ€æ±‚接口
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢
export function listPage(query) {
  return request({
    url: "/procurementDemand/listPage",
    method: "get",
    params: query,
  });
}
// æ‰¹é‡çŠ¶æ€å˜æ›´
export function changeStatus(ids, status) {
  return request({
    url: "/procurementDemand/changeStatus",
    method: "put",
    params: {
      ids: Array.isArray(ids) ? ids.join(",") : ids,
      status,
    },
  });
}
// åˆ é™¤
export function del(ids) {
  return request({
    url: "/procurementDemand/del",
    method: "delete",
    data: ids,
  });
}
// å¯¼å‡º
export function exportDemand(query) {
  return request({
    url: "/procurementDemand/export",
    method: "post",
    params: query,
    responseType: "blob",
  });
}
src/api/productionManagement/costAccounting.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
// æˆæœ¬æ ¸ç®—分析接口
import request from "@/utils/request";
// 1. æˆæœ¬æ ¸ç®—明细
export function costStat(query) {
  return request({
    url: "/costAccounting/stat",
    method: "get",
    params: query,
  });
}
// 2. æˆæœ¬æž„成汇总
export function costSummary(query) {
  return request({
    url: "/costAccounting/summary",
    method: "get",
    params: query,
  });
}
// 3. ç¼ºä»·ç‰©æ–™æ¸…单
export function missingPrice(query) {
  return request({
    url: "/costAccounting/missingPrice",
    method: "get",
    params: query,
  });
}
// å¯¼å‡ºï¼ˆå‡ä¸º GET + blob)
export function costStatExport(query) {
  return request({
    url: "/costAccounting/stat/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
export function costSummaryExport(query) {
  return request({
    url: "/costAccounting/summary/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
export function missingPriceExport(query) {
  return request({
    url: "/costAccounting/missingPrice/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
src/api/productionManagement/materialConsumption.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
// ææ–™æ¶ˆè€—核算接口
import request from "@/utils/request";
// æ ¸ç®—列表
export function stat(query) {
  return request({
    url: "/materialConsumption/stat",
    method: "get",
    params: query,
  });
}
// æ ¸ç®—导出
export function statExport(query) {
  return request({
    url: "/materialConsumption/stat/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
src/api/productionManagement/pieceRateConfig.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
// è®¡ä»¶å•价配置接口
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢
export function listPage(query) {
  return request({
    url: "/pieceRateConfig/listPage",
    method: "get",
    params: query,
  });
}
// æ–°å¢ž
export function add(data) {
  return request({
    url: "/pieceRateConfig/add",
    method: "post",
    data: data,
  });
}
// ç¼–辑
export function update(data) {
  return request({
    url: "/pieceRateConfig/update",
    method: "put",
    data: data,
  });
}
// å¯ç”¨/停用
export function changeStatus(data) {
  return request({
    url: "/pieceRateConfig/changeStatus",
    method: "put",
    data: data,
  });
}
// æ‰¹é‡åˆ é™¤
export function batchDelete(ids) {
  return request({
    url: "/pieceRateConfig/batchDelete",
    method: "delete",
    data: ids,
  });
}
// å¯¼å‡º
export function exportConfig(query) {
  return request({
    url: "/pieceRateConfig/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
src/api/productionManagement/productBom.js
@@ -72,3 +72,40 @@
    responseType: "blob",
  });
}
// BOM启用/停用
export function changeStatus(data) {
  return request({
    url: "/technologyBom/changeStatus",
    method: "put",
    data: data,
  });
}
// BOM齐套分析
export function kitCheck(data) {
  return request({
    url: "/technologyBom/kitCheck",
    method: "post",
    data: data,
  });
}
// é½å¥—分析导出
export function kitCheckExport(data) {
  return request({
    url: "/technologyBom/kitCheck/export",
    method: "post",
    data: data,
    responseType: "blob",
  });
}
// ç¼ºå£ç”Ÿæˆé‡‡è´­éœ€æ±‚
export function generateDemand(data) {
  return request({
    url: "/technologyBom/kitCheck/generateDemand",
    method: "post",
    data: data,
  });
}
src/api/productionManagement/productionStat.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
// ç”Ÿäº§ç»Ÿè®¡æŽ¥å£ï¼ˆè®¡ä»¶å·¥èµ„汇总 + äº§é‡ç»Ÿè®¡ï¼‰
import request from "@/utils/request";
// è®¡ä»¶å·¥èµ„汇总
export function wageSummary(query) {
  return request({
    url: "/productionProductMain/wageSummary",
    method: "get",
    params: query,
  });
}
// è®¡ä»¶å·¥èµ„汇总导出
export function wageSummaryExport(query) {
  return request({
    url: "/productionProductMain/wageSummary/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
// äº§é‡ç»Ÿè®¡
export function outputStat(query) {
  return request({
    url: "/productionProductMain/outputStat",
    method: "get",
    params: query,
  });
}
// äº§é‡ç»Ÿè®¡å¯¼å‡º
export function outputStatExport(query) {
  return request({
    url: "/productionProductMain/outputStat/export",
    method: "get",
    params: query,
    responseType: "blob",
  });
}
src/api/qualityManagement/qualityTrace.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
import request from '@/utils/request'
// æ­£å‘追溯:成品批号 -> å…¨é“¾è·¯
export function forwardTrace(batchNo) {
    return request({
        url: '/quality/trace/forward',
        method: 'get',
        params: { batchNo },
    })
}
// åå‘追溯:原料批号 -> å—影响成品
export function reverseTrace(batchNo) {
    return request({
        url: '/quality/trace/reverse',
        method: 'get',
        params: { batchNo },
    })
}
// æ‰¹å·å€™é€‰ï¼ˆæœç´¢ä¸‹æ‹‰ï¼‰ï¼Œtype: finished | material
export function batchOptions(type, keyword, limit = 50) {
    return request({
        url: '/quality/trace/batchOptions',
        method: 'get',
        params: { type, keyword, limit },
    })
}
// æ­£å‘追溯导出
export function forwardExport(batchNo) {
    return request({
        url: '/quality/trace/forward/export',
        method: 'get',
        params: { batchNo },
        responseType: 'blob',
    })
}
// åå‘追溯导出
export function reverseExport(batchNo) {
    return request({
        url: '/quality/trace/reverse/export',
        method: 'get',
        params: { batchNo },
        responseType: 'blob',
    })
}
src/views/basicData/product/index.vue
@@ -164,6 +164,17 @@
            </el-form-item>
          </el-col>
        </el-row>
        <el-row>
          <el-col :span="24">
            <el-form-item label="条形码:"
                          prop="barcode">
              <el-input v-model="modelForm.barcode"
                        placeholder="请输入条形码(选填)"
                        clearable
                        @keydown.enter.prevent />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
@@ -305,6 +316,11 @@
      prop: "unit",
    },
    {
      label: "条形码",
      prop: "barcode",
      minWidth: 140,
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
@@ -342,6 +358,7 @@
      model: "",
      unit: "",
      productCode: "",
      barcode: "",
    },
    modelRules: {
      model: [{ required: true, message: "请输入", trigger: "blur" }],
@@ -420,6 +437,7 @@
    modelForm.value.model = "";
    modelForm.value.unit = "";
    modelForm.value.productCode = "";
    modelForm.value.barcode = "";
    modelForm.value.id = "";
    if (type === "edit") {
      modelForm.value = { ...data };
src/views/inventoryManagement/stockCheck/components/CheckDetail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,383 @@
<template>
  <div class="check-detail">
    <div class="detail-header">
      <span class="detail-title">盘点详情</span>
      <el-button link
                 type="primary"
                 @click="handleBack">返回列表</el-button>
    </div>
    <el-descriptions :column="3"
                     border
                     style="margin-bottom: 16px">
      <el-descriptions-item label="盘点单号">
        {{ main.checkNo || "-" }}
      </el-descriptions-item>
      <el-descriptions-item label="盘点名称">
        {{ main.checkName || "-" }}
      </el-descriptions-item>
      <el-descriptions-item label="盘点日期">
        {{ main.checkDate || "-" }}
      </el-descriptions-item>
      <el-descriptions-item label="盘点人">
        {{ main.checker || "-" }}
      </el-descriptions-item>
      <el-descriptions-item label="状态">
        <el-tag :type="statusTypeMap[mainStatus] || 'info'">
          {{ statusTextMap[mainStatus] || "未知" }}
        </el-tag>
      </el-descriptions-item>
      <el-descriptions-item label="种类数/差异数">
        {{ main.totalKinds ?? 0 }} / {{ main.diffKinds ?? 0 }}
      </el-descriptions-item>
      <el-descriptions-item label="备注"
                            :span="3">
        {{ main.remark || "-" }}
      </el-descriptions-item>
    </el-descriptions>
    <div class="detail-toolbar">
      <div>
        <span style="margin-right: 8px">只看差异</span>
        <el-switch v-model="onlyDiff"
                   @change="handleQuery" />
      </div>
      <div>
        <el-button type="primary"
                   :disabled="readOnly"
                   @click="saveEntry">保存录入</el-button>
        <el-button type="success"
                   :disabled="readOnly"
                   @click="finishCheck">完成盘点</el-button>
        <el-button @click="openDiffDialog">差异分析</el-button>
        <el-button @click="handleExportDetail">导出明细</el-button>
      </div>
    </div>
    <PIMTable rowKey="id"
              :column="detailColumn"
              :tableData="tableData"
              :page="page"
              :tableLoading="tableLoading"
              @pagination="pagination">
      <template #actualQty="{ row }">
        <el-input-number v-if="!readOnly"
                         v-model="row.actualQty"
                         :min="0"
                         :precision="4"
                         :controls="false"
                         size="small"
                         style="width: 100%" />
        <span v-else>{{ row.actualQty ?? "-" }}</span>
      </template>
      <template #diffQty="{ row }">
        <span :style="{ color: diffColor(row) }">{{ diffText(row) }}</span>
      </template>
      <template #adjusted="{ row }">
        <el-tag :type="row.adjusted == 1 ? 'success' : 'info'">
          {{ row.adjusted == 1 ? "已调账" : "未调账" }}
        </el-tag>
      </template>
      <template #remark="{ row }">
        <el-input v-if="!readOnly"
                  v-model="row.remark"
                  size="small"
                  placeholder="备注" />
        <span v-else>{{ row.remark || "-" }}</span>
      </template>
    </PIMTable>
    <el-dialog v-model="diffDialogVisible"
               title="差异分析"
               width="90%">
      <div style="text-align: right; margin-bottom: 10px">
        <el-button type="warning"
                   plain
                   icon="Download"
                   :loading="diffExportLoading"
                   @click="handleExportDiff">导出差异</el-button>
      </div>
      <el-table :data="diffData"
                border
                height="420">
        <el-table-column type="index"
                         label="序号"
                         width="60"
                         align="center" />
        <el-table-column label="产品名称"
                         prop="productName"
                         show-overflow-tooltip />
        <el-table-column label="规格型号"
                         prop="productModel"
                         show-overflow-tooltip />
        <el-table-column label="单位"
                         prop="unit"
                         width="80" />
        <el-table-column label="批号"
                         prop="batchNo"
                         show-overflow-tooltip />
        <el-table-column label="账面数量"
                         prop="bookQty"
                         width="120" />
        <el-table-column label="实盘数量"
                         prop="actualQty"
                         width="120" />
        <el-table-column label="差异数量"
                         prop="diffQty"
                         width="120" />
        <el-table-column label="已调账"
                         width="100"
                         align="center">
          <template #default="scope">
            <el-tag :type="scope.row.adjusted == 1 ? 'success' : 'info'">
              {{ scope.row.adjusted == 1 ? "已调账" : "未调账" }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column label="备注"
                         prop="remark"
                         show-overflow-tooltip />
      </el-table>
    </el-dialog>
  </div>
</template>
<script setup>
  import { ref, reactive, computed, getCurrentInstance, onMounted } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import {
    getById,
    detailPage,
    entry,
    finish,
    diffList,
    diffListExport,
    detailExport,
  } from "@/api/inventoryManagement/stockCheck.js";
  const props = defineProps({
    mainId: {
      type: [Number, String],
      default: "",
    },
  });
  const emit = defineEmits(["back"]);
  const { proxy } = getCurrentInstance();
  const statusTextMap = { 0: "盘点中", 1: "已完成", 2: "已取消" };
  const statusTypeMap = { 0: "warning", 1: "success", 2: "info" };
  const main = ref({});
  const onlyDiff = ref(false);
  const mainStatus = computed(() => main.value.status ?? main.value.checkStatus);
  const readOnly = computed(() => mainStatus.value != 0);
  const detailColumn = ref([
    { label: "产品名称", prop: "productName", minWidth: 140 },
    { label: "规格型号", prop: "productModel", minWidth: 140 },
    { label: "单位", prop: "unit", width: 80 },
    { label: "批号", prop: "batchNo", minWidth: 120 },
    { label: "账面数量", prop: "bookQty", width: 110 },
    { label: "实盘数量", prop: "actualQty", width: 140, dataType: "slot", slot: "actualQty" },
    { label: "差异", prop: "diffQty", width: 110, dataType: "slot", slot: "diffQty" },
    { label: "已调账", prop: "adjusted", width: 100, dataType: "slot", slot: "adjusted" },
    { label: "备注", prop: "remark", minWidth: 140, dataType: "slot", slot: "remark" },
  ]);
  const tableData = ref([]);
  const tableLoading = ref(false);
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  const diffDialogVisible = ref(false);
  const diffData = ref([]);
  const diffExportLoading = ref(false);
  const diffColor = row => {
    const diff = diffValue(row);
    if (diff === null) return "";
    if (diff > 0) return "#67C23A";
    if (diff < 0) return "#F56C6C";
    return "";
  };
  const diffValue = row => {
    const actual = row.actualQty;
    const book = row.bookQty;
    if (actual === null || actual === undefined || actual === "") return null;
    return Number(actual) - Number(book ?? 0);
  };
  const diffText = row => {
    const diff = diffValue(row);
    return diff === null ? "-" : diff;
  };
  const loadMain = () => {
    if (!props.mainId) return;
    getById(props.mainId).then(res => {
      main.value = res?.data || {};
    });
  };
  const loadDetail = () => {
    tableLoading.value = true;
    detailPage({
      mainId: props.mainId,
      onlyDiff: onlyDiff.value,
      current: page.current,
      size: page.size,
    })
      .then(res => {
        tableData.value = res?.data?.records || [];
        page.total = res?.data?.total || 0;
      })
      .catch(() => {})
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const handleQuery = () => {
    page.current = 1;
    loadDetail();
  };
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    loadDetail();
  };
  const saveEntry = () => {
    const rows = tableData.value
      .filter(item => item.actualQty !== null && item.actualQty !== undefined)
      .map(item => ({
        id: item.id,
        actualQty: item.actualQty,
        remark: item.remark,
      }));
    if (rows.length === 0) {
      proxy.$modal.msgWarning("请先录入实盘数量");
      return;
    }
    entry(rows)
      .then(() => {
        proxy.$modal.msgSuccess("保存成功");
        loadMain();
        loadDetail();
      })
      .catch(() => {});
  };
  const finishCheck = () => {
    ElMessageBox.confirm(
      "差异将生成调账单据,审核通过后账面生效,确认完成盘点?",
      "提示",
      { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
    )
      .then(() => {
        finish({ id: props.mainId })
          .then(res => {
            proxy.$modal.msgSuccess(res?.data || "操作成功");
            loadMain();
            loadDetail();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  const openDiffDialog = () => {
    diffDialogVisible.value = true;
    diffList({ mainId: props.mainId }).then(res => {
      diffData.value = res?.data || [];
    });
  };
  const exportBlob = async (requestFn, query, filename) => {
    const blobData = await requestFn(query);
    if (blobValidate(blobData)) {
      if (blobData.size === 0) {
        ElMessage.warning("暂无数据可导出");
        return;
      }
      const blob = new Blob([blobData], {
        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      });
      saveAs(blob, filename);
      ElMessage.success("导出成功");
    } else {
      const resText = await blobData.text();
      let rspObj = {};
      try {
        rspObj = JSON.parse(resText);
      } catch (error) {
        rspObj = {};
      }
      ElMessage.error(rspObj.msg || "导出失败");
    }
  };
  const handleExportDetail = async () => {
    try {
      await exportBlob(
        detailExport,
        { mainId: props.mainId },
        `盘点明细_${main.value.checkNo || props.mainId}.xlsx`
      );
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    }
  };
  const handleExportDiff = async () => {
    diffExportLoading.value = true;
    try {
      await exportBlob(
        diffListExport,
        { mainId: props.mainId },
        `盘点差异_${main.value.checkNo || props.mainId}.xlsx`
      );
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      diffExportLoading.value = false;
    }
  };
  const handleBack = () => {
    emit("back");
  };
  onMounted(() => {
    loadMain();
    loadDetail();
  });
</script>
<style scoped lang="scss">
  .detail-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 12px;
  }
  .detail-title {
    font-size: 16px;
    font-weight: 600;
  }
  .detail-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 12px;
  }
</style>
src/views/inventoryManagement/stockCheck/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,511 @@
<template>
  <div class="app-container">
    <template v-if="viewMode === 'list'">
      <PageHeader content="库存盘点" />
      <div class="search_form">
        <el-form :model="queryParams"
                 inline
                 style="margin-bottom: 0;">
          <el-form-item label="盘点单号:">
            <el-input v-model="queryParams.checkNo"
                      placeholder="请输入"
                      clearable
                      style="width: 200px"
                      @keyup.enter="handleQuery" />
          </el-form-item>
          <el-form-item label="盘点名称:">
            <el-input v-model="queryParams.checkName"
                      placeholder="请输入"
                      clearable
                      style="width: 200px"
                      @keyup.enter="handleQuery" />
          </el-form-item>
          <el-form-item label="状态:">
            <el-select v-model="queryParams.status"
                       placeholder="全部"
                       clearable
                       style="width: 160px"
                       @change="handleQuery">
              <el-option label="盘点中"
                         :value="0" />
              <el-option label="已完成"
                         :value="1" />
              <el-option label="已取消"
                         :value="2" />
            </el-select>
          </el-form-item>
          <el-form-item label="盘点时间:">
            <el-date-picker v-model="dateRange"
                            type="datetimerange"
                            range-separator="至"
                            start-placeholder="开始时间"
                            end-placeholder="结束时间"
                            value-format="YYYY-MM-DD HH:mm:ss"
                            @change="handleQuery" />
          </el-form-item>
          <el-form-item>
            <el-button type="primary"
                       @click="handleQuery">查询</el-button>
            <el-button @click="resetQuery">重置</el-button>
          </el-form-item>
        </el-form>
      </div>
      <div class="mb20"
           style="text-align: right;">
        <el-button type="primary"
                   @click="openStartDialog">发起盘点</el-button>
        <el-button type="danger"
                   plain
                   :disabled="selectedRows.length === 0"
                   @click="handleBatchDelete">批量删除</el-button>
        <el-button type="warning"
                   plain
                   icon="Download"
                   :loading="exportLoading"
                   @click="handleExport">导出</el-button>
      </div>
      <div class="table_list">
        <PIMTable rowKey="id"
                  :column="tableColumn"
                  :tableData="tableData"
                  :page="page"
                  :isSelection="true"
                  :tableLoading="tableLoading"
                  @selection-change="handleSelectionChange"
                  @pagination="pagination">
          <template #status="{ row }">
            <el-tag :type="statusTypeMap[getStatus(row)] || 'info'">
              {{ statusTextMap[getStatus(row)] || "未知" }}
            </el-tag>
          </template>
        </PIMTable>
      </div>
    </template>
    <CheckDetail v-else
                 :main-id="currentId"
                 @back="backToList" />
    <el-dialog v-model="startDialogVisible"
               title="发起盘点"
               width="520px">
      <el-form ref="startFormRef"
               :model="startForm"
               :rules="startRules"
               label-width="100px">
        <el-form-item label="盘点名称:"
                      prop="checkName">
          <el-input v-model="startForm.checkName"
                    placeholder="请输入盘点名称"
                    clearable />
        </el-form-item>
        <el-form-item label="盘点日期:">
          <el-date-picker v-model="startForm.checkDate"
                          type="date"
                          placeholder="请选择日期"
                          value-format="YYYY-MM-DD"
                          style="width: 100%" />
        </el-form-item>
        <el-form-item label="盘点人:">
          <el-input v-model="startForm.checker"
                    placeholder="请输入盘点人"
                    clearable />
        </el-form-item>
        <el-form-item label="规格范围:">
          <el-select v-model="startForm.productModelIds"
                     multiple
                     filterable
                     clearable
                     placeholder="留空表示全部库存"
                     style="width: 100%">
            <el-option v-for="item in modelOptions"
                       :key="item.id"
                       :label="item.productName ? item.productName + ' ' + item.model : item.model"
                       :value="item.id" />
          </el-select>
          <el-button link
                     type="primary"
                     @click="openProductSelect"
                     style="margin-top: 6px">
            é€‰æ‹©äº§å“è§„æ ¼
          </el-button>
        </el-form-item>
        <el-form-item label="备注:">
          <el-input v-model="startForm.remark"
                    type="textarea"
                    :rows="2"
                    placeholder="请输入备注"
                    clearable />
        </el-form-item>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary"
                     @click="submitStart">确认</el-button>
          <el-button @click="startDialogVisible = false">取消</el-button>
        </div>
      </template>
    </el-dialog>
    <ProductSelectDialog v-model="productSelectVisible"
                         @confirm="handleProductSelected" />
  </div>
</template>
<script setup>
  import { ref, reactive, getCurrentInstance, onMounted } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import {
    start,
    listPage,
    finish,
    cancel,
    remove,
    exportList,
  } from "@/api/inventoryManagement/stockCheck.js";
  import CheckDetail from "./components/CheckDetail.vue";
  import ProductSelectDialog from "@/views/basicData/product/ProductSelectDialog.vue";
  const { proxy } = getCurrentInstance();
  const statusTextMap = { 0: "盘点中", 1: "已完成", 2: "已取消" };
  const statusTypeMap = { 0: "warning", 1: "success", 2: "info" };
  const getStatus = row => row.status ?? row.checkStatus;
  const tableColumn = ref([
    { label: "盘点单号", prop: "checkNo", minWidth: 150 },
    { label: "盘点名称", prop: "checkName", minWidth: 140 },
    { label: "盘点日期", prop: "checkDate", width: 120 },
    { label: "盘点人", prop: "checker", width: 100 },
    {
      label: "状态",
      prop: "status",
      dataType: "slot",
      slot: "status",
      width: 100,
    },
    { label: "盘点种类数", prop: "totalKinds", width: 110 },
    { label: "差异种类数", prop: "diffKinds", width: 110 },
    { label: "备注", prop: "remark", minWidth: 140 },
    { label: "创建时间", prop: "createTime", width: 170 },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 300,
      operation: [
        {
          name: "继续盘点",
          type: "text",
          showHide: row => getStatus(row) == 0,
          clickFun: row => {
            toDetail(row);
          },
        },
        {
          name: "完成",
          type: "text",
          color: "#67C23A",
          showHide: row => getStatus(row) == 0,
          clickFun: row => {
            handleFinish(row);
          },
        },
        {
          name: "取消",
          type: "text",
          color: "#909399",
          showHide: row => getStatus(row) == 0,
          clickFun: row => {
            handleCancel(row);
          },
        },
        {
          name: "详情",
          type: "text",
          showHide: row => getStatus(row) != 0,
          clickFun: row => {
            toDetail(row);
          },
        },
        {
          name: "删除",
          type: "text",
          clickFun: row => {
            handleDelete(row);
          },
        },
      ],
    },
  ]);
  const tableData = ref([]);
  const selectedRows = ref([]);
  const tableLoading = ref(false);
  const exportLoading = ref(false);
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  const queryParams = reactive({
    checkNo: "",
    checkName: "",
    status: "",
  });
  const dateRange = ref([]);
  const viewMode = ref("list");
  const currentId = ref("");
  const startDialogVisible = ref(false);
  const startFormRef = ref(null);
  const productSelectVisible = ref(false);
  const modelOptions = ref([]);
  const startForm = reactive({
    checkName: "",
    checkDate: "",
    checker: "",
    remark: "",
    productModelIds: [],
  });
  const startRules = {
    checkName: [{ required: true, message: "请输入盘点名称", trigger: "blur" }],
  };
  const buildQuery = () => ({
    checkNo: queryParams.checkNo || undefined,
    checkName: queryParams.checkName || undefined,
    status:
      queryParams.status === "" ||
      queryParams.status === null ||
      queryParams.status === undefined
        ? undefined
        : queryParams.status,
    beginTime: dateRange.value?.[0] || undefined,
    endTime: dateRange.value?.[1] || undefined,
  });
  const handleSelectionChange = selection => {
    selectedRows.value = selection;
  };
  const getList = () => {
    tableLoading.value = true;
    listPage({
      pageNum: page.current,
      pageSize: page.size,
      ...buildQuery(),
    })
      .then(res => {
        tableData.value = res?.data?.records || [];
        selectedRows.value = [];
        page.total = res?.data?.total || 0;
      })
      .catch(() => {})
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const handleQuery = () => {
    page.current = 1;
    getList();
  };
  const resetQuery = () => {
    queryParams.checkNo = "";
    queryParams.checkName = "";
    queryParams.status = "";
    dateRange.value = [];
    handleQuery();
  };
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
  };
  const toDetail = row => {
    currentId.value = row.id;
    viewMode.value = "detail";
  };
  const backToList = () => {
    viewMode.value = "list";
    getList();
  };
  // å‘起盘点
  const openStartDialog = () => {
    startForm.checkName = "";
    startForm.checkDate = "";
    startForm.checker = "";
    startForm.remark = "";
    startForm.productModelIds = [];
    modelOptions.value = [];
    startDialogVisible.value = true;
  };
  const openProductSelect = () => {
    productSelectVisible.value = true;
  };
  const handleProductSelected = rows => {
    (rows || []).forEach(row => {
      if (!startForm.productModelIds.includes(row.id) && !modelOptions.value.some(m => m.id === row.id)) {
        modelOptions.value.push(row);
      }
    });
    const ids = (rows || []).map(row => row.id);
    ids.forEach(id => {
      if (!startForm.productModelIds.includes(id)) {
        startForm.productModelIds.push(id);
      }
    });
  };
  const submitStart = () => {
    startFormRef.value?.validate(valid => {
      if (!valid) {
        return;
      }
      const submitData = {
        checkName: startForm.checkName,
        checkDate: startForm.checkDate || undefined,
        checker: startForm.checker || undefined,
        remark: startForm.remark || undefined,
        productModelIds:
          startForm.productModelIds.length > 0
            ? startForm.productModelIds
            : undefined,
      };
      start(submitData)
        .then(() => {
          proxy.$modal.msgSuccess("发起成功");
          startDialogVisible.value = false;
          getList();
        })
        .catch(() => {});
    });
  };
  // å®Œæˆ
  const handleFinish = row => {
    ElMessageBox.confirm(
      "差异将生成调账单据,审核通过后账面生效,确认完成盘点?",
      "提示",
      { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
    )
      .then(() => {
        finish({ id: row.id })
          .then(res => {
            proxy.$modal.msgSuccess(res?.data || "操作成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // å–消
  const handleCancel = row => {
    ElMessageBox.confirm("确认取消该盘点单?", "提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        cancel({ id: row.id })
          .then(() => {
            proxy.$modal.msgSuccess("操作成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // åˆ é™¤
  const handleDelete = row => {
    ElMessageBox.confirm(
      `确认删除盘点单「${row.checkNo || row.checkName || ""}」?`,
      "提示",
      { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
    )
      .then(() => {
        remove([row.id])
          .then(() => {
            proxy.$modal.msgSuccess("删除成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // æ‰¹é‡åˆ é™¤
  const handleBatchDelete = () => {
    if (selectedRows.value.length === 0) {
      proxy.$modal.msgWarning("请选择数据");
      return;
    }
    ElMessageBox.confirm(
      `选中的 ${selectedRows.value.length} æ¡ç›˜ç‚¹å•将被删除,是否确认删除?`,
      "删除提示",
      { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
    )
      .then(() => {
        remove(selectedRows.value.map(item => item.id))
          .then(() => {
            proxy.$modal.msgSuccess("删除成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // å¯¼å‡º
  const handleExport = async () => {
    exportLoading.value = true;
    try {
      const blobData = await exportList(buildQuery());
      if (blobValidate(blobData)) {
        if (blobData.size === 0) {
          ElMessage.warning("暂无数据可导出");
          return;
        }
        const blob = new Blob([blobData], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        saveAs(blob, "库存盘点.xlsx");
        ElMessage.success("导出成功");
      } else {
        const resText = await blobData.text();
        let rspObj = {};
        try {
          rspObj = JSON.parse(resText);
        } catch (error) {
          rspObj = {};
        }
        ElMessage.error(rspObj.msg || "导出失败");
      }
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      exportLoading.value = false;
    }
  };
  onMounted(() => {
    getList();
  });
</script>
<style scoped lang="scss"></style>
src/views/inventoryManagement/stockManagement/StockWarn.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,190 @@
<template>
  <div class="stock-warn">
    <div class="search_form">
      <el-form :model="searchForm"
               inline
               style="margin-bottom: 0;">
        <el-form-item label="产品大类:">
          <el-input v-model="searchForm.productName"
                    placeholder="请输入"
                    clearable
                    style="width: 240px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="规格型号:">
          <el-input v-model="searchForm.model"
                    placeholder="请输入"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="批号:">
          <el-input v-model="searchForm.batchNo"
                    placeholder="请输入"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary"
                     @click="handleQuery">查询</el-button>
          <el-button @click="resetQuery">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
    <div class="mb20"
         style="text-align: right;">
      <el-button type="warning"
                 plain
                 icon="Download"
                 :loading="exportLoading"
                 @click="handleExport">导出</el-button>
    </div>
    <div class="table_list">
      <PIMTable rowKey="id"
                :column="tableColumn"
                :tableData="tableData"
                :page="page"
                :tableLoading="tableLoading"
                :rowClassName="tableRowClassName"
                @pagination="pagination" />
    </div>
  </div>
</template>
<script setup>
  import { ref, reactive, onMounted } from "vue";
  import { ElMessage } from "element-plus";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import { warnPage, warnExport } from "@/api/inventoryManagement/stockInventory.js";
  const tableColumn = ref([
    { label: "产品大类", prop: "productName", minWidth: 140 },
    { label: "规格型号", prop: "model", minWidth: 140 },
    { label: "批号", prop: "batchNo", minWidth: 120 },
    { label: "单位", prop: "unit", width: 80 },
    { label: "库存数量", prop: "qualitity", width: 110 },
    { label: "冻结数量", prop: "lockedQuantity", width: 110 },
    { label: "可用数量", prop: "unLockedQuantity", width: 110 },
    { label: "预警值", prop: "warnNum", width: 100 },
    { label: "备注", prop: "remark", minWidth: 140 },
    { label: "最近更新时间", prop: "updateTime", width: 170 },
  ]);
  const tableData = ref([]);
  const tableLoading = ref(false);
  const exportLoading = ref(false);
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  const searchForm = reactive({
    productName: "",
    model: "",
    batchNo: "",
  });
  const getUnlocked = row => row?.unLockedQuantity ?? row?.unLocked_quantity ?? 0;
  const buildQuery = () => ({
    productName: searchForm.productName || undefined,
    model: searchForm.model || undefined,
    batchNo: searchForm.batchNo || undefined,
  });
  const getList = () => {
    tableLoading.value = true;
    warnPage({
      // å…¼å®¹ current/size ä¸Ž pageNum/pageSize ä¸¤ç§åˆ†é¡µå‚æ•°
      current: page.current,
      size: page.size,
      pageNum: page.current,
      pageSize: page.size,
      ...buildQuery(),
    })
      .then(res => {
        tableData.value = res?.data?.records || [];
        page.total = res?.data?.total || 0;
      })
      .catch(() => {})
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const handleQuery = () => {
    page.current = 1;
    getList();
  };
  const resetQuery = () => {
    searchForm.productName = "";
    searchForm.model = "";
    searchForm.batchNo = "";
    handleQuery();
  };
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
  };
  // å¯ç”¨æ•°é‡ <= é¢„警值 ä¸” é¢„警值 > 0 æ—¶é«˜äº®
  const tableRowClassName = ({ row }) => {
    const unlocked = Number(getUnlocked(row));
    const warn = Number(row?.warnNum ?? 0);
    if (!Number.isFinite(unlocked) || !Number.isFinite(warn)) {
      return "";
    }
    return warn > 0 && unlocked <= warn ? "row-low-stock" : "";
  };
  const handleExport = async () => {
    exportLoading.value = true;
    try {
      const blobData = await warnExport(buildQuery());
      if (blobValidate(blobData)) {
        if (blobData.size === 0) {
          ElMessage.warning("暂无数据可导出");
          return;
        }
        const blob = new Blob([blobData], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        saveAs(blob, "库存预警.xlsx");
        ElMessage.success("导出成功");
      } else {
        const resText = await blobData.text();
        let rspObj = {};
        try {
          rspObj = JSON.parse(resText);
        } catch (error) {
          rspObj = {};
        }
        ElMessage.error(rspObj.msg || "导出失败");
      }
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      exportLoading.value = false;
    }
  };
  onMounted(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  :deep(.row-low-stock td) {
    background-color: #fde2e2;
    color: #c45656;
  }
  :deep(.row-low-stock:hover > td) {
    background-color: #fcd4d4;
  }
</style>
src/views/inventoryManagement/stockManagement/index.vue
@@ -8,6 +8,10 @@
                     :key="tab.id">
          <Record :product-id="tab.id" v-if="tab.id === activeTab" />
        </el-tab-pane>
        <el-tab-pane label="库存预警"
                     name="__warn__">
          <StockWarn v-if="activeTab === '__warn__'" />
        </el-tab-pane>
      </el-tabs>
    </div>
  </div>
@@ -17,6 +21,7 @@
import { ref, onMounted } from 'vue';
import { productTreeList } from "@/api/basicData/product.js";
import Record from "@/views/inventoryManagement/stockManagement/Record.vue";
import StockWarn from "@/views/inventoryManagement/stockManagement/StockWarn.vue";
const products = ref([])
const activeTab = ref(null)
const loading = ref(false)
src/views/procurementManagement/procurementDemand/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,308 @@
<template>
  <div class="app-container">
    <PageHeader content="采购需求" />
    <div class="search_form">
      <el-form :model="queryParams"
               inline
               style="margin-bottom: 0;">
        <el-form-item label="产品名称:">
          <el-input v-model="queryParams.productName"
                    placeholder="请输入产品名称"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="规格型号:">
          <el-input v-model="queryParams.productModel"
                    placeholder="请输入规格型号"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="状态:">
          <el-select v-model="queryParams.status"
                     placeholder="全部"
                     clearable
                     style="width: 160px"
                     @change="handleQuery">
            <el-option label="待处理"
                       :value="0" />
            <el-option label="已转采购"
                       :value="1" />
            <el-option label="已取消"
                       :value="2" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary"
                     @click="handleQuery">查询</el-button>
          <el-button @click="resetQuery">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
    <div class="mb20"
         style="text-align: right;">
      <el-button type="warning"
                 plain
                 icon="Download"
                 :loading="exportLoading"
                 @click="handleExport">导出</el-button>
    </div>
    <div class="table_list">
      <PIMTable rowKey="id"
                :column="tableColumn"
                :tableData="tableData"
                :page="page"
                :isSelection="true"
                @selection-change="handleSelectionChange"
                :tableLoading="tableLoading"
                @pagination="pagination" />
    </div>
  </div>
</template>
<script setup>
  import { ref, reactive, getCurrentInstance, onMounted } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import {
    listPage,
    changeStatus,
    del,
    exportDemand,
  } from "@/api/procurementManagement/procurementDemand.js";
  const { proxy } = getCurrentInstance();
  const statusTextMap = { 0: "待处理", 1: "已转采购", 2: "已取消" };
  const statusTypeMap = { 0: "warning", 1: "success", 2: "info" };
  const tableColumn = ref([
    {
      label: "产品名称",
      prop: "productName",
      minWidth: 140,
    },
    {
      label: "规格型号",
      prop: "productModel",
      minWidth: 140,
    },
    {
      label: "单位",
      prop: "unit",
      width: 80,
    },
    {
      label: "缺口需求量",
      prop: "demandQuantity",
      width: 120,
    },
    {
      label: "生成时库存",
      prop: "stockQuantity",
      width: 120,
    },
    {
      label: "来源BOM编号",
      prop: "sourceBomNo",
      minWidth: 160,
    },
    {
      label: "状态",
      prop: "status",
      dataType: "tag",
      width: 100,
      formatData: v => statusTextMap[v] || "待处理",
      formatType: v => statusTypeMap[v] || "warning",
    },
    {
      label: "备注",
      prop: "remark",
      minWidth: 140,
    },
    {
      label: "创建时间",
      prop: "createTime",
      width: 170,
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 240,
      operation: [
        {
          name: "标记已转采购",
          type: "text",
          color: "#67C23A",
          showHide: row => row.status == 0,
          clickFun: row => {
            handleChangeStatus(row, 1);
          },
        },
        {
          name: "取消",
          type: "text",
          color: "#E6A23C",
          showHide: row => row.status == 0,
          clickFun: row => {
            handleChangeStatus(row, 2);
          },
        },
        {
          name: "删除",
          type: "danger",
          link: true,
          showHide: row => row.status == 0,
          clickFun: row => {
            handleDelete(row);
          },
        },
      ],
    },
  ]);
  const tableData = ref([]);
  const selectedRows = ref([]);
  const tableLoading = ref(false);
  const exportLoading = ref(false);
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  const queryParams = reactive({
    productName: "",
    productModel: "",
    status: "",
  });
  const buildQuery = () => ({
    productName: queryParams.productName || undefined,
    productModel: queryParams.productModel || undefined,
    status:
      queryParams.status === "" ||
      queryParams.status === null ||
      queryParams.status === undefined
        ? undefined
        : queryParams.status,
  });
  // æŸ¥è¯¢åˆ—表
  const getList = () => {
    tableLoading.value = true;
    listPage({
      pageNum: page.current,
      pageSize: page.size,
      ...buildQuery(),
    })
      .then(res => {
        tableData.value = res?.data?.records || [];
        page.total = res?.data?.total || 0;
      })
      .catch(() => {})
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const handleQuery = () => {
    page.current = 1;
    getList();
  };
  const resetQuery = () => {
    queryParams.productName = "";
    queryParams.productModel = "";
    queryParams.status = "";
    handleQuery();
  };
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
  };
  const handleSelectionChange = selection => {
    selectedRows.value = selection;
  };
  // çŠ¶æ€å˜æ›´
  const handleChangeStatus = (row, status) => {
    const text = status == 1 ? "标记为已转采购" : "取消该采购需求";
    ElMessageBox.confirm(`确认${text}?`, "提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        changeStatus([row.id], status)
          .then(() => {
            proxy.$modal.msgSuccess("操作成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // åˆ é™¤
  const handleDelete = row => {
    ElMessageBox.confirm("确认删除该采购需求?仅待处理数据可删除。", "删除提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        del([row.id])
          .then(() => {
            proxy.$modal.msgSuccess("删除成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // å¯¼å‡º
  const handleExport = async () => {
    exportLoading.value = true;
    try {
      const blobData = await exportDemand(buildQuery());
      if (blobValidate(blobData)) {
        if (blobData.size === 0) {
          ElMessage.warning("暂无数据可导出");
          return;
        }
        const blob = new Blob([blobData], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        saveAs(blob, "采购需求.xlsx");
        ElMessage.success("导出成功");
      } else {
        const resText = await blobData.text();
        let rspObj = {};
        try {
          rspObj = JSON.parse(resText);
        } catch (error) {
          rspObj = {};
        }
        ElMessage.error(rspObj.msg || "导出失败");
      }
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      exportLoading.value = false;
    }
  };
  onMounted(() => {
    getList();
  });
</script>
<style scoped lang="scss"></style>
src/views/productionManagement/costAccounting/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,371 @@
<template>
  <div class="app-container">
    <PageHeader content="成本核算分析" />
    <div class="search_form">
      <el-form :model="queryParams"
               inline
               style="margin-bottom: 0;">
        <el-form-item label="订单日期:">
          <el-date-picker v-model="dateRange"
                          type="daterange"
                          range-separator="至"
                          start-placeholder="开始日期"
                          end-placeholder="结束日期"
                          value-format="YYYY-MM-DD"
                          @change="handleQuery" />
        </el-form-item>
        <el-form-item label="生产订单号:">
          <el-input v-model="queryParams.npsNo"
                    placeholder="请输入"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="成品:">
          <el-input v-model="queryParams.productName"
                    placeholder="请输入"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary"
                     @click="handleQuery">查询</el-button>
          <el-button @click="resetQuery">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
    <el-tabs v-model="activeTab"
             @tab-change="handleTabChange">
      <!-- æ ¸ç®—明细 -->
      <el-tab-pane label="核算明细"
                   name="stat">
        <div class="mb20"
             style="text-align: right;">
          <el-button type="warning"
                     plain
                     icon="Download"
                     :loading="statExportLoading"
                     @click="handleStatExport">导出</el-button>
        </div>
        <PIMTable rowKey="productionOrderId"
                  :column="statColumn"
                  :tableData="statData"
                  :isShowPagination="false"
                  :tableLoading="statLoading">
          <template #laborCost="{ row }">
            <el-tooltip v-if="row.noRateRows > 0"
                        content="存在无工资标准的报工记录,人工成本可能不准确,请补录工时或维护计件单价"
                        placement="top">
              <span style="color: #e6a23c">{{ formatNum(row.laborCost) }}</span>
            </el-tooltip>
            <span v-else>{{ formatNum(row.laborCost) }}</span>
          </template>
          <template #grossProfitRate="{ row }">
            <span :style="{ color: isNegative(row.grossProfitRate) ? '#f56c6c' : '' }">
              {{ formatRate(row.grossProfitRate) }}
            </span>
          </template>
          <template #missingPriceKinds="{ row }">
            <span :style="{ color: row.missingPriceKinds > 0 ? '#e6a23c' : '' }">
              {{ row.missingPriceKinds ?? 0 }}
            </span>
          </template>
        </PIMTable>
      </el-tab-pane>
      <!-- æž„成汇总 -->
      <el-tab-pane label="构成汇总"
                   name="summary">
        <div class="mb20"
             style="display: flex; align-items: center; justify-content: space-between;">
          <el-radio-group v-model="groupBy"
                          @change="getSummary">
            <el-radio label="month">按月份</el-radio>
            <el-radio label="product">按成品</el-radio>
          </el-radio-group>
          <el-button type="warning"
                     plain
                     icon="Download"
                     :loading="summaryExportLoading"
                     @click="handleSummaryExport">导出</el-button>
        </div>
        <PIMTable rowKey="groupKey"
                  :column="summaryColumn"
                  :tableData="summaryData"
                  :isShowPagination="false"
                  :tableLoading="summaryLoading" />
      </el-tab-pane>
      <!-- ç¼ºä»·ç‰©æ–™ -->
      <el-tab-pane label="缺价物料"
                   name="missing">
        <div class="mb20"
             style="text-align: right;">
          <el-button type="warning"
                     plain
                     icon="Download"
                     :loading="missingExportLoading"
                     @click="handleMissingExport">导出</el-button>
        </div>
        <PIMTable rowKey="productModelId"
                  :column="missingColumn"
                  :tableData="missingData"
                  :isShowPagination="false"
                  :tableLoading="missingLoading" />
      </el-tab-pane>
    </el-tabs>
  </div>
</template>
<script setup>
  import { ref, reactive, onMounted } from "vue";
  import { ElMessage } from "element-plus";
  import dayjs from "dayjs";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import {
    costStat,
    costSummary,
    missingPrice,
    costStatExport,
    costSummaryExport,
    missingPriceExport,
  } from "@/api/productionManagement/costAccounting.js";
  // é»˜è®¤æŸ¥è¯¢å½“前月
  const currentMonthRange = () => [
    dayjs().startOf("month").format("YYYY-MM-DD"),
    dayjs().endOf("month").format("YYYY-MM-DD"),
  ];
  const activeTab = ref("stat");
  const dateRange = ref(currentMonthRange());
  const queryParams = reactive({
    npsNo: "",
    productName: "",
  });
  const groupBy = ref("month");
  const statColumn = ref([
    { label: "生产订单号", prop: "npsNo", minWidth: 150 },
    { label: "成品", prop: "finishedProduct", minWidth: 140 },
    { label: "单位", prop: "unit", width: 80 },
    { label: "订单日期", prop: "orderDate", width: 120 },
    { label: "产量", prop: "outputQty", width: 100 },
    { label: "材料成本", prop: "materialCost", width: 110 },
    { label: "人工成本", prop: "laborCost", width: 110, dataType: "slot", slot: "laborCost" },
    { label: "能耗成本", prop: "energyCost", width: 110 },
    { label: "总成本", prop: "totalCost", width: 110 },
    { label: "单位成本", prop: "unitCost", width: 110 },
    { label: "销售单价", prop: "salePrice", width: 110, formatData: v => formatNum(v) },
    { label: "毛利", prop: "grossProfit", width: 110, formatData: v => formatNum(v) },
    {
      label: "毛利率(%)",
      prop: "grossProfitRate",
      width: 110,
      dataType: "slot",
      slot: "grossProfitRate",
    },
    { label: "价格覆盖率(%)", prop: "priceCoverageRate", width: 120 },
    {
      label: "缺价物料数",
      prop: "missingPriceKinds",
      width: 110,
      dataType: "slot",
      slot: "missingPriceKinds",
    },
    { label: "无工资标准记录", prop: "noRateRows", width: 130 },
  ]);
  const summaryColumn = ref([
    { label: "分组", prop: "groupKey", minWidth: 160 },
    { label: "订单数", prop: "orderCount", width: 100 },
    { label: "产量", prop: "outputQty", width: 110 },
    { label: "材料成本", prop: "materialCost", width: 110 },
    { label: "材料占比(%)", prop: "materialRate", width: 120 },
    { label: "人工成本", prop: "laborCost", width: 110 },
    { label: "人工占比(%)", prop: "laborRate", width: 120 },
    { label: "能耗成本", prop: "energyCost", width: 110 },
    { label: "能耗占比(%)", prop: "energyRate", width: 120 },
    { label: "总成本", prop: "totalCost", width: 110 },
    { label: "单位成本", prop: "unitCost", width: 110 },
    { label: "毛利", prop: "grossProfit", width: 110 },
    { label: "毛利率(%)", prop: "grossProfitRate", width: 110 },
  ]);
  const missingColumn = ref([
    { label: "物料名称", prop: "materialName", minWidth: 160 },
    { label: "规格型号", prop: "materialModel", minWidth: 160 },
    { label: "单位", prop: "unit", width: 80 },
    { label: "投入数量", prop: "inputQty", width: 120 },
    { label: "涉及订单数", prop: "orderCount", width: 110 },
    { label: "最近投入时间", prop: "lastInputTime", width: 170 },
  ]);
  const statData = ref([]);
  const statLoading = ref(false);
  const statExportLoading = ref(false);
  const summaryData = ref([]);
  const summaryLoading = ref(false);
  const summaryExportLoading = ref(false);
  const missingData = ref([]);
  const missingLoading = ref(false);
  const missingExportLoading = ref(false);
  const buildQuery = () => ({
    startDate: dateRange.value?.[0] || undefined,
    endDate: dateRange.value?.[1] || undefined,
    npsNo: queryParams.npsNo || undefined,
    productName: queryParams.productName || undefined,
  });
  const buildSummaryQuery = () => ({
    groupBy: groupBy.value,
    ...buildQuery(),
  });
  const formatNum = v => {
    if (v === null || v === undefined || v === "") {
      return "—";
    }
    return v;
  };
  const isNegative = v => {
    const num = Number(v);
    return Number.isFinite(num) && num < 0;
  };
  const formatRate = v => {
    if (v === null || v === undefined || v === "") {
      return "—";
    }
    return v;
  };
  const getStat = () => {
    statLoading.value = true;
    costStat(buildQuery())
      .then(res => {
        statData.value = res?.data || [];
      })
      .catch(() => {})
      .finally(() => {
        statLoading.value = false;
      });
  };
  const getSummary = () => {
    summaryLoading.value = true;
    costSummary(buildSummaryQuery())
      .then(res => {
        summaryData.value = res?.data || [];
      })
      .catch(() => {})
      .finally(() => {
        summaryLoading.value = false;
      });
  };
  const getMissing = () => {
    missingLoading.value = true;
    missingPrice(buildQuery())
      .then(res => {
        missingData.value = res?.data || [];
      })
      .catch(() => {})
      .finally(() => {
        missingLoading.value = false;
      });
  };
  const loadCurrentTab = () => {
    if (activeTab.value === "stat") {
      getStat();
    } else if (activeTab.value === "summary") {
      getSummary();
    } else {
      getMissing();
    }
  };
  const handleQuery = () => {
    loadCurrentTab();
  };
  const resetQuery = () => {
    dateRange.value = currentMonthRange();
    queryParams.npsNo = "";
    queryParams.productName = "";
    loadCurrentTab();
  };
  const handleTabChange = () => {
    loadCurrentTab();
  };
  const exportBlob = async (requestFn, query, filename) => {
    const blobData = await requestFn(query);
    if (blobValidate(blobData)) {
      if (blobData.size === 0) {
        ElMessage.warning("暂无数据可导出");
        return;
      }
      const blob = new Blob([blobData], {
        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      });
      saveAs(blob, filename);
      ElMessage.success("导出成功");
    } else {
      const resText = await blobData.text();
      let rspObj = {};
      try {
        rspObj = JSON.parse(resText);
      } catch (error) {
        rspObj = {};
      }
      ElMessage.error(rspObj.msg || "导出失败");
    }
  };
  const handleStatExport = async () => {
    statExportLoading.value = true;
    try {
      await exportBlob(costStatExport, buildQuery(), "成本核算明细.xlsx");
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      statExportLoading.value = false;
    }
  };
  const handleSummaryExport = async () => {
    summaryExportLoading.value = true;
    try {
      await exportBlob(costSummaryExport, buildSummaryQuery(), "成本构成汇总.xlsx");
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      summaryExportLoading.value = false;
    }
  };
  const handleMissingExport = async () => {
    missingExportLoading.value = true;
    try {
      await exportBlob(missingPriceExport, buildQuery(), "缺价物料清单.xlsx");
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      missingExportLoading.value = false;
    }
  };
  onMounted(() => {
    getStat();
  });
</script>
<style scoped lang="scss"></style>
src/views/productionManagement/materialConsumption/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,199 @@
<template>
  <div class="app-container">
    <PageHeader content="材料消耗核算" />
    <div class="search_form">
      <el-form :model="queryParams"
               inline
               style="margin-bottom: 0;">
        <el-form-item label="订单日期:">
          <el-date-picker v-model="dateRange"
                          type="daterange"
                          range-separator="至"
                          start-placeholder="开始日期"
                          end-placeholder="结束日期"
                          value-format="YYYY-MM-DD"
                          @change="handleQuery" />
        </el-form-item>
        <el-form-item label="生产订单号:">
          <el-input v-model="queryParams.npsNo"
                    placeholder="请输入"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="成品:">
          <el-input v-model="queryParams.productName"
                    placeholder="请输入"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="只看差异:">
          <el-switch v-model="queryParams.onlyDiff"
                     @change="handleQuery" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary"
                     @click="handleQuery">查询</el-button>
          <el-button @click="resetQuery">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
    <div class="mb20"
         style="text-align: right;">
      <el-button type="warning"
                 plain
                 icon="Download"
                 :loading="exportLoading"
                 @click="handleExport">导出</el-button>
    </div>
    <div class="table_list">
      <PIMTable rowKey="rowKey"
                :column="tableColumn"
                :tableData="tableData"
                :isShowPagination="false"
                :tableLoading="tableLoading">
        <template #diffRate="{ row }">
          <span :style="{ color: diffRateColor(row.diffRate) }">
            {{ formatDiffRate(row.diffRate) }}
          </span>
        </template>
      </PIMTable>
    </div>
  </div>
</template>
<script setup>
  import { ref, reactive, getCurrentInstance, onMounted } from "vue";
  import { ElMessage } from "element-plus";
  import dayjs from "dayjs";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import { stat, statExport } from "@/api/productionManagement/materialConsumption.js";
  const { proxy } = getCurrentInstance();
  const tableColumn = ref([
    { label: "生产订单号", prop: "npsNo", minWidth: 150 },
    { label: "成品", prop: "finishedProduct", minWidth: 140 },
    { label: "物料名称", prop: "materialName", minWidth: 140 },
    { label: "规格型号", prop: "materialModel", minWidth: 140 },
    { label: "单位", prop: "unit", width: 80 },
    { label: "BOM单耗", prop: "unitQuantity", width: 110 },
    { label: "完工数量", prop: "completeQty", width: 110 },
    { label: "标准消耗", prop: "standardQty", width: 110 },
    { label: "实际消耗", prop: "actualQty", width: 110 },
    { label: "差异数量", prop: "diffQty", width: 110 },
    {
      label: "差异率(%)",
      prop: "diffRate",
      width: 110,
      dataType: "slot",
      slot: "diffRate",
    },
  ]);
  const tableData = ref([]);
  const tableLoading = ref(false);
  const exportLoading = ref(false);
  // é»˜è®¤æŸ¥è¯¢å½“前月
  const currentMonthRange = () => [
    dayjs().startOf("month").format("YYYY-MM-DD"),
    dayjs().endOf("month").format("YYYY-MM-DD"),
  ];
  const dateRange = ref(currentMonthRange());
  const queryParams = reactive({
    npsNo: "",
    productName: "",
    onlyDiff: false,
  });
  const buildQuery = () => ({
    startDate: dateRange.value?.[0] || undefined,
    endDate: dateRange.value?.[1] || undefined,
    npsNo: queryParams.npsNo || undefined,
    productName: queryParams.productName || undefined,
    onlyDiff: queryParams.onlyDiff ? true : undefined,
  });
  const formatDiffRate = rate => {
    if (rate === null || rate === undefined || rate === "") {
      return "-";
    }
    return rate;
  };
  const diffRateColor = rate => {
    const num = Number(rate);
    if (rate === null || rate === undefined || rate === "" || !Number.isFinite(num)) {
      return "";
    }
    return Math.abs(num) > 10 ? "#F56C6C" : "";
  };
  const getList = () => {
    tableLoading.value = true;
    stat(buildQuery())
      .then(res => {
        const list = res?.data || [];
        tableData.value = list.map((item, index) => ({
          ...item,
          rowKey: `${item.productionOrderId}_${item.materialModelId}_${index}`,
        }));
      })
      .catch(() => {})
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const handleQuery = () => {
    getList();
  };
  const resetQuery = () => {
    dateRange.value = currentMonthRange();
    queryParams.npsNo = "";
    queryParams.productName = "";
    queryParams.onlyDiff = false;
    getList();
  };
  const handleExport = async () => {
    exportLoading.value = true;
    try {
      const blobData = await statExport(buildQuery());
      if (blobValidate(blobData)) {
        if (blobData.size === 0) {
          ElMessage.warning("暂无数据可导出");
          return;
        }
        const blob = new Blob([blobData], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        saveAs(blob, "材料消耗核算.xlsx");
        ElMessage.success("导出成功");
      } else {
        const resText = await blobData.text();
        let rspObj = {};
        try {
          rspObj = JSON.parse(resText);
        } catch (error) {
          rspObj = {};
        }
        ElMessage.error(rspObj.msg || "导出失败");
      }
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      exportLoading.value = false;
    }
  };
  onMounted(() => {
    getList();
  });
</script>
<style scoped lang="scss"></style>
src/views/productionManagement/outputStatistics/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,350 @@
<template>
  <div class="app-container">
    <PageHeader content="生产统计" />
    <el-tabs v-model="activeTab"
             @tab-change="handleTabChange">
      <!-- è®¡ä»¶å·¥èµ„汇总 -->
      <el-tab-pane label="计件工资汇总"
                   name="wage">
        <div class="search_form">
          <el-form :model="wageQuery"
                   inline
                   style="margin-bottom: 0;">
            <el-form-item label="日期范围:">
              <el-date-picker v-model="wageDateRange"
                              type="daterange"
                              range-separator="至"
                              start-placeholder="开始日期"
                              end-placeholder="结束日期"
                              value-format="YYYY-MM-DD"
                              @change="handleWageQuery" />
            </el-form-item>
            <el-form-item label="人员姓名:">
              <el-input v-model="wageQuery.userName"
                        placeholder="请输入"
                        clearable
                        style="width: 200px"
                        @keyup.enter="handleWageQuery" />
            </el-form-item>
            <el-form-item>
              <el-button type="primary"
                         @click="handleWageQuery">查询</el-button>
              <el-button @click="resetWageQuery">重置</el-button>
            </el-form-item>
          </el-form>
        </div>
        <div class="mb20"
             style="text-align: right;">
          <el-button type="warning"
                     plain
                     icon="Download"
                     :loading="wageExportLoading"
                     @click="handleWageExport">导出</el-button>
        </div>
        <div class="table_list">
          <PIMTable rowKey="userId"
                    :column="wageColumn"
                    :tableData="wageData"
                    :isShowPagination="false"
                    :tableLoading="wageLoading" />
        </div>
      </el-tab-pane>
      <!-- äº§é‡ç»Ÿè®¡ -->
      <el-tab-pane label="产量统计"
                   name="output">
        <div class="search_form">
          <el-form :model="outputQuery"
                   inline
                   style="margin-bottom: 0;">
            <el-form-item label="统计维度:">
              <el-radio-group v-model="outputQuery.dimension"
                              @change="handleDimensionChange">
                <el-radio label="device">设备</el-radio>
                <el-radio label="operation">工序</el-radio>
                <el-radio label="product">产品</el-radio>
              </el-radio-group>
            </el-form-item>
            <el-form-item label="日期范围:">
              <el-date-picker v-model="outputDateRange"
                              type="daterange"
                              range-separator="至"
                              start-placeholder="开始日期"
                              end-placeholder="结束日期"
                              value-format="YYYY-MM-DD"
                              @change="handleOutputQuery" />
            </el-form-item>
            <el-form-item v-if="outputQuery.dimension === 'device'"
                          label="设备:">
              <el-select v-model="outputQuery.deviceId"
                         placeholder="全部"
                         clearable
                         filterable
                         style="width: 200px"
                         @change="handleOutputQuery">
                <el-option v-for="item in deviceOptions"
                           :key="item.id"
                           :label="item.deviceName"
                           :value="item.id" />
              </el-select>
            </el-form-item>
            <el-form-item>
              <el-button type="primary"
                         @click="handleOutputQuery">查询</el-button>
              <el-button @click="resetOutputQuery">重置</el-button>
            </el-form-item>
          </el-form>
        </div>
        <div class="mb20"
             style="text-align: right;">
          <el-button type="warning"
                     plain
                     icon="Download"
                     :loading="outputExportLoading"
                     @click="handleOutputExport">导出</el-button>
        </div>
        <div class="table_list">
          <PIMTable rowKey="groupId"
                    :column="outputColumn"
                    :tableData="outputData"
                    :isShowPagination="false"
                    :tableLoading="outputLoading" />
        </div>
      </el-tab-pane>
    </el-tabs>
  </div>
</template>
<script setup>
  import { ref, reactive, getCurrentInstance, onMounted } from "vue";
  import { ElMessage } from "element-plus";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import {
    wageSummary,
    wageSummaryExport,
    outputStat,
    outputStatExport,
  } from "@/api/productionManagement/productionStat.js";
  import { getDeviceLedger } from "@/api/equipmentManagement/ledger.js";
  const { proxy } = getCurrentInstance();
  const activeTab = ref("wage");
  const deviceOptions = ref([]);
  const wageColumn = ref([
    {
      label: "生产人",
      prop: "userName",
      minWidth: 120,
    },
    {
      label: "报工单数",
      prop: "workCount",
      width: 110,
    },
    {
      label: "生产数量",
      prop: "quantity",
      width: 120,
    },
    {
      label: "总工时",
      prop: "workHour",
      width: 110,
    },
    {
      label: "计件工资",
      prop: "pieceWage",
      width: 120,
    },
    {
      label: "应发合计",
      prop: "wages",
      width: 120,
    },
  ]);
  const outputColumn = ref([
    {
      label: "分组名称",
      prop: "groupName",
      minWidth: 160,
    },
    {
      label: "报工单数",
      prop: "workCount",
      width: 110,
    },
    {
      label: "产量",
      prop: "quantity",
      width: 120,
    },
    {
      label: "报废数量",
      prop: "scrapQty",
      width: 120,
    },
    {
      label: "工时",
      prop: "workHour",
      width: 110,
    },
  ]);
  const wageData = ref([]);
  const wageLoading = ref(false);
  const wageExportLoading = ref(false);
  const wageDateRange = ref([]);
  const wageQuery = reactive({
    userName: "",
  });
  const outputData = ref([]);
  const outputLoading = ref(false);
  const outputExportLoading = ref(false);
  const outputDateRange = ref([]);
  const outputQuery = reactive({
    dimension: "device",
    deviceId: "",
  });
  const buildWageQuery = () => ({
    startDate: wageDateRange.value?.[0] || undefined,
    endDate: wageDateRange.value?.[1] || undefined,
    userName: wageQuery.userName || undefined,
  });
  const buildOutputQuery = () => ({
    dimension: outputQuery.dimension,
    startDate: outputDateRange.value?.[0] || undefined,
    endDate: outputDateRange.value?.[1] || undefined,
    deviceId:
      outputQuery.dimension === "device" && outputQuery.deviceId
        ? outputQuery.deviceId
        : undefined,
  });
  // è®¡ä»¶å·¥èµ„汇总
  const getWageList = () => {
    wageLoading.value = true;
    wageSummary(buildWageQuery())
      .then(res => {
        wageData.value = res?.data || [];
      })
      .catch(() => {})
      .finally(() => {
        wageLoading.value = false;
      });
  };
  const handleWageQuery = () => {
    getWageList();
  };
  const resetWageQuery = () => {
    wageQuery.userName = "";
    wageDateRange.value = [];
    getWageList();
  };
  // äº§é‡ç»Ÿè®¡
  const getOutputList = () => {
    outputLoading.value = true;
    outputStat(buildOutputQuery())
      .then(res => {
        outputData.value = res?.data || [];
      })
      .catch(() => {})
      .finally(() => {
        outputLoading.value = false;
      });
  };
  const handleOutputQuery = () => {
    getOutputList();
  };
  const resetOutputQuery = () => {
    outputQuery.dimension = "device";
    outputQuery.deviceId = "";
    outputDateRange.value = [];
    getOutputList();
  };
  const handleDimensionChange = () => {
    outputQuery.deviceId = "";
    getOutputList();
  };
  const handleTabChange = name => {
    if (name === "wage") {
      getWageList();
    } else {
      getOutputList();
    }
  };
  // å¯¼å‡º
  const exportBlob = async (requestFn, query, filename) => {
    const blobData = await requestFn(query);
    if (blobValidate(blobData)) {
      if (blobData.size === 0) {
        ElMessage.warning("暂无数据可导出");
        return;
      }
      const blob = new Blob([blobData], {
        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      });
      saveAs(blob, filename);
      ElMessage.success("导出成功");
    } else {
      const resText = await blobData.text();
      let rspObj = {};
      try {
        rspObj = JSON.parse(resText);
      } catch (error) {
        rspObj = {};
      }
      ElMessage.error(rspObj.msg || "导出失败");
    }
  };
  const handleWageExport = async () => {
    wageExportLoading.value = true;
    try {
      await exportBlob(wageSummaryExport, buildWageQuery(), "计件工资汇总.xlsx");
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      wageExportLoading.value = false;
    }
  };
  const handleOutputExport = async () => {
    outputExportLoading.value = true;
    try {
      await exportBlob(outputStatExport, buildOutputQuery(), "产量统计.xlsx");
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      outputExportLoading.value = false;
    }
  };
  onMounted(() => {
    getWageList();
    getDeviceLedger()
      .then(res => {
        deviceOptions.value = res.data || [];
      })
      .catch(err => {
        console.error("获取设备列表失败", err);
        deviceOptions.value = [];
      });
  });
</script>
<style scoped lang="scss"></style>
src/views/productionManagement/pieceRateConfig/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,450 @@
<template>
  <div class="app-container">
    <PageHeader content="计件单价配置" />
    <div class="search_form">
      <el-form :model="queryParams"
               inline
               style="margin-bottom: 0;">
        <el-form-item label="规格型号:">
          <el-input v-model="queryParams.productModel"
                    placeholder="请输入规格型号"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="工序名称:">
          <el-input v-model="queryParams.operationName"
                    placeholder="请输入工序名称"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery" />
        </el-form-item>
        <el-form-item label="状态:">
          <el-select v-model="queryParams.status"
                     placeholder="全部"
                     clearable
                     style="width: 160px"
                     @change="handleQuery">
            <el-option label="启用"
                       :value="1" />
            <el-option label="停用"
                       :value="0" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary"
                     @click="handleQuery">查询</el-button>
          <el-button @click="resetQuery">重置</el-button>
        </el-form-item>
      </el-form>
    </div>
    <div class="mb20"
         style="text-align: right;">
      <el-button type="primary"
                 @click="openDialog('add')">新增</el-button>
      <el-button type="danger"
                 plain
                 @click="handleBatchDelete">删除</el-button>
      <el-button type="warning"
                 plain
                 icon="Download"
                 :loading="exportLoading"
                 @click="handleExport">导出</el-button>
    </div>
    <div class="table_list">
      <PIMTable rowKey="id"
                :column="tableColumn"
                :tableData="tableData"
                :page="page"
                :isSelection="true"
                @selection-change="handleSelectionChange"
                :tableLoading="tableLoading"
                @pagination="pagination" />
    </div>
    <el-dialog v-model="dialogVisible"
               :title="operationType === 'add' ? '新增计件单价' : '编辑计件单价'"
               width="480px"
               @close="closeDialog">
      <el-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="100px">
        <el-form-item label="规格型号:"
                      prop="productModel">
          <el-input v-model="form.productModel"
                    placeholder="请选择规格型号"
                    readonly
                    @click="openProductSelect">
            <template #append>
              <el-button icon="Search"
                         @click="openProductSelect" />
            </template>
          </el-input>
        </el-form-item>
        <el-form-item label="工序名称:"
                      prop="operationName">
          <el-input v-model="form.operationName"
                    placeholder="请输入工序名称"
                    clearable />
        </el-form-item>
        <el-form-item label="计件单价:"
                      prop="unitPrice">
          <el-input-number v-model="form.unitPrice"
                           :min="0.0001"
                           :precision="4"
                           :controls="false"
                           placeholder="请输入计件单价"
                           style="width: 100%" />
        </el-form-item>
        <el-form-item label="备注:">
          <el-input v-model="form.remark"
                    type="textarea"
                    :rows="2"
                    placeholder="请输入备注"
                    clearable />
        </el-form-item>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary"
                     @click="submitForm">确认</el-button>
          <el-button @click="closeDialog">取消</el-button>
        </div>
      </template>
    </el-dialog>
    <ProductSelectDialog v-model="productSelectVisible"
                         single
                         @confirm="handleProductSelected" />
  </div>
</template>
<script setup>
  import { ref, reactive, getCurrentInstance, onMounted } from "vue";
  import { ElMessage, ElMessageBox } from "element-plus";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import {
    listPage,
    add,
    update,
    changeStatus,
    batchDelete,
    exportConfig,
  } from "@/api/productionManagement/pieceRateConfig.js";
  import ProductSelectDialog from "@/views/basicData/product/ProductSelectDialog.vue";
  const { proxy } = getCurrentInstance();
  const tableColumn = ref([
    {
      label: "规格型号",
      prop: "productModel",
      minWidth: 160,
    },
    {
      label: "工序名称",
      prop: "operationName",
      minWidth: 140,
    },
    {
      label: "计件单价",
      prop: "unitPrice",
      width: 120,
    },
    {
      label: "状态",
      prop: "status",
      dataType: "tag",
      width: 100,
      formatData: v => (v == 1 ? "启用" : "停用"),
      formatType: v => (v == 1 ? "success" : "info"),
    },
    {
      label: "备注",
      prop: "remark",
      minWidth: 140,
    },
    {
      label: "创建时间",
      prop: "createTime",
      width: 170,
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 200,
      operation: [
        {
          name: "编辑",
          type: "text",
          clickFun: row => {
            openDialog("edit", row);
          },
        },
        {
          name: "启用",
          type: "text",
          color: "#67C23A",
          showHide: row => row.status != 1,
          clickFun: row => {
            handleChangeStatus(row, 1);
          },
        },
        {
          name: "停用",
          type: "text",
          color: "#909399",
          showHide: row => row.status == 1,
          clickFun: row => {
            handleChangeStatus(row, 0);
          },
        },
        {
          name: "删除",
          type: "danger",
          link: true,
          clickFun: row => {
            handleDelete(row);
          },
        },
      ],
    },
  ]);
  const tableData = ref([]);
  const selectedRows = ref([]);
  const tableLoading = ref(false);
  const exportLoading = ref(false);
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  const queryParams = reactive({
    productModel: "",
    operationName: "",
    status: "",
  });
  const dialogVisible = ref(false);
  const operationType = ref("add");
  const formRef = ref(null);
  const productSelectVisible = ref(false);
  const form = reactive({
    id: "",
    productModelId: "",
    productModel: "",
    operationName: "",
    unitPrice: undefined,
    remark: "",
  });
  const rules = {
    productModel: [{ required: true, message: "请选择规格型号", trigger: "change" }],
    operationName: [{ required: true, message: "请输入工序名称", trigger: "blur" }],
    unitPrice: [{ required: true, message: "请输入计件单价", trigger: "blur" }],
  };
  const buildQuery = () => ({
    productModel: queryParams.productModel || undefined,
    operationName: queryParams.operationName || undefined,
    status:
      queryParams.status === "" ||
      queryParams.status === null ||
      queryParams.status === undefined
        ? undefined
        : queryParams.status,
  });
  // æŸ¥è¯¢åˆ—表
  const getList = () => {
    tableLoading.value = true;
    listPage({
      pageNum: page.current,
      pageSize: page.size,
      ...buildQuery(),
    })
      .then(res => {
        tableData.value = res?.data?.records || [];
        page.total = res?.data?.total || 0;
      })
      .catch(() => {})
      .finally(() => {
        tableLoading.value = false;
      });
  };
  const handleQuery = () => {
    page.current = 1;
    getList();
  };
  const resetQuery = () => {
    queryParams.productModel = "";
    queryParams.operationName = "";
    queryParams.status = "";
    handleQuery();
  };
  const pagination = obj => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
  };
  const handleSelectionChange = selection => {
    selectedRows.value = selection;
  };
  // æ‰“开弹框
  const openDialog = (type, row) => {
    operationType.value = type;
    if (type === "edit" && row) {
      form.id = row.id;
      form.productModelId = row.productModelId;
      form.productModel = row.productModel;
      form.operationName = row.operationName;
      form.unitPrice = row.unitPrice;
      form.remark = row.remark;
    } else {
      form.id = "";
      form.productModelId = "";
      form.productModel = "";
      form.operationName = "";
      form.unitPrice = undefined;
      form.remark = "";
    }
    dialogVisible.value = true;
  };
  const closeDialog = () => {
    formRef.value?.resetFields();
    dialogVisible.value = false;
  };
  const openProductSelect = () => {
    productSelectVisible.value = true;
  };
  const handleProductSelected = rows => {
    const row = rows && rows[0];
    if (row) {
      form.productModelId = row.id;
      form.productModel = row.model;
    }
  };
  // æäº¤
  const submitForm = () => {
    formRef.value?.validate(valid => {
      if (!valid) {
        return;
      }
      const submitData = {
        id: form.id,
        productModelId: form.productModelId,
        operationName: form.operationName,
        unitPrice: form.unitPrice,
        remark: form.remark,
      };
      const request = operationType.value === "edit" ? update : add;
      request(submitData)
        .then(() => {
          proxy.$modal.msgSuccess("提交成功");
          closeDialog();
          getList();
        })
        .catch(() => {});
    });
  };
  // å¯ç”¨/停用
  const handleChangeStatus = (row, status) => {
    const text = status == 1 ? "启用" : "停用";
    ElMessageBox.confirm(`确认${text}该计件单价?`, "提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        changeStatus({ id: row.id, status })
          .then(() => {
            proxy.$modal.msgSuccess("操作成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // åˆ é™¤
  const handleDelete = row => {
    removeRows([row.id]);
  };
  const handleBatchDelete = () => {
    if (selectedRows.value.length === 0) {
      proxy.$modal.msgWarning("请选择数据");
      return;
    }
    removeRows(selectedRows.value.map(item => item.id));
  };
  const removeRows = ids => {
    ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "删除提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        batchDelete(ids)
          .then(() => {
            proxy.$modal.msgSuccess("删除成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // å¯¼å‡º
  const handleExport = async () => {
    exportLoading.value = true;
    try {
      const blobData = await exportConfig(buildQuery());
      if (blobValidate(blobData)) {
        if (blobData.size === 0) {
          ElMessage.warning("暂无数据可导出");
          return;
        }
        const blob = new Blob([blobData], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        saveAs(blob, "计件单价配置.xlsx");
        ElMessage.success("导出成功");
      } else {
        const resText = await blobData.text();
        let rspObj = {};
        try {
          rspObj = JSON.parse(resText);
        } catch (error) {
          rspObj = {};
        }
        ElMessage.error(rspObj.msg || "导出失败");
      }
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      exportLoading.value = false;
    }
  };
  onMounted(() => {
    getList();
  });
</script>
<style scoped lang="scss"></style>
src/views/productionManagement/productStructure/KitCheckDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,216 @@
<template>
  <el-dialog v-model="visible"
             title="BOM齐套分析"
             width="900px"
             @close="handleClose">
    <div class="bom-info">
      <span>BOM编号:{{ bom.bomNo || '-' }}</span>
      <span>产品名称:{{ bom.productName || '-' }}</span>
      <span>规格型号:{{ bom.productModelName || '-' }}</span>
    </div>
    <el-form :inline="true"
             @submit.prevent>
      <el-form-item label="需求数量">
        <el-input-number v-model="demandQty"
                         :min="0"
                         :precision="4"
                         :step="1"
                         :controls="false"
                         placeholder="请输入需求数量"
                         style="width: 180px" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary"
                   :loading="loading"
                   @click="handleAnalyze">开始分析</el-button>
        <el-button :disabled="!resultList.length"
                   :loading="exportLoading"
                   @click="handleExport">导出</el-button>
        <el-button type="success"
                   :disabled="!resultList.length"
                   :loading="genLoading"
                   @click="handleGenerate">生成采购需求</el-button>
      </el-form-item>
    </el-form>
    <el-table v-loading="loading"
              :data="resultList"
              border
              size="small"
              max-height="420"
              style="width: 100%">
      <el-table-column type="index"
                       label="序号"
                       width="60"
                       align="center" />
      <el-table-column label="物料名称"
                       prop="productName"
                       min-width="140" />
      <el-table-column label="规格型号"
                       prop="model"
                       min-width="140" />
      <el-table-column label="单位"
                       prop="unit"
                       width="80"
                       align="center" />
      <el-table-column label="需求数量"
                       prop="requiredQty"
                       width="110"
                       align="right" />
      <el-table-column label="可用库存"
                       prop="availableQty"
                       width="110"
                       align="right" />
      <el-table-column label="缺口数量"
                       prop="shortageQty"
                       width="110"
                       align="right">
        <template #default="{ row }">
          <span :class="{ shortage: Number(row.shortageQty) > 0 }">{{ row.shortageQty }}</span>
        </template>
      </el-table-column>
    </el-table>
  </el-dialog>
</template>
<script setup>
  import { ref, computed, getCurrentInstance } from "vue";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import {
    kitCheck,
    kitCheckExport,
    generateDemand,
  } from "@/api/productionManagement/productBom.js";
  const props = defineProps({
    showModel: {
      type: Boolean,
      default: false,
    },
    bom: {
      type: Object,
      default: () => ({}),
    },
  });
  const emits = defineEmits(["update:showModel"]);
  const { proxy } = getCurrentInstance();
  const visible = computed({
    get() {
      return props.showModel;
    },
    set(val) {
      emits("update:showModel", val);
    },
  });
  const demandQty = ref(undefined);
  const resultList = ref([]);
  const loading = ref(false);
  const exportLoading = ref(false);
  const genLoading = ref(false);
  // æ ¡éªŒå¹¶ç»„装请求参数
  const buildParams = () => {
    if (!props.bom?.id) {
      proxy.$modal.msgWarning("未获取到BOM信息,请重试");
      return null;
    }
    const qty = Number(demandQty.value);
    if (!qty || qty <= 0) {
      proxy.$modal.msgWarning("请输入大于0的需求数量");
      return null;
    }
    return { bomId: props.bom.id, demandQty: qty };
  };
  // å¼€å§‹åˆ†æž
  const handleAnalyze = () => {
    const params = buildParams();
    if (!params) return;
    loading.value = true;
    kitCheck(params)
      .then(res => {
        resultList.value = res?.data || [];
        if (!resultList.value.length) {
          proxy.$modal.msgWarning("该BOM下无物料节点");
        }
      })
      .catch(() => {})
      .finally(() => {
        loading.value = false;
      });
  };
  // å¯¼å‡º
  const handleExport = async () => {
    const params = buildParams();
    if (!params) return;
    exportLoading.value = true;
    try {
      const blobData = await kitCheckExport(params);
      if (blobValidate(blobData)) {
        if (blobData.size === 0) {
          proxy.$modal.msgWarning("暂无数据可导出");
          return;
        }
        const blob = new Blob([blobData], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        saveAs(blob, `BOM齐套分析_${props.bom.bomNo || props.bom.id}.xlsx`);
        proxy.$modal.msgSuccess("导出成功");
      } else {
        const resText = await blobData.text();
        let rspObj = {};
        try {
          rspObj = JSON.parse(resText);
        } catch (error) {
          rspObj = {};
        }
        proxy.$modal.msgError(rspObj.msg || "导出失败");
      }
    } catch (error) {
      proxy.$modal.msgError("导出失败,请稍后重试");
    } finally {
      exportLoading.value = false;
    }
  };
  // ç¼ºå£ç”Ÿæˆé‡‡è´­éœ€æ±‚
  const handleGenerate = () => {
    const params = buildParams();
    if (!params) return;
    genLoading.value = true;
    generateDemand(params)
      .then(res => {
        proxy.$modal.msgSuccess(res?.data || "生成完成");
      })
      .catch(() => {})
      .finally(() => {
        genLoading.value = false;
      });
  };
  const handleClose = () => {
    demandQty.value = undefined;
    resultList.value = [];
  };
</script>
<style scoped lang="scss">
  .bom-info {
    display: flex;
    gap: 24px;
    margin-bottom: 16px;
    font-size: 13px;
    color: #606266;
  }
  .shortage {
    color: #d93025;
    font-weight: 600;
  }
</style>
src/views/productionManagement/productStructure/index.vue
@@ -63,6 +63,9 @@
    <StructureEdit v-if="showEdit"
                   v-model:show-model="showEdit"
                   :record="currentRow" />
    <!-- é½å¥—分析弹窗 -->
    <KitCheckDialog v-model:show-model="kitCheckVisible"
                    :bom="kitCheckRow" />
    <!-- æ–°å¢ž/编辑弹窗 -->
    <el-dialog v-model="dialogVisible"
               :title="operationType === 'add' ? '新增BOM' : '编辑BOM'"
@@ -138,11 +141,13 @@
    batchDelete,
    exportBom,
    downloadTemplate,
    changeStatus,
  } from "@/api/productionManagement/productBom.js";
  import { useRouter } from "vue-router";
  import { ElMessageBox } from "element-plus";
  import ProductSelectDialog from "@/views/basicData/product/ProductSelectDialog.vue";
  import ImportDialog from "@/components/Dialog/ImportDialog.vue";
  import KitCheckDialog from "@/views/productionManagement/productStructure/KitCheckDialog.vue";
  const router = useRouter();
  const { proxy } = getCurrentInstance();
@@ -175,6 +180,14 @@
      width: 100,
    },
    {
      label: "状态",
      prop: "status",
      dataType: "tag",
      width: 90,
      formatData: v => (v == 1 ? "启用" : "停用"),
      formatType: v => (v == 1 ? "success" : "info"),
    },
    {
      label: "备注",
      prop: "remark",
      minWidth: 160,
@@ -184,7 +197,7 @@
      label: "操作",
      align: "center",
      fixed: "right",
      width: 250,
      width: 330,
      operation: [
        {
          name: "复制",
@@ -198,6 +211,31 @@
          type: "text",
          clickFun: row => {
            handleEdit(row);
          },
        },
        {
          name: "齐套分析",
          type: "text",
          clickFun: row => {
            handleKitCheck(row);
          },
        },
        {
          name: "启用",
          type: "text",
          color: "#67C23A",
          showHide: row => row.status != 1,
          clickFun: row => {
            handleChangeStatus(row, 1);
          },
        },
        {
          name: "停用",
          type: "text",
          color: "#909399",
          showHide: row => row.status == 1,
          clickFun: row => {
            handleChangeStatus(row, 0);
          },
        },
        {
@@ -215,6 +253,8 @@
  const tableData = ref([]);
  const tableLoading = ref(false);
  const showEdit = ref(false);
  const kitCheckVisible = ref(false);
  const kitCheckRow = ref({});
  const selectedRows = ref([]);
  const currentRow = ref({});
  const dialogVisible = ref(false);
@@ -345,6 +385,34 @@
      .catch(() => {});
  };
  // å¯ç”¨/停用
  const handleChangeStatus = (row, status) => {
    const tip =
      status == 1
        ? "确认启用该BOM版本?同产品规格的其他启用版本将自动停用。"
        : "确认停用该BOM版本?";
    ElMessageBox.confirm(tip, "提示", {
      confirmButtonText: "确认",
      cancelButtonText: "取消",
      type: "warning",
    })
      .then(() => {
        changeStatus({ id: row.id, status })
          .then(() => {
            proxy.$modal.msgSuccess(status == 1 ? "启用成功" : "停用成功");
            getList();
          })
          .catch(() => {});
      })
      .catch(() => {});
  };
  // é½å¥—分析
  const handleKitCheck = row => {
    kitCheckRow.value = row;
    kitCheckVisible.value = true;
  };
  // ç¼–辑
  const handleEdit = row => {
    operationType.value = "edit";
src/views/productionManagement/productionCosting/index.vue
@@ -125,6 +125,11 @@
      minWidth: 100,
    },
    {
      label: "使用设备",
      prop: "deviceName",
      minWidth: 100,
    },
    {
      label: "工时(h)",
      prop: "workHour",
      minWidth: 100,
@@ -140,6 +145,14 @@
      minWidth: 100,
    },
    {
      label: "计薪方式",
      prop: "wageMode",
      minWidth: 100,
      dataType: "tag",
      formatData: v => (v === "计件" ? "计件" : "计时"),
      formatType: v => (v === "计件" ? "success" : "info"),
    },
    {
      label: "工资",
      prop: "wages",
      minWidth: 100,
src/views/productionManagement/productionReporting/index.vue
@@ -19,6 +19,19 @@
                    style="width: 200px;"
                    @change="handleQuery" />
        </el-form-item>
        <el-form-item label="使用设备:">
          <el-select v-model="searchForm.deviceId"
                     placeholder="请选择"
                     clearable
                     filterable
                     style="width: 200px;"
                     @change="handleQuery">
            <el-option v-for="item in deviceOptions"
                       :key="item.id"
                       :label="item.deviceName"
                       :value="item.id" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary"
                     @click="handleQuery">搜索</el-button>
@@ -162,6 +175,7 @@
  } from "@/api/productionManagement/productionReporting.js";
  import { productionProductMainListPage } from "@/api/productionManagement/productionProductMain.js";
  import { userListNoPageByTenantId } from "@/api/system/user.js";
  import { getDeviceLedger } from "@/api/equipmentManagement/ledger.js";
  import InputModal from "@/views/productionManagement/productionReporting/Input.vue";
  const data = reactive({
@@ -169,12 +183,14 @@
      nickName: "",
      workOrderNo: "",
      workOrderStatus: "",
      deviceId: "",
    },
  });
  const { searchForm } = toRefs(data);
  const expandedRowKeys = ref([]);
  const expandData = ref([]);
  const userList = ref([]);
  const deviceOptions = ref([]);
  const tableColumn = ref([
    {
      label: "报工单号",
@@ -195,6 +211,11 @@
      label: "工序",
      prop: "process",
      width: 120,
    },
    {
      label: "使用设备",
      prop: "deviceName",
      width: 140,
    },
    {
      label: "工单编号",
@@ -459,6 +480,14 @@
  };
  onMounted(() => {
    getList();
    getDeviceLedger()
      .then(res => {
        deviceOptions.value = res.data || [];
      })
      .catch(err => {
        console.error("获取设备列表失败", err);
        deviceOptions.value = [];
      });
  });
</script>
src/views/productionManagement/productionTraceability/index.vue
@@ -166,6 +166,9 @@
            <el-table-column label="创建人"
                             prop="userName"
                             align="center" />
            <el-table-column label="使用设备"
                             prop="deviceName"
                             align="center" />
            <el-table-column label="创建时间"
                             align="center">
              <template #default="{ row }">
src/views/productionManagement/workOrder/index.vue
@@ -209,6 +209,18 @@
                       :value="user.userId" />
          </el-select>
        </el-form-item>
        <el-form-item label="使用设备">
          <el-select v-model="reportForm.deviceId"
                     style="width: 300px"
                     placeholder="请选择使用设备"
                     clearable
                     filterable>
            <el-option v-for="item in deviceOptions"
                       :key="item.id"
                       :label="item.deviceName"
                       :value="item.id" />
          </el-select>
        </el-form-item>
      </el-form>
      <template #footer>
        <span class="dialog-footer">
@@ -233,6 +245,7 @@
    downProductWorkOrder,
  } from "@/api/productionManagement/workOrder.js";
  import { getUserProfile, userListNoPageByTenantId } from "@/api/system/user.js";
  import { getDeviceLedger } from "@/api/equipmentManagement/ledger.js";
  import QRCode from "qrcode";
  import { getCurrentInstance, reactive, toRefs } from "vue";
  import FilesDia from "./components/filesDia.vue";
@@ -356,6 +369,7 @@
  const workOrderFilesRef = ref(null);
  const reportFormRef = ref(null);
  const userOptions = ref([]);
  const deviceOptions = ref([]);
  const reportForm = reactive({
    planQuantity: 0,
    quantity: null,
@@ -365,6 +379,7 @@
    reportWork: "",
    productProcessRouteItemId: "",
    userId: "",
    deviceId: "",
    productMainId: null,
  });
@@ -596,6 +611,7 @@
  const showReportDialog = row => {
    currentReportRowData.value = row;
    reportForm.deviceId = "";
    reportForm.planQuantity = row.planQuantity - row.completeQuantity;
    reportForm.quantity =
      row.quantity !== undefined && row.quantity !== null ? row.quantity : null;
@@ -729,9 +745,22 @@
    }
  };
  // èŽ·å–è®¾å¤‡ä¸‹æ‹‰
  const getDeviceOptions = () => {
    getDeviceLedger()
      .then(res => {
        deviceOptions.value = res.data || [];
      })
      .catch(err => {
        console.error("获取设备列表失败", err);
        deviceOptions.value = [];
      });
  };
  onMounted(() => {
    getList();
    getUserList();
    getDeviceOptions();
  });
</script>
src/views/productionManagement/workOrderManagement/index.vue
@@ -168,6 +168,18 @@
                       :value="user.userId" />
          </el-select>
        </el-form-item>
        <el-form-item label="使用设备">
          <el-select v-model="reportForm.deviceId"
                     style="width: 300px"
                     placeholder="请选择使用设备"
                     clearable
                     filterable>
            <el-option v-for="item in deviceOptions"
                       :key="item.id"
                       :label="item.deviceName"
                       :value="item.id" />
          </el-select>
        </el-form-item>
        <!-- å·¥æ—¶ -->
        <el-form-item label="工时"
                      v-if="currentReportRowData?.type == 0"
@@ -280,6 +292,7 @@
  import { listMaterialPickingDetail } from "@/api/productionManagement/productionOrder.js";
  import { findProcessParamListOrder } from "@/api/productionManagement/productProcessRoute.js";
  import { getUserProfile, userListNoPageByTenantId } from "@/api/system/user.js";
  import { getDeviceLedger } from "@/api/equipmentManagement/ledger.js";
  import { getDicts } from "@/api/system/dict/data";
  import QRCode from "qrcode";
  import { getCurrentInstance, reactive, toRefs } from "vue";
@@ -426,6 +439,7 @@
  const currentWorkOrderId = ref(null);
  const reportFormRef = ref(null);
  const userOptions = ref([]);
  const deviceOptions = ref([]);
  const reportForm = reactive({
    planQuantity: 0,
    quantity: null,
@@ -435,6 +449,7 @@
    reportWork: "",
    productProcessRouteItemId: "",
    userId: "",
    deviceId: "",
    productMainId: null,
    productionOrderRoutingOperationId: "",
    productionOrderId: "",
@@ -694,6 +709,7 @@
      toQuantity(planQuantity - completeQuantity)
    );
    reportForm.planQuantity = remainingQuantity;
    reportForm.deviceId = "";
    reportForm.quantity = remainingQuantity;
    reportForm.productProcessRouteItemId = row.productProcessRouteItemId;
    reportForm.workOrderId = row.id;
@@ -801,6 +817,7 @@
        scrapQty: isNaN(scrapQty) ? 0 : scrapQty,
        userId: reportForm.userId,
        userName: reportForm.userName,
        deviceId: reportForm.deviceId || undefined,
        productionOperationTaskId: reportForm.workOrderId,
        productProcessRouteItemId: reportForm.productProcessRouteItemId,
        reportWork: reportForm.reportWork,
@@ -958,6 +975,15 @@
        userOptions.value = res.data;
      }
    });
    // èŽ·å–è®¾å¤‡åˆ—è¡¨
    getDeviceLedger()
      .then(res => {
        deviceOptions.value = res.data || [];
      })
      .catch(err => {
        console.error("获取设备列表失败", err);
        deviceOptions.value = [];
      });
  });
</script>
src/views/qualityManagement/finalInspection/components/formDia.vue
@@ -301,6 +301,10 @@
  // æ‰“开弹框
  const openDialog = async (type, row) => {
    operationType.value = type;
    // å·²æäº¤çš„æ£€éªŒå•强制只读
    if (row?.inspectState === 1) {
      operationType.value = "view";
    }
    dialogFormVisible.value = true;
    // å…ˆæ¸…空表单验证状态,避免闪烁
    await nextTick();
src/views/qualityManagement/finalInspection/index.vue
@@ -487,11 +487,23 @@
  // æä»·
  const submit = async id => {
    const res = await submitQualityInspect({ id: id });
    if (res.code === 200) {
      proxy.$modal.msgSuccess("提交成功");
      getList();
    }
    ElMessageBox.confirm(
      "提交后将生成入库记录且不可修改,确认提交?",
      "提示",
      { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
    )
      .then(async () => {
        const res = await submitQualityInspect({ id: id });
        if (res.code === 200) {
          proxy.$modal.msgSuccess(
            "提交成功,合格部分已生成入库记录(待审核),不合格部分已生成处理单"
          );
          getList();
        }
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
  };
  const handleBatchSubmit = () => {
src/views/qualityManagement/nonconformingManagement/components/formDia.vue
@@ -124,10 +124,19 @@
                         clearable>
                <el-option :label="item.label"
                           :value="item.value"
                           v-for="item in rejection_handling"
                           v-for="item in filteredRejectionHandling"
                           :key="item.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row v-if="isReworkResult">
          <el-col :span="24">
            <el-alert title="提交后将自动创建返工生产订单(FG单号)"
                      type="info"
                      :closable="false"
                      show-icon
                      style="margin-bottom: 12px" />
          </el-col>
        </el-row>
        <el-row :gutter="30">
@@ -224,6 +233,27 @@
  const modelOptions = ref([]);
  const userList = ref([]); // æ£€éªŒå‘˜/处理人下拉列表
  // åŽŸæ–™ç±»ï¼ˆinspectType=0)不合格只允许"报废/让步放行"
  const filteredRejectionHandling = computed(() => {
    const data = rejection_handling.value || [];
    if (form.value.inspectType === 0 || form.value.inspectType === "0") {
      return data.filter(
        item => item && (item.label === "报废" || item.label === "让步放行")
      );
    }
    return data;
  });
  // é€‰ä¸­"返工/返修"时的提示
  const isReworkResult = computed(() => {
    const item = (rejection_handling.value || []).find(
      i => i.value === form.value.dealResult
    );
    const label = item ? item.label : form.value.dealResult;
    return label === "返工" || label === "返修";
  });
  // æ‰“开弹框
  const openDialog = async (type, row) => {
    operationType.value = type;
src/views/qualityManagement/nonconformingManagement/components/inspectionFormDia.vue
@@ -87,6 +87,17 @@
            </el-form-item>
          </el-col>
        </el-row>
        <el-row v-if="isReworkResult">
          <el-col :span="24">
            <el-alert
                title="提交后将自动创建返工生产订单(FG单号)"
                type="info"
                :closable="false"
                show-icon
                style="margin-bottom: 12px"
            />
          </el-col>
        </el-row>
        <el-row :gutter="30">
          <el-col :span="12">
            <el-form-item label="处理人:" prop="dealName">
@@ -174,13 +185,24 @@
const userList = ref([]); // å¤„理人下拉列表
const filteredRejectionHandling = computed(() => {
  const data = rejection_handling.value;
  let data = rejection_handling.value;
  // åŽŸæ–™ç±»ä¸åˆæ ¼åªå…è®¸"报废/让步放行"
  if (form.value.inspectType === 0 || form.value.inspectType === "0") {
    data = data.filter(item => item && (item.label === '报废' || item.label === '让步放行'));
  }
  if (form.value.method) {
    return data.filter(item => item && item.label && item.label !== '返工' && item.label !== '返修')
  }
  return data
})
// é€‰ä¸­"返工/返修"时的提示
const isReworkResult = computed(() => {
  const item = (rejection_handling.value || []).find(i => i.value === form.value.dealResult);
  const label = item ? item.label : form.value.dealResult;
  return label === '返工' || label === '返修';
})
// æ‰“开弹框
const openDialog = async (type, row) => {
src/views/qualityManagement/processInspection/components/formDia.vue
@@ -319,6 +319,10 @@
  // æ‰“开弹框
  const openDialog = async (type, row) => {
    operationType.value = type;
    // å·²æäº¤çš„æ£€éªŒå•强制只读
    if (row?.inspectState === 1) {
      operationType.value = "view";
    }
    getOptions().then(res => {
      supplierList.value = res.data;
    });
src/views/qualityManagement/processInspection/index.vue
@@ -401,11 +401,23 @@
  };
  // æä»·
  const submit = async id => {
    const res = await submitQualityInspect({ id: id });
    if (res.code === 200) {
      proxy.$modal.msgSuccess("提交成功");
      getList();
    }
    ElMessageBox.confirm(
      "提交后将生成入库记录且不可修改,确认提交?",
      "提示",
      { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
    )
      .then(async () => {
        const res = await submitQualityInspect({ id: id });
        if (res.code === 200) {
          proxy.$modal.msgSuccess(
            "提交成功,合格部分已生成入库记录(待审核),不合格部分已生成处理单"
          );
          getList();
        }
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
  };
  const handleBatchSubmit = () => {
src/views/qualityManagement/qualityTraceability/components/ForwardResult.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,432 @@
<template>
  <div v-loading="loading"
       class="forward-result">
    <!-- è®¢å•卡 -->
    <div v-for="chain in data.chains"
         :key="chain.productionOrderId"
         class="order-card">
      <div class="order-head">
        <div class="order-title">
          <el-link type="primary"
                   @click="$emit('goto-order', chain.npsNo)">{{ chain.npsNo }}</el-link>
          <span class="product-name">{{ chain.finishedProduct || '-' }}</span>
        </div>
        <el-tag :type="statusType(chain.orderStatus)">{{ statusText(chain.orderStatus) }}</el-tag>
      </div>
      <div class="order-meta">
        <span>计划数量:{{ chain.planQty ?? '-' }} {{ chain.unit || '' }}</span>
        <span>完工数量:{{ chain.completeQty ?? '-' }} {{ chain.unit || '' }}</span>
        <span>下单时间:{{ chain.orderCreateTime || '-' }}</span>
      </div>
      <div v-if="chain.batches && chain.batches.length"
           class="hit-batches">
        <span class="hit-label">命中入库批次:</span>
        <el-tag v-for="batch in chain.batches"
                :key="batch.batchNo"
                type="success"
                effect="plain">
          {{ batch.batchNo }}({{ batch.qty }} {{ chain.unit || '' }},{{ batch.inTime }},{{ recordTypeLabel(batch.recordType) }})
        </el-tag>
      </div>
      <el-timeline class="order-timeline">
        <!-- å·¥åºæŠ¥å·¥ -->
        <el-timeline-item v-for="report in sortReports(chain.reports)"
                          :key="report.productMainId + '_' + report.workOrderNo"
                          :timestamp="report.reportTime"
                          type="primary"
                          placement="top">
          <div class="node-title">工序报工:{{ report.operationName || '-' }}</div>
          <el-descriptions :column="3"
                           border
                           size="small">
            <el-descriptions-item label="工单编号">{{ report.workOrderNo || '-' }}</el-descriptions-item>
            <el-descriptions-item label="报工单号">{{ report.productNo || '-' }}</el-descriptions-item>
            <el-descriptions-item label="报工人">{{ report.userName || '-' }}</el-descriptions-item>
            <el-descriptions-item label="设备">{{ report.deviceName || '-' }}</el-descriptions-item>
            <el-descriptions-item label="产出数量">{{ report.outputQty ?? '-' }}</el-descriptions-item>
            <el-descriptions-item label="报废数量">{{ report.scrapQty ?? '-' }}</el-descriptions-item>
          </el-descriptions>
          <el-collapse class="input-collapse"
                       :model-value="expandedInputs[reportKey(chain, report)] || []"
                       @update:model-value="val => (expandedInputs[reportKey(chain, report)] = val)">
            <el-collapse-item name="inputs"
                              :title="`投入物料(${inputsFor(chain, report).length})`">
              <el-table :data="inputsFor(chain, report)"
                        border
                        size="small">
                <el-table-column label="物料名称"
                                 prop="materialName"
                                 align="center" />
                <el-table-column label="物料规格"
                                 prop="materialModel"
                                 align="center" />
                <el-table-column label="投入数量"
                                 prop="inputQty"
                                 align="center" />
                <el-table-column label="单位"
                                 prop="unit"
                                 align="center" />
              </el-table>
            </el-collapse-item>
          </el-collapse>
        </el-timeline-item>
        <!-- é¢†æ–™æ‰¹æ¬¡ï¼ˆè®¢å•级) -->
        <el-timeline-item v-if="chain.picks && chain.picks.length"
                          type="warning"
                          placement="top">
          <div class="node-title">领料批次</div>
          <el-table :data="chain.picks"
                    border
                    size="small">
            <el-table-column label="原料批号"
                             align="center"
                             min-width="200">
              <template #default="{ row }">
                <el-link type="primary"
                         @click="$emit('pick-batch', row.batchNo)">{{ row.batchNo }}</el-link>
              </template>
            </el-table-column>
            <el-table-column label="物料"
                             prop="materialName"
                             align="center" />
            <el-table-column label="物料规格"
                             prop="materialModel"
                             align="center" />
            <el-table-column label="领料数量"
                             align="center">
              <template #default="{ row }">{{ row.quantity }} {{ row.unit || '' }}</template>
            </el-table-column>
            <el-table-column label="工序"
                             prop="operationName"
                             align="center" />
            <el-table-column label="领料时间"
                             prop="pickTime"
                             align="center" />
            <el-table-column label="供应商"
                             align="center">
              <template #default="{ row }">{{ row.supplierName || '-' }}</template>
            </el-table-column>
            <el-table-column label="采购合同号"
                             align="center">
              <template #default="{ row }">{{ row.contractNo || '-' }}</template>
            </el-table-column>
            <el-table-column label="原料检验结论"
                             align="center">
              <template #default="{ row }">{{ row.checkResult || '-' }}</template>
            </el-table-column>
          </el-table>
        </el-timeline-item>
        <!-- æ£€éªŒå• -->
        <el-timeline-item v-if="chain.inspects && chain.inspects.length"
                          type="success"
                          placement="top">
          <div class="node-title">检验单</div>
          <el-table :data="chain.inspects"
                    border
                    size="small">
            <el-table-column label="检验类型"
                             prop="inspectTypeName"
                             align="center" />
            <el-table-column label="工序"
                             align="center">
              <template #default="{ row }">{{ row.process || '-' }}</template>
            </el-table-column>
            <el-table-column label="检验员"
                             prop="checkName"
                             align="center" />
            <el-table-column label="产品名称"
                             prop="productName"
                             align="center" />
            <el-table-column label="规格型号"
                             prop="model"
                             align="center" />
            <el-table-column label="数量"
                             prop="quantity"
                             align="center" />
            <el-table-column label="合格数量"
                             prop="qualifiedQuantity"
                             align="center" />
            <el-table-column label="不合格数量"
                             prop="unqualifiedQuantity"
                             align="center" />
            <el-table-column label="检测结果"
                             align="center">
              <template #default="{ row }">
                <el-tag :type="row.checkResult === '合格' ? 'success' : 'danger'">
                  {{ row.checkResult || '待检测' }}
                </el-tag>
              </template>
            </el-table-column>
            <el-table-column label="检测日期"
                             prop="checkTime"
                             align="center" />
            <el-table-column label="操作"
                             align="center"
                             width="100">
              <template #default="{ row }">
                <el-link type="primary"
                         @click="openInspect(chain, row)">查看详情</el-link>
              </template>
            </el-table-column>
          </el-table>
        </el-timeline-item>
      </el-timeline>
    </div>
    <!-- å‡ºåº“流向(顶层,按批号聚合) -->
    <div v-if="data.outbounds && data.outbounds.length"
         class="order-card">
      <div class="order-head">
        <div class="order-title">
          <span class="product-name">出库流向</span>
        </div>
      </div>
      <el-table :data="data.outbounds"
                border
                size="small">
        <el-table-column label="批号"
                         prop="batchNo"
                         align="center" />
        <el-table-column label="产品名称"
                         prop="productName"
                         align="center" />
        <el-table-column label="规格型号"
                         prop="productModel"
                         align="center" />
        <el-table-column label="出库数量"
                         align="center">
          <template #default="{ row }">{{ row.qty }} {{ row.unit || '' }}</template>
        </el-table-column>
        <el-table-column label="出库类型"
                         align="center">
          <template #default="{ row }">{{ recordTypeLabel(row.recordType) }}</template>
        </el-table-column>
        <el-table-column label="出库时间"
                         prop="outTime"
                         align="center" />
        <el-table-column label="备注"
                         align="center">
          <template #default="{ row }">{{ row.remark || '-' }}</template>
        </el-table-column>
      </el-table>
    </div>
    <!-- æ£€éªŒå•详情 -->
    <el-dialog v-model="inspectVisible"
               title="检验单详情"
               width="900px">
      <div v-if="currentInspect"
           class="inspect-detail">
        <el-descriptions :column="3"
                         border
                         size="small">
          <el-descriptions-item label="检验类型">{{ currentInspect.inspectTypeName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="工序">{{ currentInspect.process || '-' }}</el-descriptions-item>
          <el-descriptions-item label="检验员">{{ currentInspect.checkName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="产品名称">{{ currentInspect.productName || '-' }}</el-descriptions-item>
          <el-descriptions-item label="规格型号">{{ currentInspect.model || '-' }}</el-descriptions-item>
          <el-descriptions-item label="单位">{{ currentInspect.unit || '-' }}</el-descriptions-item>
          <el-descriptions-item label="数量">{{ currentInspect.quantity ?? '-' }}</el-descriptions-item>
          <el-descriptions-item label="合格数量">{{ currentInspect.qualifiedQuantity ?? '-' }}</el-descriptions-item>
          <el-descriptions-item label="不合格数量">{{ currentInspect.unqualifiedQuantity ?? '-' }}</el-descriptions-item>
          <el-descriptions-item label="检测结果">
            <el-tag :type="currentInspect.checkResult === '合格' ? 'success' : 'danger'">
              {{ currentInspect.checkResult || '待检测' }}
            </el-tag>
          </el-descriptions-item>
          <el-descriptions-item label="检测日期">{{ currentInspect.checkTime || '-' }}</el-descriptions-item>
          <el-descriptions-item label="不合格现象">{{ currentInspect.defectivePhenomena || '-' }}</el-descriptions-item>
        </el-descriptions>
        <h4 class="sub-title">不合格处理</h4>
        <el-table :data="currentUnqualified"
                  border
                  size="small">
          <el-table-column label="序号"
                           type="index"
                           width="60"
                           align="center" />
          <el-table-column label="产品名称"
                           prop="productName"
                           align="center" />
          <el-table-column label="规格型号"
                           prop="model"
                           align="center" />
          <el-table-column label="数量"
                           prop="quantity"
                           align="center" />
          <el-table-column label="不合格现象"
                           prop="defectivePhenomena"
                           align="center" />
          <el-table-column label="处理结果"
                           prop="dealResult"
                           align="center" />
          <el-table-column label="处理人"
                           prop="dealName"
                           align="center" />
          <el-table-column label="处理日期"
                           prop="dealTime"
                           align="center" />
        </el-table>
      </div>
      <template #footer>
        <el-button @click="inspectVisible = false">关闭</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
  import { ref, reactive } from "vue";
  defineProps({
    data: {
      type: Object,
      required: true,
    },
    loading: {
      type: Boolean,
      default: false,
    },
  });
  defineEmits(["goto-order", "pick-batch"]);
  const statusTypeMap = { 1: "primary", 2: "warning", 3: "success", 5: "danger" };
  const statusTextMap = { 1: "待开始", 2: "进行中", 3: "已完成", 5: "已结束" };
  const statusType = status => statusTypeMap[status] || "info";
  const statusText = status => statusTextMap[status] || "已取消";
  const inTypeMap = {
    0: "自定义入库",
    2: "生产报工入库",
    6: "质检合格入库",
    7: "采购入库",
    10: "采购入库",
  };
  const outTypeMap = {
    1: "自定义出库",
    3: "生产报工出库",
    8: "销售出库",
    13: "销售发货出库",
  };
  const recordTypeLabel = type => {
    const key = String(type ?? "");
    return inTypeMap[key] || outTypeMap[key] || key || "-";
  };
  const sortReports = reports =>
    [...(reports || [])].sort((a, b) =>
      String(a.reportTime || "").localeCompare(String(b.reportTime || ""))
    );
  const inputsFor = (chain, report) =>
    (chain.inputs || []).filter(
      item => String(item.productMainId) === String(report.productMainId)
    );
  const reportKey = (chain, report) =>
    `${chain.productionOrderId}_${report.productMainId}_${report.workOrderNo}`;
  const expandedInputs = reactive({});
  // æ£€éªŒå•详情
  const inspectVisible = ref(false);
  const currentInspect = ref(null);
  const currentUnqualified = ref([]);
  const openInspect = (chain, inspect) => {
    currentInspect.value = inspect;
    const own = inspect.unqualifiedList || [];
    currentUnqualified.value = own.length
      ? own
      : (chain.unqualifiedList || []).filter(
          item => String(item.inspectId) === String(inspect.inspectId)
        );
    inspectVisible.value = true;
  };
</script>
<style scoped lang="scss">
  .forward-result {
    min-height: 200px;
  }
  .order-card {
    background-color: #ffffff;
    border-radius: 10px;
    padding: 20px;
    margin-bottom: 20px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
  }
  .order-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 12px;
    border-bottom: 1px solid #ebeef5;
  }
  .order-title {
    display: flex;
    align-items: center;
    gap: 12px;
    font-size: 16px;
  }
  .product-name {
    font-weight: 600;
    color: #1a1a1a;
  }
  .order-meta {
    display: flex;
    gap: 28px;
    padding: 12px 0;
    font-size: 13px;
    color: #606266;
  }
  .hit-batches {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
    background-color: #f0f9eb;
    border-radius: 6px;
    .hit-label {
      font-size: 13px;
      color: #606266;
    }
  }
  .order-timeline {
    margin-top: 20px;
    padding-left: 4px;
  }
  .node-title {
    font-size: 14px;
    font-weight: 600;
    color: #303133;
    margin-bottom: 12px;
  }
  .input-collapse {
    margin-top: 12px;
  }
  .sub-title {
    margin: 20px 0 12px;
    font-size: 14px;
    font-weight: 600;
    color: #303133;
  }
</style>
src/views/qualityManagement/qualityTraceability/components/ReverseTable.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,171 @@
<template>
  <div v-loading="loading"
       class="reverse-result">
    <!-- åŽŸæ–™æ¥æº -->
    <div class="material-card">
      <h3 class="section-title">原料来源</h3>
      <el-descriptions v-if="data.material"
                       :column="4"
                       border
                       size="small">
        <el-descriptions-item label="原料批号">{{ data.material.batchNo || '-' }}</el-descriptions-item>
        <el-descriptions-item label="物料">{{ data.material.materialName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="物料规格">{{ data.material.materialModel || '-' }}</el-descriptions-item>
        <el-descriptions-item label="入库类型">{{ recordTypeLabel(data.material.recordType) }}</el-descriptions-item>
        <el-descriptions-item label="入库数量">{{ data.material.qty ?? '-' }} {{ data.material.unit || '' }}</el-descriptions-item>
        <el-descriptions-item label="入库时间">{{ data.material.inTime || '-' }}</el-descriptions-item>
        <el-descriptions-item label="供应商">{{ data.material.supplierName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="采购合同号">{{ data.material.contractNo || '-' }}</el-descriptions-item>
        <el-descriptions-item label="采购日期">{{ data.material.entryDate || '-' }}</el-descriptions-item>
        <el-descriptions-item label="检验数量">{{ data.material.inspectQty ?? '-' }}</el-descriptions-item>
        <el-descriptions-item label="合格数量">{{ data.material.qualifiedQty ?? '-' }}</el-descriptions-item>
        <el-descriptions-item label="不合格数量">{{ data.material.unqualifiedQty ?? '-' }}</el-descriptions-item>
        <el-descriptions-item label="检验结论">{{ data.material.checkResult || '-' }}</el-descriptions-item>
        <el-descriptions-item label="检验日期">{{ data.material.checkTime || '-' }}</el-descriptions-item>
      </el-descriptions>
      <el-alert v-else
                type="info"
                :closable="false"
                show-icon
                description="该批号无入库/采购来源记录" />
    </div>
    <!-- å—影响订单 -->
    <div class="order-card">
      <h3 class="section-title">受影响生产订单({{ data.orders.length }})</h3>
      <PIMTable rowKey="productionOrderId"
                :column="tableColumn"
                :tableData="data.orders"
                :page="{ total: data.orders.length }"
                :isShowPagination="false"
                height="520"
                :tableLoading="loading">
        <template #npsNo="{ row }">
          <el-link type="primary"
                   @click="$emit('goto-order', row.npsNo)">{{ row.npsNo }}</el-link>
        </template>
        <template #orderStatus="{ row }">
          <el-tag :type="statusType(row.orderStatus)">{{ statusText(row.orderStatus) }}</el-tag>
        </template>
        <template #finishedBatches="{ row }">
          <template v-if="row.finishedBatches && row.finishedBatches.length">
            <el-tag v-for="batch in row.finishedBatches"
                    :key="batch.batchNo"
                    class="batch-tag"
                    @click="$emit('goto-forward', batch.batchNo)">{{ batch.batchNo }}</el-tag>
          </template>
          <span v-else>-</span>
        </template>
      </PIMTable>
    </div>
  </div>
</template>
<script setup>
  import PIMTable from "@/components/PIMTable/PIMTable.vue";
  defineProps({
    data: {
      type: Object,
      required: true,
    },
    loading: {
      type: Boolean,
      default: false,
    },
  });
  defineEmits(["goto-order", "goto-forward"]);
  const tableColumn = [
    {
      label: "生产订单号",
      prop: "npsNo",
      dataType: "slot",
      slot: "npsNo",
      minWidth: 160,
    },
    {
      label: "成品",
      prop: "finishedProduct",
      minWidth: 220,
    },
    {
      label: "计划数量",
      prop: "planQty",
      width: 100,
    },
    {
      label: "完工数量",
      prop: "completeQty",
      width: 100,
    },
    {
      label: "订单状态",
      prop: "orderStatus",
      dataType: "slot",
      slot: "orderStatus",
      width: 100,
    },
    {
      label: "领料数量",
      prop: "pickedQty",
      width: 110,
    },
    {
      label: "产出成品批号",
      prop: "finishedBatches",
      dataType: "slot",
      slot: "finishedBatches",
      minWidth: 260,
    },
  ];
  const statusTypeMap = { 1: "primary", 2: "warning", 3: "success", 5: "danger" };
  const statusTextMap = { 1: "待开始", 2: "进行中", 3: "已完成", 5: "已结束" };
  const statusType = status => statusTypeMap[status] || "info";
  const statusText = status => statusTextMap[status] || "已取消";
  const inTypeMap = {
    0: "自定义入库",
    2: "生产报工入库",
    6: "质检合格入库",
    7: "采购入库",
    10: "采购入库",
  };
  const outTypeMap = {
    1: "自定义出库",
    3: "生产报工出库",
    8: "销售出库",
    13: "销售发货出库",
  };
  const recordTypeLabel = type => {
    const key = String(type ?? "");
    return inTypeMap[key] || outTypeMap[key] || key || "-";
  };
</script>
<style scoped lang="scss">
  .material-card,
  .order-card {
    background-color: #ffffff;
    border-radius: 10px;
    padding: 20px;
    margin-bottom: 20px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
  }
  .section-title {
    font-size: 16px;
    font-weight: 600;
    margin-bottom: 16px;
    color: #1a1a1a;
    border-bottom: 2px solid #409eff;
    padding-bottom: 10px;
  }
  .batch-tag {
    margin: 2px 6px 2px 0;
    cursor: pointer;
  }
</style>
src/views/qualityManagement/qualityTraceability/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,305 @@
<template>
  <div class="app-container">
    <PageHeader content="质量追溯" />
    <el-card class="trace-card">
      <el-tabs v-model="activeTab"
               @tab-change="handleTabChange">
        <el-tab-pane label="正向追溯(成品 â†’ åŽŸæ–™ï¼‰"
                     name="forward" />
        <el-tab-pane label="反向追溯(原料 â†’ æˆå“ï¼‰"
                     name="reverse" />
      </el-tabs>
      <div class="search-bar">
        <el-select v-model="batchNo"
                   filterable
                   remote
                   reserve-keyword
                   clearable
                   :placeholder="placeholder"
                   :loading="batchLoading"
                   :remote-method="handleBatchSearch"
                   style="width: 460px;"
                   @change="handleBatchChange">
          <el-option v-for="option in batchOpts"
                     :key="option.batchNo"
                     :value="option.batchNo"
                     :label="optionLabel(option)" />
          <template #footer>
            <div v-if="batchHasMore"
                 class="select-load-more"
                 @click="loadMoreBatch">
              <span v-if="batchLoading">加载中...</span>
              <span v-else>点击加载更多</span>
            </div>
            <div v-else-if="batchOpts.length > 0"
                 class="select-load-more no-more">
              æ²¡æœ‰æ›´å¤šäº†
            </div>
          </template>
        </el-select>
        <el-button type="primary"
                   :loading="loading"
                   @click="handleQuery">查询</el-button>
        <el-button :loading="exportLoading"
                   @click="handleExport">导出</el-button>
      </div>
      <el-empty v-if="!queried"
                description="请输入或选择批号后点击查询" />
      <el-empty v-else-if="isEmptyResult"
                description="未查询到该批号的追溯记录" />
      <ForwardResult v-else-if="isForward"
                     :data="forwardData"
                     :loading="loading"
                     @goto-order="gotoOrder"
                     @pick-batch="goReverse" />
      <ReverseTable v-else
                    :data="reverseData"
                    :loading="loading"
                    @goto-order="gotoOrder"
                    @goto-forward="goForward" />
    </el-card>
  </div>
</template>
<script setup>
  import { ref, reactive, computed, onMounted } from "vue";
  import { ElMessage } from "element-plus";
  import { useRouter } from "vue-router";
  import { saveAs } from "file-saver";
  import { blobValidate } from "@/utils/ruoyi";
  import ForwardResult from "./components/ForwardResult.vue";
  import ReverseTable from "./components/ReverseTable.vue";
  import {
    forwardTrace,
    reverseTrace,
    batchOptions,
    forwardExport,
    reverseExport,
  } from "@/api/qualityManagement/qualityTrace";
  const router = useRouter();
  const BATCH_PAGE = 50;
  const BATCH_MAX = 200;
  const activeTab = ref("forward");
  const isForward = computed(() => activeTab.value === "forward");
  const batchType = computed(() => (isForward.value ? "finished" : "material"));
  const placeholder = computed(() =>
    isForward.value ? "请输入成品批号搜索" : "请输入原料批号搜索"
  );
  // æ‰¹å·ä¸‹æ‹‰
  const batchNo = ref("");
  const batchOpts = ref([]);
  const batchLoading = ref(false);
  const batchKeyword = ref("");
  const batchLimit = ref(BATCH_PAGE);
  const batchHasMore = computed(
    () => batchOpts.value.length >= batchLimit.value && batchLimit.value < BATCH_MAX
  );
  const loading = ref(false);
  const exportLoading = ref(false);
  const queried = ref(false);
  const forwardData = reactive({ batchNo: "", chains: [], outbounds: [] });
  const reverseData = reactive({ batchNo: "", material: null, orders: [] });
  const isEmptyResult = computed(() => {
    if (!queried.value) return false;
    return isForward.value
      ? forwardData.chains.length === 0 && forwardData.outbounds.length === 0
      : !reverseData.material && reverseData.orders.length === 0;
  });
  const optionLabel = option =>
    [option.batchNo, option.productName, option.productModel]
      .filter(Boolean)
      .join(" | ");
  // æŽ¥å£æ—  offset,只能放大 limit åŽæ•´ä½“重取
  const fetchBatchOptions = async () => {
    batchLoading.value = true;
    try {
      const res = await batchOptions(
        batchType.value,
        batchKeyword.value,
        batchLimit.value
      );
      batchOpts.value = res.code === 200 ? res.data || [] : [];
    } catch (error) {
      batchOpts.value = [];
    } finally {
      batchLoading.value = false;
    }
  };
  const handleBatchSearch = keyword => {
    batchKeyword.value = keyword || "";
    batchLimit.value = BATCH_PAGE;
    fetchBatchOptions();
  };
  const loadMoreBatch = () => {
    if (batchLoading.value || !batchHasMore.value) return;
    batchLimit.value = Math.min(batchLimit.value + BATCH_PAGE, BATCH_MAX);
    fetchBatchOptions();
  };
  const handleBatchChange = value => {
    if (value) handleQuery();
  };
  const resetResult = () => {
    forwardData.batchNo = "";
    forwardData.chains = [];
    forwardData.outbounds = [];
    reverseData.batchNo = "";
    reverseData.material = null;
    reverseData.orders = [];
  };
  const handleTabChange = () => {
    batchNo.value = "";
    batchOpts.value = [];
    queried.value = false;
    resetResult();
    handleBatchSearch("");
  };
  const handleQuery = async () => {
    const keyword = (batchNo.value || "").trim();
    if (!keyword) {
      ElMessage.warning("请输入或选择批号");
      return;
    }
    queried.value = true;
    loading.value = true;
    try {
      if (isForward.value) {
        const res = await forwardTrace(keyword);
        if (res.code === 200) {
          forwardData.batchNo = res.data?.batchNo || keyword;
          forwardData.chains = res.data?.chains || [];
          forwardData.outbounds = res.data?.outbounds || [];
        }
      } else {
        const res = await reverseTrace(keyword);
        if (res.code === 200) {
          reverseData.batchNo = res.data?.batchNo || keyword;
          reverseData.material = res.data?.material || null;
          reverseData.orders = res.data?.orders || [];
        }
      }
    } catch (error) {
      // éž 200(如"批号不能为空")已由响应拦截器提示
    } finally {
      loading.value = false;
    }
  };
  const handleExport = async () => {
    const keyword = (batchNo.value || "").trim();
    if (!keyword) {
      ElMessage.warning("请先选择或输入批号");
      return;
    }
    exportLoading.value = true;
    try {
      const blobData = isForward.value
        ? await forwardExport(keyword)
        : await reverseExport(keyword);
      if (blobValidate(blobData)) {
        if (blobData.size === 0) {
          ElMessage.warning("暂无数据可导出");
          return;
        }
        const blob = new Blob([blobData], {
          type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        });
        const name = isForward.value ? "成品批次追溯" : "原料批次流向追溯";
        saveAs(blob, `${name}_${keyword}.xlsx`);
        ElMessage.success("导出成功");
      } else {
        const resText = await blobData.text();
        let rspObj = {};
        try {
          rspObj = JSON.parse(resText);
        } catch (error) {
          rspObj = {};
        }
        ElMessage.error(rspObj.msg || "导出失败");
      }
    } catch (error) {
      ElMessage.error("导出失败,请稍后重试");
    } finally {
      exportLoading.value = false;
    }
  };
  const gotoOrder = npsNo => {
    if (!npsNo) return;
    router.push({
      path: "/productionManagement/productionTraceability",
      query: { npsNo },
    });
  };
  const goReverse = batch => {
    if (!batch) return;
    activeTab.value = "reverse";
    handleTabChange();
    batchNo.value = batch;
    handleQuery();
  };
  const goForward = batch => {
    if (!batch) return;
    activeTab.value = "forward";
    handleTabChange();
    batchNo.value = batch;
    handleQuery();
  };
  onMounted(() => {
    handleBatchSearch("");
  });
</script>
<style scoped lang="scss">
  .trace-card {
    min-height: 82vh;
  }
  .search-bar {
    display: flex;
    align-items: center;
    gap: 12px;
    margin: 8px 0 20px;
  }
  .select-load-more {
    text-align: center;
    padding: 8px 0;
    font-size: 13px;
    color: #409eff;
    cursor: pointer;
    border-top: 1px solid #ebeef5;
    &:hover {
      background-color: #f5f7fa;
    }
    &.no-more {
      color: #c0c4cc;
      cursor: default;
      &:hover {
        background-color: transparent;
      }
    }
  }
</style>
src/views/qualityManagement/rawMaterialInspection/components/formDia.vue
@@ -327,6 +327,10 @@
  // æ‰“开弹框
  const openDialog = async (type, row) => {
    operationType.value = type;
    // å·²æäº¤çš„æ£€éªŒå•强制只读
    if (row?.inspectState === 1) {
      operationType.value = "view";
    }
    getOptions().then(res => {
      supplierList.value = res.data;
    });
src/views/qualityManagement/rawMaterialInspection/index.vue
@@ -430,11 +430,23 @@
  // æä»·
  const submit = async id => {
    const res = await submitQualityInspect({ id: id });
    if (res.code === 200) {
      proxy.$modal.msgSuccess("提交成功");
      getList();
    }
    ElMessageBox.confirm(
      "提交后将生成入库记录且不可修改,确认提交?",
      "提示",
      { confirmButtonText: "确认", cancelButtonText: "取消", type: "warning" }
    )
      .then(async () => {
        const res = await submitQualityInspect({ id: id });
        if (res.code === 200) {
          proxy.$modal.msgSuccess(
            "提交成功,合格部分已生成入库记录(待审核),不合格部分已生成处理单"
          );
          getList();
        }
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
  };
  // å…³é—­å¼¹æ¡†
vite.config.js
@@ -8,7 +8,7 @@
  const { VITE_APP_ENV } = env;
  const baseUrl =
      env.VITE_APP_ENV === "development"
          ? "http://localhost:7005"
          ? "http://192.168.0.10:7005"
          : env.VITE_BASE_API;
  const javaUrl =
      env.VITE_APP_ENV === "development"