821 lines
35 KiB
Vue
821 lines
35 KiB
Vue
<template>
|
||
<view class="min-h-screen bg-gray-50">
|
||
<scroll-view scroll-y class="h-screen">
|
||
<view class="space-y-4 p-4 pb-24">
|
||
<view>
|
||
<text class="block text-xl font-bold text-gray-800">客户现场使用</text>
|
||
<text class="mt-1 block text-xs text-gray-500">
|
||
扫描现场托盘,确认入隧后记录实际使用包数,产线可选
|
||
</text>
|
||
</view>
|
||
<TestBatchBanner />
|
||
|
||
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||
<text class="text-sm text-gray-500">当前账号没有现场使用权限</text>
|
||
</view>
|
||
|
||
<template v-else>
|
||
<view v-if="pendingOperations.length || recoveryError || recoveryMessage" class="rounded-2xl bg-amber-50 p-4 shadow-sm">
|
||
<text class="block font-bold text-gray-800">提交结果核对</text>
|
||
<text v-if="pendingOperations.length" class="mt-2 block text-xs text-gray-600">
|
||
以下操作结果尚未确认。恢复重试会核对原操作,请先确认结果再录入下一次使用。
|
||
</text>
|
||
<view v-for="item in pendingOperations" :key="item.payload.idempotencyKey" class="mt-3">
|
||
<text class="block text-sm text-gray-700">
|
||
{{ item.palletCode }} · {{ item.tunnelName }} · {{ item.action === "usage" ? `使用 ${item.payload.usedPackages} 包` : "托盘入隧" }}
|
||
</text>
|
||
<button size="mini" class="mt-2 text-blue-600" :disabled="!canOperate || operationPending" @click="retryPendingOperation(item)">
|
||
恢复重试 / 核对
|
||
</button>
|
||
<button size="mini" class="mt-2 text-gray-600" :disabled="operationPending" @click="releasePendingOperation(item)">
|
||
已人工核对,解除待确认
|
||
</button>
|
||
</view>
|
||
<text v-if="recoveryError" class="mt-2 block text-xs text-red-500">{{ recoveryError }}</text>
|
||
<text v-if="recoveryMessage" class="mt-2 block text-xs text-gray-700">{{ recoveryMessage }}</text>
|
||
</view>
|
||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||
<view class="mb-3 flex items-center justify-between">
|
||
<text class="font-bold text-gray-800">扫描待使用托盘</text>
|
||
<button class="text-xs text-gray-500" size="mini" :disabled="operationPending" @click="resetUsageOperation">
|
||
重新扫描
|
||
</button>
|
||
</view>
|
||
<PalletScanInput
|
||
:disabled="operationPending || optionLoading"
|
||
:loading="scanLoading"
|
||
@scan="handleUsageScan"
|
||
@invalid="handleScanInvalid"
|
||
@duplicate="handleScanDuplicate"
|
||
/>
|
||
<text class="mt-2 block text-xs text-gray-400">
|
||
仅接受 AT_SITE_UNUSED 或 AT_SITE_PARTIAL 且仍有余量的托盘。
|
||
</text>
|
||
<text v-if="operationError" class="mt-2 block text-xs text-red-500">{{ operationError }}</text>
|
||
|
||
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-purple-50 p-3">
|
||
<view class="flex items-center justify-between">
|
||
<text class="font-medium text-gray-700">
|
||
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||
</text>
|
||
<text class="text-xs text-purple-600">{{ scannedPallet.flowState }}</text>
|
||
</view>
|
||
<text class="mt-1 block text-xs text-gray-500">
|
||
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
|
||
</text>
|
||
<text class="mt-1 block text-xs text-gray-500">
|
||
生产日期:{{ scannedPallet.productionDate || "未登记" }} · 有效期至:{{ scannedPallet.expiryDate || "未登记" }}
|
||
</text>
|
||
<text class="mt-1 block text-xs text-gray-500">
|
||
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
|
||
</text>
|
||
</view>
|
||
<view v-if="scannedPallet" class="mt-3 rounded-xl bg-blue-50 p-3">
|
||
<view class="flex items-center justify-between">
|
||
<text class="text-sm text-gray-700">先到先用建议</text>
|
||
<button size="mini" class="text-xs text-blue-600" :disabled="fifoLoading || operationPending" @click="loadFifoRecommendation">
|
||
刷新建议
|
||
</button>
|
||
</view>
|
||
<text class="mt-2 block text-xs text-gray-600">{{ fifoLoading ? "建议加载中,提交时由服务端复核" : fifoMessage }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||
<text class="mb-3 block font-bold text-gray-800">托盘入隧</text>
|
||
<view v-if="tunnelLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||
{{ selectedTunnelName }}(已入隧,不可切换)
|
||
</view>
|
||
<picker
|
||
v-else
|
||
:range="tunnelPickerOptions"
|
||
range-key="label"
|
||
:value="tunnelIndex"
|
||
:disabled="!canOperate || operationPending || optionLoading || !scannedPallet"
|
||
@change="handleTunnelChange"
|
||
>
|
||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||
{{ selectedTunnelName || "请选择使用隧道" }}
|
||
</view>
|
||
</picker>
|
||
<text v-if="scannedPallet && !tunnelLocked" class="mt-2 block text-xs text-gray-500">
|
||
先确认入隧,再提交使用包数。
|
||
</text>
|
||
<view class="mt-3">
|
||
<text class="mb-2 block text-xs text-gray-500">跳过先到托盘原因({{ fifoRequiresSkipReason ? "必填" : "可选" }})</text>
|
||
<textarea v-model="form.fifoSkipReason" class="w-full rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||
:maxlength="500" placeholder="跳过先到托盘时请填写原因,最多500字"
|
||
:disabled="operationPending || !scannedPallet" @input="markPayloadChanged" />
|
||
<text class="mt-1 block text-xs text-gray-400">最多500字;提交时由服务端复核先到先用要求。</text>
|
||
</view>
|
||
<button
|
||
v-if="!tunnelLocked"
|
||
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
|
||
@click="enterTunnel"
|
||
>
|
||
{{ entryLoading ? "入隧中" : "确认入隧" }}
|
||
</button>
|
||
</view>
|
||
|
||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||
<text class="mb-3 block font-bold text-gray-800">使用信息</text>
|
||
<view class="mb-3">
|
||
<text class="mb-2 block text-xs text-gray-500">使用产线(可选)</text>
|
||
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||
{{ selectedLineName }}(已由托盘绑定)
|
||
</view>
|
||
<picker
|
||
v-else
|
||
:range="linePickerOptions"
|
||
range-key="name"
|
||
:value="lineIndex"
|
||
:disabled="operationPending || lineOptionLoading || !scannedPallet"
|
||
@change="handleLineChange"
|
||
>
|
||
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||
{{ selectedLineName || "不选择产线" }}
|
||
</view>
|
||
</picker>
|
||
<text v-if="lineOptionError" class="mt-2 block text-xs text-amber-600">{{ lineOptionError }}</text>
|
||
</view>
|
||
<view>
|
||
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
|
||
<input
|
||
v-model="form.usedPackages"
|
||
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||
type="number"
|
||
placeholder="请输入正整数"
|
||
:disabled="operationPending || !scannedPallet"
|
||
@input="markPayloadChanged"
|
||
/>
|
||
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||
不能超过当前剩余 {{ scannedPallet.remainingPackages }} 包,最终以服务端校验为准
|
||
</text>
|
||
</view>
|
||
<text v-if="submitError" class="mt-3 block text-xs text-red-500">{{ submitError }}</text>
|
||
<view v-if="usageResult" class="mt-3 rounded-xl bg-green-50 p-3">
|
||
<text class="block text-sm font-medium text-green-700">使用记录已提交</text>
|
||
<text class="mt-1 block text-xs text-gray-600">
|
||
使用后剩余 {{ usageResult.remainingPackages }} 包 · 状态 {{ usageResult.flowState }}
|
||
</text>
|
||
</view>
|
||
<button
|
||
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
|
||
@click="submitUsage"
|
||
>
|
||
{{ submitLoading ? "提交中" : "确认使用" }}
|
||
</button>
|
||
</view>
|
||
|
||
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||
<view class="mb-3 flex items-center justify-between">
|
||
<text class="font-bold text-gray-800">最近使用记录</text>
|
||
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadUsageList">
|
||
刷新
|
||
</button>
|
||
</view>
|
||
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||
<view v-else-if="!usageList.length" class="py-6 text-center text-xs text-gray-400">
|
||
暂无使用记录
|
||
</view>
|
||
<view
|
||
v-for="row in usageList"
|
||
v-else
|
||
:key="row.usageId"
|
||
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||
>
|
||
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
|
||
<text class="mt-1 block text-xs text-gray-500">
|
||
托盘 #{{ row.palletId }} · {{ row.tunnelName || row.tunnelCode || (row.tunnelId ? `隧道 #${row.tunnelId}` : "未登记隧道") }} · 使用 {{ row.usedPackages }} 包
|
||
</text>
|
||
<text v-if="row.lineId" class="mt-1 block text-xs text-gray-500">
|
||
产线 #{{ row.lineId }}
|
||
</text>
|
||
<text class="mt-1 block text-xs text-gray-400">
|
||
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
|
||
</text>
|
||
<text class="mt-1 block text-xs text-gray-500">跳过先到托盘原因:{{ row.fifoSkipReason || "—" }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, getCurrentInstance, reactive, ref } from "vue";
|
||
import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
|
||
import PalletScanInput from "@/components/business/PalletScanInput.vue";
|
||
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
|
||
import {
|
||
addUsage,
|
||
enterUsageTunnel,
|
||
getUsageFifoRecommendation,
|
||
getUsageLineOptions,
|
||
getUsageTunnelOptions,
|
||
listUsage,
|
||
scanUsagePallet,
|
||
} from "@/api/business/usage";
|
||
import { createIdempotencyKey } from "@/utils/palletScan";
|
||
import config from "@/config";
|
||
import constant from "@/utils/constant";
|
||
import storage from "@/utils/storage";
|
||
|
||
const { proxy } = getCurrentInstance();
|
||
const usageList = ref([]);
|
||
const lineOptions = ref([]);
|
||
const tunnelOptions = ref([]);
|
||
const scannedPallet = ref(null);
|
||
const usageResult = ref(null);
|
||
const lineLocked = ref(false);
|
||
const tunnelLocked = ref(false);
|
||
const loading = ref(false);
|
||
const optionLoading = ref(false);
|
||
const lineOptionLoading = ref(false);
|
||
const lineOptionError = ref("");
|
||
const fifoLoading = ref(false);
|
||
const fifoMessage = ref("");
|
||
const fifoRequiresSkipReason = ref(false);
|
||
const scanLoading = ref(false);
|
||
const submitLoading = ref(false);
|
||
const entryLoading = ref(false);
|
||
const listError = ref("");
|
||
const operationError = ref("");
|
||
const submitError = ref("");
|
||
const pendingOperations = ref([]);
|
||
const recoveryStorageKey = ref("");
|
||
const recoveryLoading = ref(false);
|
||
const recoveryError = ref("");
|
||
const recoveryMessage = ref("");
|
||
const form = reactive({ qrToken: "", tunnelId: null, lineId: null, usedPackages: "", fifoSkipReason: "" });
|
||
let listGeneration = 0;
|
||
let scanGeneration = 0;
|
||
let optionGeneration = 0;
|
||
let fifoGeneration = 0;
|
||
let operationSession = 0;
|
||
|
||
const canPage = computed(() =>
|
||
proxy.$auth.hasPermiOr([
|
||
"business:usage:list",
|
||
"business:usage:query",
|
||
"business:usage:add",
|
||
]),
|
||
);
|
||
const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
|
||
const canOperate = computed(() =>
|
||
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
|
||
);
|
||
const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
|
||
const linePickerOptions = computed(() => [
|
||
{ id: null, name: "不选择产线" },
|
||
...lineOptions.value,
|
||
]);
|
||
const lineIndex = computed(() => {
|
||
const index = linePickerOptions.value.findIndex((item) => item.id === form.lineId);
|
||
return index >= 0 ? index : 0;
|
||
});
|
||
const selectedLineName = computed(
|
||
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
|
||
);
|
||
const tunnelPickerOptions = computed(() => tunnelOptions.value.map((item) => ({
|
||
...item,
|
||
label: formatTunnelName(item),
|
||
})));
|
||
const tunnelIndex = computed(() => {
|
||
const index = tunnelOptions.value.findIndex((item) => item.id === form.tunnelId);
|
||
return index >= 0 ? index : 0;
|
||
});
|
||
const selectedTunnelName = computed(() => {
|
||
const option = tunnelOptions.value.find((item) => item.id === form.tunnelId);
|
||
if (option) return formatTunnelName(option);
|
||
if (!form.tunnelId) return "";
|
||
return formatTunnelName({
|
||
id: form.tunnelId,
|
||
name: scannedPallet.value?.tunnelName,
|
||
code: scannedPallet.value?.tunnelCode,
|
||
});
|
||
});
|
||
|
||
function formatTunnelName(option) {
|
||
return [option.name, option.code].filter(Boolean).join(" · ") || `隧道 #${option.id}`;
|
||
}
|
||
|
||
function errorMessage(error, fallback) {
|
||
return error?.response?.data?.msg || error?.message || fallback;
|
||
}
|
||
|
||
function currentRecoveryStorageKey() {
|
||
const userId = storage.get(constant.id);
|
||
if (!userId) throw new Error("账号信息未就绪,无法保存操作恢复记录");
|
||
return `business-usage-pending-v1:${JSON.stringify([config.baseUrl, userId, storage.get(constant.testBatchId) || "formal"])}`;
|
||
}
|
||
|
||
function loadPendingOperations() {
|
||
try {
|
||
const key = currentRecoveryStorageKey();
|
||
const records = uni.getStorageSync(key) || [];
|
||
if (!Array.isArray(records) || records.some((item) =>
|
||
!["entry", "usage"].includes(item?.action) || !item.payload?.qrToken || !item.payload?.idempotencyKey
|
||
)) throw new Error("本机操作恢复记录异常,请先联系管理员核对");
|
||
recoveryStorageKey.value = key;
|
||
pendingOperations.value = records;
|
||
recoveryError.value = "";
|
||
return true;
|
||
} catch (error) {
|
||
pendingOperations.value = [];
|
||
recoveryError.value = errorMessage(error, "本机操作恢复记录读取失败,请先核对");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function savePendingOperations(records) {
|
||
// 先持久化,再发送命令;页面隐藏、销毁或响应丢失都不能丢掉重试身份。
|
||
uni.setStorageSync(recoveryStorageKey.value, records);
|
||
pendingOperations.value = records;
|
||
}
|
||
|
||
function pendingForPallet() {
|
||
return pendingOperations.value.find((item) => item.palletId === scannedPallet.value?.palletId || item.payload.qrToken === form.qrToken);
|
||
}
|
||
|
||
function operationSignature(action, payload) {
|
||
return JSON.stringify([action, payload.qrToken, payload.tunnelId, payload.lineId ?? null, payload.usedPackages ?? null, payload.fifoSkipReason || ""]);
|
||
}
|
||
|
||
function prepareOperation(action, payload) {
|
||
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||
const pending = pendingForPallet();
|
||
if (pending) {
|
||
if (operationSignature(pending.action, pending.payload) !== operationSignature(action, payload)) {
|
||
throw new Error("该托盘有结果未确认的操作,请先恢复重试并核对原操作");
|
||
}
|
||
return pending;
|
||
}
|
||
const operation = {
|
||
action,
|
||
palletId: scannedPallet.value.palletId,
|
||
palletCode: scannedPallet.value.palletCode,
|
||
tunnelName: selectedTunnelName.value,
|
||
payload: { ...payload, idempotencyKey: createIdempotencyKey() },
|
||
};
|
||
savePendingOperations([...pendingOperations.value, operation]);
|
||
return operation;
|
||
}
|
||
|
||
function completeOperation(operation, response) {
|
||
const result = response?.data;
|
||
// 先核实完整业务结果,再解除原凭据;空响应和不完整响应仍属于结果未知。
|
||
const confirmed = result && (operation.action === "usage"
|
||
? Number.isInteger(result.usageId) && result.usageId > 0 &&
|
||
Number.isInteger(result.remainingPackages) && result.remainingPackages >= 0 &&
|
||
typeof result.flowState === "string" && result.flowState.trim()
|
||
: result.palletId === operation.palletId && result.tunnelId === operation.payload.tunnelId &&
|
||
typeof result.traceNo === "string" && result.traceNo.trim());
|
||
if (!confirmed) throw new Error("服务端确认结果不完整,原操作凭据已保留");
|
||
if (!loadPendingOperations()) throw new Error(recoveryError.value);
|
||
savePendingOperations(pendingOperations.value.filter((item) => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||
return result;
|
||
}
|
||
|
||
function isCurrentOperation(session, scopeKey) {
|
||
try {
|
||
return session === operationSession && scopeKey === recoveryStorageKey.value && scopeKey === currentRecoveryStorageKey();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function validatedFifoSkipReason() {
|
||
const reason = form.fifoSkipReason.trim();
|
||
if (Array.from(reason).length > 500) throw new Error("跳过先到托盘原因最多500字");
|
||
if (fifoRequiresSkipReason.value && !reason) throw new Error("请填写跳过先到托盘原因");
|
||
return reason;
|
||
}
|
||
|
||
function handleFifoRejection(error, operation) {
|
||
// 与 request 的 getResponseBody 一致:兼容 uni 响应外层及直接业务 body。
|
||
const response = error?.response;
|
||
const body = response?.data && typeof response.data === "object" ? response.data : response;
|
||
if (!operation || body?.code !== 500 || body.data !== "FIFO_SKIP_REASON_REQUIRED") return false;
|
||
try {
|
||
if (!loadPendingOperations()) return true;
|
||
const current = pendingOperations.value.find(item => item.payload.idempotencyKey === operation.payload.idempotencyKey);
|
||
if (!current || JSON.stringify(current) !== JSON.stringify(operation)) {
|
||
recoveryError.value = "本机待确认记录已变化,请重新核对后操作";
|
||
return true;
|
||
}
|
||
// 仅此明确业务错误保证事务回滚,可解除对应原凭据并允许修改原因。
|
||
savePendingOperations(pendingOperations.value.filter(item => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||
} catch (storageError) {
|
||
recoveryError.value = `${errorMessage(storageError, "本机待确认记录清理失败")};原凭据已保留,请恢复重试`;
|
||
return true;
|
||
}
|
||
if (scannedPallet.value?.palletId === operation.palletId && form.qrToken === operation.payload.qrToken) {
|
||
fifoRequiresSkipReason.value = true;
|
||
loadFifoRecommendation();
|
||
} else {
|
||
recoveryLoading.value = false;
|
||
handleUsageScan(operation.payload.qrToken, operation);
|
||
}
|
||
operationError.value = "请补充跳过先到托盘原因后重新提交,本次操作未生效";
|
||
submitError.value = operationError.value;
|
||
recoveryMessage.value = operationError.value;
|
||
return true;
|
||
}
|
||
|
||
async function releasePendingOperation(item) {
|
||
if (operationPending.value) return;
|
||
const session = operationSession;
|
||
const scopeKey = recoveryStorageKey.value;
|
||
if (!loadPendingOperations() || recoveryStorageKey.value !== scopeKey) return;
|
||
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||
if (!operation) return;
|
||
recoveryLoading.value = true;
|
||
try {
|
||
const confirmed = await new Promise((resolve, reject) => {
|
||
uni.showModal({
|
||
title: "解除本机待确认记录",
|
||
content: `请先核对使用记录和当前库存,确认 ${operation.palletCode} 的原操作结果。此操作仅移除对应本机待确认记录,不撤销或确认服务端业务。若原操作已成功,再次提交可能重复扣减库存。确认已人工核对并解除?`,
|
||
cancelText: "取消",
|
||
confirmText: "确认解除",
|
||
success: (result) => resolve(Boolean(result.confirm)),
|
||
fail: reject,
|
||
});
|
||
});
|
||
// 弹窗期间可能换账号、批次或离页;不能把旧确认应用到新的范围。
|
||
if (!confirmed || !isCurrentOperation(session, scopeKey) || currentRecoveryStorageKey() !== scopeKey) return;
|
||
if (!loadPendingOperations()) return;
|
||
savePendingOperations(pendingOperations.value.filter((record) => record.payload.idempotencyKey !== operation.payload.idempotencyKey));
|
||
if (scannedPallet.value?.palletId === operation.palletId || form.qrToken === operation.payload.qrToken) {
|
||
resetUsageOperation();
|
||
}
|
||
recoveryMessage.value = `${operation.palletCode} 仅已解除本机待确认记录,不代表原操作成功。请依据人工核对结果重新扫码后继续。`;
|
||
} catch (error) {
|
||
if (!isCurrentOperation(session, scopeKey)) return;
|
||
recoveryError.value = errorMessage(error, "解除本机待确认失败,原记录仍保留");
|
||
} finally {
|
||
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function retryPendingOperation(item) {
|
||
if (!canOperate.value || operationPending.value || !loadPendingOperations()) return;
|
||
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
|
||
if (!operation) return;
|
||
resetUsageOperation();
|
||
const session = operationSession;
|
||
const scopeKey = recoveryStorageKey.value;
|
||
recoveryLoading.value = true;
|
||
recoveryMessage.value = "";
|
||
try {
|
||
const response = await (operation.action === "usage" ? addUsage : enterUsageTunnel)({ ...operation.payload });
|
||
if (!isCurrentOperation(session, scopeKey)) return;
|
||
const result = completeOperation(operation, response);
|
||
recoveryMessage.value = operation.action === "usage"
|
||
? `${operation.palletCode} 原使用已确认,剩余 ${result.remainingPackages} 包。请重新扫码后继续。`
|
||
: `${operation.palletCode} 原入隧已确认。请重新扫码后继续。`;
|
||
loadUsageList();
|
||
} catch (error) {
|
||
if (!isCurrentOperation(session, scopeKey)) return;
|
||
if (handleFifoRejection(error, operation)) return;
|
||
recoveryError.value = `${errorMessage(error, "原操作核对失败")};原操作已保留,请重试或联系管理员核对`;
|
||
} finally {
|
||
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function loadUsageList() {
|
||
if (!canList.value) return;
|
||
const generation = ++listGeneration;
|
||
loading.value = true;
|
||
listError.value = "";
|
||
listUsage({ pageNum: 1, pageSize: 20 })
|
||
.then((response) => {
|
||
if (generation !== listGeneration) return;
|
||
usageList.value = Array.isArray(response.rows) ? response.rows : [];
|
||
})
|
||
.catch((error) => {
|
||
if (generation !== listGeneration) return;
|
||
usageList.value = [];
|
||
listError.value = errorMessage(error, "使用记录加载失败,请稍后重试");
|
||
})
|
||
.finally(() => {
|
||
if (generation === listGeneration) loading.value = false;
|
||
});
|
||
}
|
||
|
||
function resetUsageOperation() {
|
||
operationSession += 1;
|
||
scanGeneration += 1;
|
||
optionGeneration += 1;
|
||
fifoGeneration += 1;
|
||
scannedPallet.value = null;
|
||
usageResult.value = null;
|
||
lineOptions.value = [];
|
||
tunnelOptions.value = [];
|
||
lineLocked.value = false;
|
||
tunnelLocked.value = false;
|
||
form.qrToken = "";
|
||
form.tunnelId = null;
|
||
form.lineId = null;
|
||
form.usedPackages = "";
|
||
form.fifoSkipReason = "";
|
||
operationError.value = "";
|
||
submitError.value = "";
|
||
scanLoading.value = false;
|
||
optionLoading.value = false;
|
||
lineOptionLoading.value = false;
|
||
lineOptionError.value = "";
|
||
fifoLoading.value = false;
|
||
fifoMessage.value = "";
|
||
fifoRequiresSkipReason.value = false;
|
||
submitLoading.value = false;
|
||
entryLoading.value = false;
|
||
recoveryLoading.value = false;
|
||
}
|
||
|
||
function loadUsageOptions(pallet, session, scanRequestGeneration) {
|
||
const generation = ++optionGeneration;
|
||
const isCurrent = () => generation === optionGeneration &&
|
||
session === operationSession && scanRequestGeneration === scanGeneration;
|
||
optionLoading.value = true;
|
||
lineOptionLoading.value = true;
|
||
const lineRequest = pallet.currentLineId == null
|
||
? getUsageLineOptions(pallet.siteId)
|
||
: Promise.resolve({ data: lineOptions.value });
|
||
getUsageTunnelOptions(pallet.siteId)
|
||
.then((tunnels) => {
|
||
if (!isCurrent()) return;
|
||
tunnelOptions.value = Array.isArray(tunnels.data) ? tunnels.data : [];
|
||
if (!tunnelLocked.value && !tunnelOptions.value.length) {
|
||
operationError.value = "该现场暂无可用隧道";
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
if (!isCurrent()) return;
|
||
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
|
||
})
|
||
.finally(() => {
|
||
if (isCurrent()) optionLoading.value = false;
|
||
});
|
||
lineRequest
|
||
.then((lines) => {
|
||
if (isCurrent()) lineOptions.value = Array.isArray(lines.data) ? lines.data : [];
|
||
})
|
||
.catch((error) => {
|
||
if (!isCurrent()) return;
|
||
lineOptions.value = [];
|
||
lineOptionError.value = `${errorMessage(error, "产线选项加载失败")};可不选产线继续操作`;
|
||
})
|
||
.finally(() => {
|
||
if (isCurrent()) lineOptionLoading.value = false;
|
||
});
|
||
}
|
||
|
||
async function loadFifoRecommendation() {
|
||
if (!scannedPallet.value || !form.qrToken) return;
|
||
const generation = ++fifoGeneration;
|
||
const session = operationSession;
|
||
const scopeKey = recoveryStorageKey.value;
|
||
const { qrToken, tunnelId } = form;
|
||
const isCurrent = () => generation === fifoGeneration && isCurrentOperation(session, scopeKey) &&
|
||
qrToken === form.qrToken && tunnelId === form.tunnelId;
|
||
fifoLoading.value = true;
|
||
fifoMessage.value = "";
|
||
try {
|
||
const response = await getUsageFifoRecommendation(qrToken, tunnelId);
|
||
if (!isCurrent()) return;
|
||
fifoMessage.value = response.data?.message || "当前暂无先到先用建议";
|
||
fifoRequiresSkipReason.value = response.data?.requiresSkipReason === true;
|
||
} catch {
|
||
if (!isCurrent()) return;
|
||
fifoMessage.value = "先到先用建议暂不可用,请人工核对;提交时由服务端复核,跳过先到托盘可能需要填写原因";
|
||
} finally {
|
||
if (isCurrent()) fifoLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function handleUsageScan(qrToken, rejectedOperation = null) {
|
||
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||
resetUsageOperation();
|
||
if (!loadPendingOperations()) return;
|
||
const session = operationSession;
|
||
const scopeKey = recoveryStorageKey.value;
|
||
const generation = ++scanGeneration;
|
||
scanLoading.value = true;
|
||
scanUsagePallet(qrToken)
|
||
.then((response) => {
|
||
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
|
||
const pallet = response.data;
|
||
if (
|
||
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
|
||
pallet.remainingPackages <= 0
|
||
) {
|
||
operationError.value = "该托盘当前不可记录使用";
|
||
return;
|
||
}
|
||
scannedPallet.value = {
|
||
palletId: pallet.palletId,
|
||
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||
siteId: pallet.siteId,
|
||
currentLineId: pallet.currentLineId,
|
||
tunnelId: pallet.tunnelId,
|
||
tunnelName: pallet.tunnelName,
|
||
tunnelCode: pallet.tunnelCode,
|
||
strainCode: pallet.strainCode,
|
||
strainName: pallet.strainName,
|
||
batchNo: pallet.batchNo,
|
||
productionDate: pallet.productionDate,
|
||
expiryDate: pallet.expiryDate,
|
||
remainingPackages: pallet.remainingPackages,
|
||
flowState: pallet.flowState,
|
||
};
|
||
form.qrToken = pallet.qrToken || qrToken;
|
||
form.tunnelId = pallet.tunnelId ?? null;
|
||
tunnelLocked.value = pallet.tunnelId != null;
|
||
if (rejectedOperation) {
|
||
form.tunnelId = pallet.tunnelId ?? rejectedOperation.payload.tunnelId;
|
||
form.usedPackages = rejectedOperation.payload.usedPackages ?? "";
|
||
form.fifoSkipReason = rejectedOperation.payload.fifoSkipReason || "";
|
||
fifoRequiresSkipReason.value = true;
|
||
}
|
||
if (pallet.currentLineId != null) {
|
||
lineLocked.value = true;
|
||
form.lineId = pallet.currentLineId;
|
||
lineOptions.value = [
|
||
{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `产线 #${pallet.currentLineId}` },
|
||
];
|
||
} else {
|
||
lineLocked.value = false;
|
||
form.lineId = rejectedOperation?.payload.lineId ?? null;
|
||
}
|
||
loadUsageOptions(pallet, session, generation);
|
||
loadFifoRecommendation();
|
||
})
|
||
.catch((error) => {
|
||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
|
||
scanLoading.value = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleScanInvalid(message) {
|
||
resetUsageOperation();
|
||
operationError.value = message;
|
||
}
|
||
|
||
function handleScanDuplicate() {
|
||
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||
}
|
||
|
||
function handleLineChange(event) {
|
||
if (lineLocked.value || operationPending.value) return;
|
||
const option = linePickerOptions.value[Number(event.detail.value)];
|
||
form.lineId = option?.id ?? null;
|
||
markPayloadChanged();
|
||
}
|
||
|
||
function handleTunnelChange(event) {
|
||
if (tunnelLocked.value || operationPending.value) return;
|
||
const option = tunnelOptions.value[Number(event.detail.value)];
|
||
if (form.tunnelId === (option?.id ?? null)) return;
|
||
form.tunnelId = option?.id ?? null;
|
||
markPayloadChanged();
|
||
fifoRequiresSkipReason.value = false;
|
||
loadFifoRecommendation();
|
||
}
|
||
|
||
function markPayloadChanged() {
|
||
if (operationPending.value) return;
|
||
submitError.value = "";
|
||
}
|
||
|
||
async function enterTunnel() {
|
||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || tunnelLocked.value) return;
|
||
if (!scannedPallet.value || !form.qrToken || !form.tunnelId) {
|
||
operationError.value = "请先扫描托盘并选择隧道";
|
||
return;
|
||
}
|
||
let fifoSkipReason;
|
||
try {
|
||
fifoSkipReason = validatedFifoSkipReason();
|
||
} catch (error) {
|
||
operationError.value = error.message;
|
||
return;
|
||
}
|
||
const session = operationSession;
|
||
entryLoading.value = true;
|
||
operationError.value = "";
|
||
let scopeKey = recoveryStorageKey.value;
|
||
let operation;
|
||
try {
|
||
operation = prepareOperation("entry", {
|
||
qrToken: form.qrToken,
|
||
tunnelId: form.tunnelId,
|
||
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||
});
|
||
scopeKey = recoveryStorageKey.value;
|
||
const response = await enterUsageTunnel({ ...operation.payload });
|
||
if (!isCurrentOperation(session, scopeKey)) return;
|
||
const result = completeOperation(operation, response);
|
||
form.tunnelId = result.tunnelId;
|
||
scannedPallet.value.tunnelId = result.tunnelId;
|
||
tunnelLocked.value = true;
|
||
submitError.value = "";
|
||
proxy.$modal.msgSuccess(response.msg || "托盘入隧成功");
|
||
loadFifoRecommendation();
|
||
} catch (error) {
|
||
if (!isCurrentOperation(session, scopeKey)) return;
|
||
if (handleFifoRejection(error, operation)) return;
|
||
operationError.value = `${errorMessage(error, "托盘入隧失败")};结果未确认时请恢复重试核对原操作`;
|
||
} finally {
|
||
if (isCurrentOperation(session, scopeKey)) entryLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitUsage() {
|
||
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return;
|
||
const usedPackages = Number(form.usedPackages);
|
||
if (!scannedPallet.value || !form.qrToken) {
|
||
submitError.value = "请先扫描待使用托盘";
|
||
return;
|
||
}
|
||
if (!tunnelLocked.value || !form.tunnelId) {
|
||
submitError.value = "请先选择隧道并确认入隧";
|
||
return;
|
||
}
|
||
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
|
||
submitError.value = "使用包数必须为正整数";
|
||
return;
|
||
}
|
||
let fifoSkipReason;
|
||
try {
|
||
fifoSkipReason = validatedFifoSkipReason();
|
||
} catch (error) {
|
||
submitError.value = error.message;
|
||
return;
|
||
}
|
||
if (!loadPendingOperations()) {
|
||
submitError.value = recoveryError.value;
|
||
return;
|
||
}
|
||
// 服务端可能已扣减;已有原命令的重试由服务端幂等结果确认,不能用新余量拒绝。
|
||
if (usedPackages > scannedPallet.value.remainingPackages && !pendingForPallet()) {
|
||
submitError.value = "使用包数不能超过当前剩余包数";
|
||
return;
|
||
}
|
||
const session = operationSession;
|
||
submitLoading.value = true;
|
||
submitError.value = "";
|
||
const scopeKey = recoveryStorageKey.value;
|
||
let operation;
|
||
try {
|
||
operation = prepareOperation("usage", {
|
||
qrToken: form.qrToken,
|
||
tunnelId: form.tunnelId,
|
||
lineId: form.lineId,
|
||
usedPackages,
|
||
...(fifoSkipReason ? { fifoSkipReason } : {}),
|
||
});
|
||
const response = await addUsage({ ...operation.payload });
|
||
if (!isCurrentOperation(session, scopeKey)) return;
|
||
usageResult.value = completeOperation(operation, response);
|
||
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
|
||
loadUsageList();
|
||
} catch (error) {
|
||
if (!isCurrentOperation(session, scopeKey)) return;
|
||
if (handleFifoRejection(error, operation)) return;
|
||
submitError.value = `${errorMessage(error, "现场使用提交失败")};结果未确认时请恢复重试核对原操作`;
|
||
} finally {
|
||
if (isCurrentOperation(session, scopeKey)) submitLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function leaveUsagePage() {
|
||
// uni-app 页面可能缓存;离开时使所有旧请求失效,不能回写下一次操作。
|
||
resetUsageOperation();
|
||
listGeneration += 1;
|
||
loading.value = false;
|
||
usageList.value = [];
|
||
listError.value = "";
|
||
recoveryMessage.value = "";
|
||
}
|
||
|
||
onHide(leaveUsagePage);
|
||
onUnload(leaveUsagePage);
|
||
onShow(() => {
|
||
loadPendingOperations();
|
||
loadUsageList();
|
||
});
|
||
loadPendingOperations();
|
||
</script>
|