| | |
| | | :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> |
| | |
| | | <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> |
| | |
| | | type="info" |
| | | plain |
| | | custom-class="footer-action-button" |
| | | @click="rackSelectorVisible = false" |
| | | @click="closeRackSelector" |
| | | > |
| | | 取消 |
| | | </wd-button> |
| | |
| | | label="铁牌序号" |
| | | type="number" |
| | | required |
| | | :readonly="materialType === '单丝'" |
| | | placeholder="请输入正整数" |
| | | readonly |
| | | placeholder="自动生成" |
| | | /> |
| | | <wd-cell title="规格型号" :value="candidate.model || '-'" /> |
| | | <wd-cell |
| | |
| | | </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); |
| | |
| | | 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) |
| | | ); |
| | |
| | | .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 { |
| | |
| | | })); |
| | | } |
| | | |
| | | 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[] = []) { |
| | |
| | | ); |
| | | 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); |
| | |
| | | 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() { |
| | |
| | | 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); |
| | |
| | | } |
| | | } |
| | | |
| | | 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 || "扫码领用物料失败"); |
| | |
| | | if (materialType.value === "单丝") { |
| | | await loadRackOptions(); |
| | | syncActiveRackNo(); |
| | | if (!activeRackNo.value || activeRackNo.value === UNASSIGNED_LAYER) { |
| | | toast.error("请先选择单丝领用铁架号"); |
| | | openRackSelector(); |
| | | if (!hasSelectedActiveRack()) { |
| | | scanAfterRackSelection.value = true; |
| | | await openRackSelector(); |
| | | return; |
| | | } |
| | | } |
| | |
| | | .detail-page { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: calc(100vh - 50px); |
| | | height: 100vh; |
| | | background: #f3f9f8; |
| | | } |
| | | |
| | |
| | | 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, |
| | |
| | | .candidate-meta { |
| | | font-size: 12px; |
| | | color: #999999; |
| | | } |
| | | |
| | | .summary-grid .label { |
| | | font-size: 14px; |
| | | font-weight: 400; |
| | | color: #7e8987; |
| | | } |
| | | |
| | | .scan-tip { |
| | |
| | | } |
| | | |
| | | .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; |
| | | } |