spring
5 天以前 e9066f0c6478091278cca7131347dbc896e73278
fix: 完善时效领料功能
已修改6个文件
432 ■■■■■ 文件已修改
src/api/timeSensitiveTask.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/index/index.vue 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/timely/detail.vue 368 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/timely/index.vue 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/timely/inspection.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/timely/materialDetail.vue 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/timeSensitiveTask.ts
@@ -51,6 +51,16 @@
  ironPlateNo: number | string;
}
export interface TimeSensitiveScannedMaterial {
  id: number | string;
  materialType: TimelinessMaterialType | string;
  batchCode: string;
  available: boolean;
  unavailableReason?: string | null;
  qualitativeProductionId?: number | string;
  [key: string]: any;
}
export interface TimelinessInspectionDetailForm {
  id?: number | string;
  routingInspectionUser?: string;
@@ -755,6 +765,23 @@
    });
  },
  /** 时效领用扫码:单丝、绞线统一按流转卡批次号查询。 */
  scanByBatchCode(data: { batchCode: string }) {
    if (USE_TIME_SENSITIVE_MOCK) {
      const material = [...mockMaterialPool.单丝, ...mockMaterialPool.绞线].find(
        (item) => mockMaterialCode(item) === data.batchCode
      );
      return mockResult<TimeSensitiveScannedMaterial | null>(
        material ? { ...material, batchCode: mockMaterialCode(material), available: true } : null
      );
    }
    return request<BaseResult<TimeSensitiveScannedMaterial>>({
      url: "/timeSensitiveTask/scanByBatchCode",
      method: "POST",
      data,
    });
  },
  addItems(data: {
    taskId: number | string;
    itemIds: Array<number | string>;
src/pages/index/index.vue
@@ -234,7 +234,7 @@
    icon: "/static/icons/log.png",
    title: "时效",
    url: "/pages/timely/index",
    show: true,
    show: false,
  },
]);
@@ -295,14 +295,14 @@
    }
  }
  if (
    (data.deviceGroupName && data.deviceGroupName.includes("丝")) ||
    (data.deviceGroupName && data.deviceGroupName.includes("绞")) ||
    isAdmin.value
  ) {
    // 如果是丝或绞,显示生产管理菜单
    navList[0].show = true;
  }
  const deviceGroupName = String(data.deviceGroupName || "");
  const canAccessProduction =
    deviceGroupName.includes("丝") || deviceGroupName.includes("绞") || isAdmin.value;
  const canAccessTimely = deviceGroupName.includes("时效") || isAdmin.value;
  const productionNav = navList.find((item) => item.url === "/pages/production/index");
  const timelyNav = navList.find((item) => item.url === "/pages/timely/index");
  if (productionNav) productionNav.show = canAccessProduction;
  if (timelyNav) timelyNav.show = canAccessTimely;
  console.log("isInspector.value", isInspector.value);
  if (isInspector.value || isAdmin.value) {
    // 如果是巡检员,显示巡检菜单
src/pages/timely/detail.vue
@@ -150,9 +150,21 @@
              :value="String(getCandidateWeight(item))"
            />
          </wd-cell-group>
          <view class="material-detail-link" @click="openMaterialDetail(item)">
            <text>查看详情</text>
            <wd-icon name="arrow-right" size="14px" color="#0d867f" />
          <view class="received-item-actions">
            <wd-button
              v-if="isWaiting"
              type="error"
              plain
              custom-class="delete-received-button"
              :loading="String(deletingItemId) === String(getCandidateId(item))"
              @click="deleteReceivedItem(item)"
            >
              删除领用
            </wd-button>
            <view class="material-detail-link" @click="openMaterialDetail(item)">
              <text>查看详情</text>
              <wd-icon name="arrow-right" size="16px" color="#0d867f" />
            </view>
          </view>
        </wd-card>
      </view>
@@ -170,7 +182,7 @@
          <view>
            <text class="popup-title">选择铁架号</text>
          </view>
          <wd-icon name="close" size="20px" @click="rackSelectorVisible = false" />
          <wd-icon name="close" size="20px" @click="closeRackSelector" />
        </view>
        <scroll-view class="rack-option-list" scroll-y>
@@ -203,7 +215,7 @@
              type="info"
              plain
              custom-class="footer-action-button"
              @click="rackSelectorVisible = false"
              @click="closeRackSelector"
            >
              取消
            </wd-button>
@@ -279,8 +291,8 @@
                label="铁牌序号"
                type="number"
                required
                :readonly="materialType === '单丝'"
                placeholder="请输入正整数"
                readonly
                placeholder="自动生成"
              />
              <wd-cell title="规格型号" :value="candidate.model || '-'" />
              <wd-cell
@@ -342,30 +354,28 @@
    </wd-popup>
    <Scan ref="scanRef" emitName="scanTimeSensitiveItem" />
    <wd-message-box />
    <wd-toast />
  </view>
</template>
<script setup lang="ts">
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { useToast } from "wot-design-uni";
import { useMessage, useToast } from "wot-design-uni";
import type { TagType } from "wot-design-uni/components/wd-tag/types";
import CardTitle from "@/components/card-title/index.vue";
import Scan from "@/components/scan/index.vue";
import TimeSensitiveTaskApi, {
  USE_TIME_SENSITIVE_MOCK,
  type TimelinessMaterialType,
} from "@/api/timeSensitiveTask";
import TwistApi from "@/api/product/twist";
import OutboundApi from "@/api/product/outbound";
import TimeSensitiveTaskApi, { type TimelinessMaterialType } from "@/api/timeSensitiveTask";
const toast = useToast();
const message = useMessage();
const scanRef = ref();
const taskId = ref<number | string>("");
const task = ref<any>({});
const items = ref<any[]>([]);
const loading = ref(false);
const scanSubmitting = ref(false);
const deletingItemId = ref<number | string>("");
const pageVisible = ref(false);
const candidatePopupVisible = ref(false);
const candidateLoading = ref(false);
@@ -382,6 +392,8 @@
const rackSelectionRows = ref<Array<{ label: string; value: string; selected: boolean }>>([]);
const activeRackNo = ref("");
const candidateRackNo = ref("");
const pendingScanEvent = ref<any>(null);
const scanAfterRackSelection = ref(false);
const materialType = computed<TimelinessMaterialType>(() =>
  normalizeMaterialType(task.value.materialType)
);
@@ -492,17 +504,34 @@
    .length;
}
function hasSelectedActiveRack() {
  return Boolean(
    activeRackNo.value &&
      activeRackNo.value !== UNASSIGNED_LAYER &&
      selectedRackNos.value.includes(activeRackNo.value)
  );
}
function getMaterialCode(item: any) {
  return String(
    item?.flowCode ||
      item?.batchCode ||
      item?.monofilamentNumber ||
      item?.monofilamentnumber ||
      item?.materialCode ||
      item?.systemNo ||
      item?.systemno ||
      ""
  ).trim();
  return getMaterialCodes(item)[0] || "";
}
function getMaterialCodes(item: any) {
  const source = item?.data && typeof item.data === "object" ? { ...item, ...item.data } : item;
  const values = [
    source?.flowCode,
    source?.batchCode,
    source?.batchNo,
    source?.productionBatchNo,
    source?.productionBatchNumber,
    source?.productionBatchCode,
    source?.monofilamentNumber,
    source?.monofilamentnumber,
    source?.materialCode,
    source?.systemNo,
    source?.systemno,
  ];
  return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
}
function getCandidateId(item: any): number | string | undefined {
@@ -607,12 +636,43 @@
  }));
}
function confirmRackSelection() {
  selectedRackNos.value = rackSelectionRows.value
function closeRackSelector() {
  rackSelectorVisible.value = false;
  pendingScanEvent.value = null;
  scanAfterRackSelection.value = false;
}
async function confirmRackSelection() {
  const selectedValues = rackSelectionRows.value
    .filter((item) => item.selected)
    .map((item) => item.value);
  if ((pendingScanEvent.value || scanAfterRackSelection.value) && !selectedValues.length) {
    return toast.error("请至少选择一个铁架号");
  }
  selectedRackNos.value = selectedValues;
  syncActiveRackNo();
  if (
    (pendingScanEvent.value || scanAfterRackSelection.value) &&
    (!activeRackNo.value ||
      activeRackNo.value === UNASSIGNED_LAYER ||
      !selectedValues.includes(activeRackNo.value))
  ) {
    activeRackNo.value = selectedValues[0];
    candidateRackNo.value = selectedValues[0];
  }
  const scanEvent = pendingScanEvent.value;
  const shouldStartScan = scanAfterRackSelection.value;
  pendingScanEvent.value = null;
  scanAfterRackSelection.value = false;
  rackSelectorVisible.value = false;
  if (scanEvent) {
    await handleScan(scanEvent);
  } else if (shouldStartScan) {
    scanRef.value?.triggerScan();
  }
}
function fillIronPlateNumbers(rows: any[], otherCandidates: any[] = []) {
@@ -626,11 +686,6 @@
  );
  let nextNumber = 1;
  return rows.map((item) => {
    const original = Number(item.ironPlateNo);
    if (Number.isInteger(original) && original > 0 && !used.has(original)) {
      used.add(original);
      return { ...item, ironPlateNo: original, _selected: false };
    }
    while (used.has(nextNumber)) nextNumber += 1;
    const result = { ...item, ironPlateNo: nextNumber, _selected: false };
    used.add(nextNumber);
@@ -701,28 +756,6 @@
    return materialType.value === "单丝" ? "同一铁架号内铁牌序号不能重复" : "铁牌序号不能重复";
  }
  return "";
}
function requestIronPlateNo(defaultValue: number) {
  return new Promise<number | undefined>((resolve) => {
    uni.showModal({
      title: "填写铁牌序号",
      content: String(defaultValue),
      editable: true,
      placeholderText: "请输入正整数",
      confirmText: "确认领用",
      success: (result) => {
        if (!result.confirm) return resolve(undefined);
        const value = Number((result as any).content);
        if (!Number.isInteger(value) || value <= 0) {
          toast.error("请填写正确的铁牌序号");
          return resolve(undefined);
        }
        resolve(value);
      },
      fail: () => resolve(undefined),
    });
  });
}
async function loadTask() {
@@ -811,6 +844,15 @@
  successMessage = "物料领用成功",
  layer = candidateRackNo.value
) {
  const mismatchedMaterial = rows.find(
    (item) => item?.materialType && normalizeMaterialType(item.materialType) !== materialType.value
  );
  if (mismatchedMaterial) {
    toast.error(
      `${materialType.value}任务只能领用${materialType.value},不能领用${mismatchedMaterial.materialType}`
    );
    return false;
  }
  const validationMessage = validateIronPlateNumbers(rows, layer);
  if (validationMessage) {
    toast.error(validationMessage);
@@ -853,85 +895,136 @@
  }
}
async function deleteReceivedItem(item: any) {
  if (!isWaiting.value) return toast.error("只有待开始的任务可以删除已领用物料");
  const itemId = getCandidateId(item);
  if (itemId === undefined || itemId === null || itemId === "") {
    return toast.error("该物料缺少明细ID,无法删除");
  }
  try {
    await message.confirm({
      title: "删除领用",
      msg: `确定删除已领用物料“${getMaterialCode(item) || itemId}”吗?`,
      confirmButtonText: "确定删除",
      cancelButtonText: "取消",
      closeOnClickModal: false,
    });
  } catch {
    return;
  }
  deletingItemId.value = itemId;
  try {
    await TimeSensitiveTaskApi.deleteItems({ taskId: taskId.value, itemIds: [itemId] });
    toast.success("已删除领用物料");
    await loadTask();
  } catch (error: any) {
    toast.error(error?.message || "删除领用物料失败");
  } finally {
    deletingItemId.value = "";
  }
}
function addSingleWireCodeVariants(codes: string[]) {
  const result = new Set(codes);
  codes.forEach((code) => {
    if (!/^75\d+(?:-\d+)?$/i.test(code)) return;
    const compactCode = code.replace(/-/g, "");
    result.add(compactCode);
    if (compactCode.length > 3) {
      result.add(`${compactCode.slice(0, -3)}-${compactCode.slice(-3)}`);
    }
  });
  return [...result];
}
function readScanPayload(event: any) {
  const rawValue = event?.code ?? event;
  const raw = typeof rawValue === "string" ? rawValue.trim() : JSON.stringify(rawValue || "");
  const rawValue = event?.code ?? event?.data ?? event;
  let raw = typeof rawValue === "string" ? rawValue.trim() : JSON.stringify(rawValue || "");
  try {
    raw = decodeURIComponent(raw);
  } catch {
    // 扫描内容不是 URI 编码时直接使用原值。
  }
  let parsed: any = {};
  if (raw.startsWith("{") || raw.startsWith("[")) {
  let jsonValue: any = raw;
  // 兼容被扫码设备再次包成字符串的 JSON:"{\"monofilamentNumber\":\"...\"}"。
  for (let index = 0; index < 2 && typeof jsonValue === "string"; index += 1) {
    const text = jsonValue.trim();
    if (!text.startsWith("{") && !text.startsWith("[") && !text.startsWith('"')) break;
    try {
      parsed = JSON.parse(raw);
      if (Array.isArray(parsed)) parsed = parsed[0] || {};
      jsonValue = JSON.parse(text);
    } catch {
      parsed = {};
      break;
    }
  }
  const directCode = getMaterialCode(parsed);
  if (Array.isArray(jsonValue)) parsed = jsonValue[0] || {};
  else if (jsonValue && typeof jsonValue === "object") parsed = jsonValue;
  const outputId = parsed.id ?? parsed.outPutId ?? parsed.outputId;
  const ironPlateNo = parsed.ironPlateNo ?? parsed.ironPlateNumber;
  if (directCode) return { raw, parsed, outputId, ironPlateNo, materialCode: directCode };
  const parsedCodes = getMaterialCodes(parsed);
  const singleWireMatch = raw.match(/(?:^|[^0-9])(75\d+(?:-\d+)?)(?=$|[^0-9])/i)?.[1];
  const strandedWireMatch = raw.match(/(?:^|[^0-9])(74\d+)(?=$|[^0-9])/i)?.[1];
  const typeCode = materialType.value === "单丝" ? singleWireMatch : strandedWireMatch;
  let materialCodes = [...parsedCodes, ...(typeCode ? [typeCode] : [])];
  const parts = raw.split(/[,,\s]/).filter(Boolean);
  const singleWireCode = parts.find((part) => part.startsWith("75"));
  // 流转卡正常为 monofilamentNumber JSON;同时兼容纯编号和“单丝编号:...”文本。
  if (!materialCodes.length && !Object.keys(parsed).length) {
    const plainCode = raw.match(/[A-Za-z0-9][A-Za-z0-9._/-]*/)?.[0];
    if (plainCode) materialCodes.push(plainCode);
  }
  materialCodes = [...new Set(materialCodes.map((code) => code.trim()).filter(Boolean))];
  if (materialType.value === "单丝") materialCodes = addSingleWireCodeVariants(materialCodes);
  return {
    raw,
    parsed,
    outputId,
    ironPlateNo,
    materialCode: singleWireCode || (!Object.keys(parsed).length ? raw : ""),
    monofilamentNumber: String(
      parsed.monofilamentNumber ?? parsed.monofilamentnumber ?? typeCode ?? ""
    ).trim(),
    materialCodes,
  };
}
async function resolveScannedMaterialCode(scan: ReturnType<typeof readScanPayload>) {
  if (scan.materialCode) return scan.materialCode;
  if (scan.outputId === undefined || scan.outputId === null || scan.outputId === "") return "";
  if (USE_TIME_SENSITIVE_MOCK) return String(scan.outputId);
  try {
    const result =
      materialType.value === "单丝"
        ? await TwistApi.getTagByIdLs({ outPutId: scan.outputId })
        : await OutboundApi.getTagByIdJx({ outPutId: scan.outputId });
    const data = Array.isArray(result.data) ? result.data[0] : result.data;
    return getMaterialCode(data) || String(scan.outputId);
  } catch {
    return String(scan.outputId);
  }
}
async function handleScan(event: any) {
  if (!pageVisible.value || scanSubmitting.value) return;
  if (!isWaiting.value) return toast.error("只有待开始的任务可以扫码新增明细");
  if (
    materialType.value === "单丝" &&
    (!activeRackNo.value || activeRackNo.value === UNASSIGNED_LAYER)
  ) {
    return toast.error("请先选择单丝领用铁架号");
  if (materialType.value === "单丝" && !hasSelectedActiveRack()) {
    pendingScanEvent.value = event;
    await openRackSelector();
    return;
  }
  scanSubmitting.value = true;
  try {
    const scan = readScanPayload(event);
    const materialCode = await resolveScannedMaterialCode(scan);
    if (!materialCode) return toast.error("二维码格式错误,未识别到流转码或批次号");
    const { data } = await TimeSensitiveTaskApi.queryCandidates({
      current: 1,
      size: 20,
      taskId: taskId.value,
      materialType: materialType.value,
      materialCode,
    });
    const rows = data?.records || [];
    const normalizedCode = materialCode.trim().toUpperCase();
    const candidate =
      rows.find((item: any) => getMaterialCode(item).toUpperCase() === normalizedCode) ||
      rows.find((item: any) => String(getCandidateId(item)) === String(scan.outputId)) ||
      (rows.length === 1 ? rows[0] : undefined);
    if (!candidate) {
      return toast.error(`未找到可添加的${materialType.value},可能已添加或不在待时效范围`);
    const batchCode = scan.monofilamentNumber || scan.materialCodes[0];
    if (!batchCode) {
      return toast.error("二维码格式错误,未识别到单丝编号或生产批次号");
    }
    const suggestedNumber = Number(scan.ironPlateNo) || getNextIronPlateNo(activeRackNo.value);
    const { data: candidate } = await TimeSensitiveTaskApi.scanByBatchCode({ batchCode });
    if (!candidate) {
      return toast.error(`未找到批次号为 ${batchCode} 的待时效物料`);
    }
    if (candidate.available === false) {
      return toast.error(candidate.unavailableReason || "该物料当前不可领用");
    }
    if (
      candidate.materialType &&
      normalizeMaterialType(candidate.materialType) !== materialType.value
    ) {
      return toast.error(
        `当前任务只能领用${materialType.value},该流转卡为${candidate.materialType}`
      );
    }
    const ironPlateNo =
      materialType.value === "单丝" ? suggestedNumber : await requestIronPlateNo(suggestedNumber);
    if (ironPlateNo === undefined) return;
      materialType.value === "单丝"
        ? Number(scan.ironPlateNo) || getNextIronPlateNo(activeRackNo.value)
        : getNextIronPlateNo();
    await addCandidates([{ ...candidate, ironPlateNo }], "扫码领用成功", activeRackNo.value);
  } catch (error: any) {
    toast.error(error?.message || "扫码领用物料失败");
@@ -945,9 +1038,9 @@
  if (materialType.value === "单丝") {
    await loadRackOptions();
    syncActiveRackNo();
    if (!activeRackNo.value || activeRackNo.value === UNASSIGNED_LAYER) {
      toast.error("请先选择单丝领用铁架号");
      openRackSelector();
    if (!hasSelectedActiveRack()) {
      scanAfterRackSelection.value = true;
      await openRackSelector();
      return;
    }
  }
@@ -983,7 +1076,7 @@
.detail-page {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 50px);
  height: 100vh;
  background: #f3f9f8;
}
@@ -1029,17 +1122,26 @@
  font-weight: 600;
}
.summary-title {
  font-size: 19px;
}
.summary-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px 16px;
  font-size: 13px;
  gap: 16px 18px;
  font-size: 17px;
  font-weight: 500;
  line-height: 1.45;
  color: #333333;
}
.summary-grid > view {
  display: flex;
  flex-direction: column;
  gap: 3px;
  gap: 4px;
  min-width: 0;
  overflow-wrap: anywhere;
}
.label,
@@ -1047,6 +1149,12 @@
.candidate-meta {
  font-size: 12px;
  color: #999999;
}
.summary-grid .label {
  font-size: 14px;
  font-weight: 400;
  color: #7e8987;
}
.scan-tip {
@@ -1139,21 +1247,47 @@
}
.item-list {
  padding: 0 10px 24px;
  padding: 0 10px 12px;
}
.item-list :deep(.wd-card) {
  margin: 0 0 10px;
}
.received-item-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  background: #f8fbfa;
  border-top: 1px solid #edf2f0;
}
.received-item-actions :deep(.delete-received-button) {
  min-width: 108px;
  height: 44px;
  font-size: 16px;
  font-weight: 600;
  border-radius: 9px;
}
.material-detail-link {
  display: flex;
  gap: 6px;
  align-items: center;
  justify-content: flex-end;
  padding: 10px 12px;
  font-size: 13px;
  min-height: 44px;
  padding: 0 4px 0 18px;
  font-size: 15px;
  color: #0d867f;
}
.received-item-actions .material-detail-link {
  margin-left: auto;
}
.candidate-item > .material-detail-link {
  justify-content: flex-end;
  padding: 0 12px;
  background: #f8fbfa;
  border-top: 1px solid #edf2f0;
}
src/pages/timely/index.vue
@@ -7,12 +7,6 @@
      :auto-show-back-to-top="true"
      @query="queryTaskList"
    >
      <template #top>
        <view class="page-top">
          <CardTitle title="时效任务台账" :hideAction="false" />
        </view>
      </template>
      <view class="task-list">
        <wd-card v-for="task in taskList" :key="task.id" custom-class="task-card">
          <template #title>
@@ -184,7 +178,7 @@
<style lang="scss" scoped>
.timely-page {
  height: calc(100vh - 50px);
  height: 100vh;
  background: #f3f9f8;
}
src/pages/timely/inspection.vue
@@ -208,8 +208,9 @@
<style lang="scss" scoped>
.inspection-page {
  min-height: calc(100vh - 50px);
  padding-bottom: 24px;
  min-height: 100vh;
  padding-bottom: calc(12px + env(safe-area-inset-bottom));
  box-sizing: border-box;
  background: #f3f9f8;
}
src/pages/timely/materialDetail.vue
@@ -36,7 +36,7 @@
        </view>
        <view class="detail-row">
          <text class="detail-label">单位</text>
          <text class="detail-value">{{ material.unit || "-" }}</text>
          <text class="detail-value">kg</text>
        </view>
        <view class="detail-row">
          <text class="detail-label">厂家</text>
@@ -193,12 +193,12 @@
<style lang="scss" scoped>
.material-page {
  height: calc(100vh - 50px);
  min-height: 100vh;
  background: #f3f9f8;
}
.page-content {
  padding: 12px 12px calc(24px + env(safe-area-inset-bottom));
  padding: 12px 12px calc(12px + env(safe-area-inset-bottom));
}
.hero-card,