Files
ShiErFeng/shierfeng-fastapi-app/src/pages/business/usage/index.vue
T

821 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>