更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "1.9.0",
"packageManager": "pnpm@10.28.1",
"scripts": {
"test:business": "node --test tests/business-contract.test.mjs",
"test:business": "node --test tests/*.test.mjs",
"dev": "uni -p mp-weixin",
"build": "npm run build:mp-weixin",
"dev:app": "uni -p app",
@@ -17,7 +17,7 @@ export function getFillingStrainOptions() {
export function scanFillingPallet(qrToken) {
return request({
url: "/business/filling/scan/" + qrToken,
url: "/business/filling/scan/" + encodeURIComponent(qrToken),
method: "get",
});
}
@@ -17,7 +17,7 @@ export function getInboundWarehouseOptions() {
export function scanInboundPallet(qrToken, query) {
return request({
url: "/business/inbound/scan/" + qrToken,
url: "/business/inbound/scan/" + encodeURIComponent(qrToken),
method: "get",
params: query,
});
@@ -17,7 +17,7 @@ export function getReturnWarehouseOptions() {
export function scanReturnPallet(qrToken, query) {
return request({
url: "/business/return/scan/" + qrToken,
url: "/business/return/scan/" + encodeURIComponent(qrToken),
method: "get",
params: query,
});
@@ -24,7 +24,7 @@ export function getShipmentSiteOptions() {
export function scanShipmentPallet(qrToken, query) {
return request({
url: "/business/shipment/scan/" + qrToken,
url: "/business/shipment/scan/" + encodeURIComponent(qrToken),
method: "get",
params: query,
});
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 收货仅依赖目标现场范围,使用独立端点,不能复用出库查询。
export function listShipmentReceipts(query) {
return request({ url: '/business/shipment-receipt/list', method: 'get', params: query })
}
export function getShipmentReceipt(shipmentId) {
return request({ url: '/business/shipment-receipt/' + shipmentId, method: 'get' })
}
export function scanShipmentReceipt(shipmentId, identifier) {
return request({
url: '/business/shipment-receipt/' + shipmentId + '/scan/' + encodeURIComponent(identifier),
method: 'get'
})
}
export function confirmShipmentReceipt(shipmentId, data) {
return request({ url: '/business/shipment-receipt/' + shipmentId + '/confirm', method: 'put', data })
}
@@ -16,13 +16,37 @@ export function getUsageLineOptions(siteId) {
});
}
export function getUsageTunnelOptions(siteId) {
return request({
url: "/business/usage/tunnel-options",
method: "get",
params: { siteId },
});
}
export function enterUsageTunnel(data) {
return request({
url: "/business/usage/enter-tunnel",
method: "post",
data,
});
}
export function scanUsagePallet(qrToken) {
return request({
url: "/business/usage/scan/" + qrToken,
url: "/business/usage/scan/" + encodeURIComponent(qrToken),
method: "get",
});
}
export function getUsageFifoRecommendation(qrToken, tunnelId) {
return request({
url: "/business/usage-fifo/recommendation",
method: "get",
params: { qrToken, ...(tunnelId != null ? { tunnelId } : {}) },
});
}
export function getUsage(usageId) {
return request({
url: "/business/usage/" + usageId,
@@ -9,7 +9,7 @@
maxlength="128"
:focus="focusRequested"
:disabled="disabled || loading"
placeholder="请扫描或输入托盘二维码后按回车"
placeholder="扫描二维码或输入托盘号后按回车"
@confirm="handleSubmit"
@input="clearError"
/>
@@ -0,0 +1,302 @@
<template>
<view v-if="canReceive" class="space-y-4">
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">收货单</text>
<picker :range="receiptStateOptions" range-key="label" :value="receiptStateIndex" @change="changeReceiptState">
<view class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ receiptStateOptions[receiptStateIndex].label }}</view>
</picker>
<input v-model="queryParams.shipmentNo" class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm" maxlength="64" placeholder="输入原发货单号" confirm-type="search" @confirm="handleQuery" />
<button size="mini" class="rounded-lg bg-blue-50 text-blue-600" :disabled="loading" @click="handleQuery">查询收货</button>
<text v-if="listError" class="mt-3 block text-xs text-red-500">{{ listError }}</text>
<text v-if="loading" class="mt-3 block text-xs text-gray-400">加载中...</text>
<text v-else-if="!receiptList.length" class="mt-3 block text-xs text-gray-400">暂无收货单</text>
<view v-for="row in receiptList" :key="row.shipmentId" class="mt-3 rounded-xl border border-gray-100 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ row.shipmentNo }}</text>
<text class="text-xs text-orange-600">{{ receiptStateLabel(row.receiptState) }}</text>
</view>
<text class="mt-2 block text-xs text-gray-500">仓库 #{{ row.sourceWarehouseId }} → 现场 #{{ row.targetSiteId }}</text>
<text v-if="row.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ row.receivedAt || '未登记' }}</text>
<button size="mini" class="mt-3 rounded-lg bg-orange-50 text-orange-600" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</button>
</view>
<view class="mt-3 flex items-center justify-between gap-2">
<button size="mini" :disabled="loading || queryParams.pageNum <= 1" @click="changePage(-1)">上一页</button>
<text class="text-xs text-gray-500">第 {{ queryParams.pageNum }} 页 · 共 {{ total }} 单</text>
<button size="mini" :disabled="loading || queryParams.pageNum * queryParams.pageSize >= total" @click="changePage(1)">下一页</button>
</view>
</view>
<view v-if="activeShipmentId !== null" 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 size="mini" class="text-xs text-gray-500" @click="closeReceipt">关闭</button>
</view>
<text v-if="detailLoading" class="block text-xs text-gray-400">加载原单明细...</text>
<text v-if="detailError" class="mb-3 block text-xs text-red-500">{{ detailError }}</text>
<button v-if="detailError" size="mini" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</button>
<template v-if="receiptDetail">
<text class="block text-sm font-medium text-gray-700">{{ receiptDetail.shipmentNo }} · {{ receiptStateLabel(receiptDetail.receiptState) }}</text>
<text class="mt-1 block text-xs text-gray-500">目标现场 #{{ receiptDetail.targetSiteId }}</text>
<text v-if="receiptDetail.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ receiptDetail.receivedAt || '未登记' }}</text>
<text v-if="receiptDetail.receiptState === 'LEGACY'" class="mt-3 block text-xs text-gray-500">历史未登记收货:仅供查询原单明细,不补造收货时间。</text>
<view v-if="receiptDetail.receiptState === 'PENDING'" class="mt-3">
<PalletScanInput :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
<text class="mt-2 block text-xs text-gray-500">逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托,全部核对后才可确认。</text>
</view>
<text v-if="scanError" class="mt-3 block text-xs text-red-500">{{ scanError }}</text>
<text v-if="!receiptItems.length" class="mt-3 block text-xs text-red-500">原单无托盘明细,无法确认收货</text>
<view v-for="item in receiptItems" :key="item.palletId" class="mt-3 rounded-xl bg-gray-50 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ item.palletCode }}</text>
<text v-if="receiptDetail.receiptState === 'PENDING'" class="text-xs text-orange-600">{{ scannedPalletIds.includes(item.palletId) ? '已核对' : '待扫描' }}</text>
</view>
<text class="mt-2 block text-xs text-gray-600">品种:{{ item.strainName || '未登记' }} · 原发货 {{ item.packageQty }} 包</text>
<text class="mt-1 block text-xs text-gray-500">批号:{{ item.batchNo || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">生产日期:{{ item.productionDate || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">失效日期:{{ item.expiryDate || '未登记' }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING' && scannedPalletIds.includes(item.palletId)" size="mini" class="mt-2 text-xs text-red-500" :disabled="!canScan || scanLoading" @click="removeScannedPallet(item.palletId)">撤销核对</button>
</view>
<text v-if="confirmError" class="mt-3 block text-xs text-red-500">{{ confirmError }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING'" class="mt-4 rounded-xl bg-orange-500 py-3 text-sm text-white disabled:bg-gray-300" :disabled="!canConfirmReceipt" :loading="confirmLoading" @click="confirmReceipt">确认收货</button>
</template>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
import PalletScanInput from '@/components/business/PalletScanInput.vue'
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
import { createIdempotencyKey } from '@/utils/palletScan'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['received'])
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
const receiptList = ref([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const activeShipmentId = ref(null)
const receiptDetail = ref(null)
const detailLoading = ref(false)
const detailError = ref('')
const scannedPalletIds = ref([])
const scanLoading = ref(false)
const scanError = ref('')
const confirmLoading = ref(false)
const confirmError = ref('')
const receiptAccepted = ref(false)
let session = 0
let listGeneration = 0
let suspended = false
let idempotencyKey = ''
const receiptItems = computed(() => receiptDetail.value?.items || [])
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
function receiptStateLabel(state) {
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback
}
function isCurrent(currentSession) {
return currentSession === session && !suspended && canReceive.value
}
async function loadReceipts() {
if (!canReceive.value || suspended) return
const generation = ++listGeneration
loading.value = true
listError.value = ''
try {
const response = await listShipmentReceipts({ ...queryParams })
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = Array.isArray(response.rows) ? response.rows : []
total.value = response.total || 0
} catch (error) {
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = []
total.value = 0
listError.value = errorMessage(error, '收货列表加载失败,请重试')
} finally {
if (generation === listGeneration) loading.value = false
}
}
function handleQuery() {
queryParams.pageNum = 1
return loadReceipts()
}
function closeReceipt() {
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
session += 1
activeShipmentId.value = null
receiptDetail.value = null
scannedPalletIds.value = []
detailLoading.value = false
scanLoading.value = false
confirmLoading.value = false
receiptAccepted.value = false
detailError.value = ''
scanError.value = ''
confirmError.value = ''
idempotencyKey = ''
}
async function loadReceiptDetail(shipmentId, currentSession) {
detailLoading.value = true
detailError.value = ''
try {
const response = await getShipmentReceipt(shipmentId)
if (!isCurrent(currentSession)) return
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
throw new Error('收货详情与当前单据不一致,请重新打开')
}
receiptDetail.value = response.data
} catch (error) {
if (!isCurrent(currentSession)) return
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
} finally {
if (isCurrent(currentSession)) detailLoading.value = false
}
}
async function openReceipt(row) {
if (!canReceive.value || suspended || row.shipmentId == null) return
closeReceipt()
activeShipmentId.value = row.shipmentId
await loadReceiptDetail(row.shipmentId, session)
}
function retryDetail() {
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
return loadReceiptDetail(activeShipmentId.value, session)
}
async function handleReceiptScan(identifier) {
if (!canScan.value || scanLoading.value) return
const value = String(identifier ?? '').trim()
if (!value) {
scanError.value = '请扫描二维码或输入托盘号'
return
}
const currentSession = session
scanLoading.value = true
scanError.value = ''
try {
const response = await scanShipmentReceipt(activeShipmentId.value, value)
if (!isCurrent(currentSession)) return
const scanned = response.data
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
scanError.value = '该托盘或装填记录不属于当前发货单'
return
}
if (scannedPalletIds.value.includes(original.palletId)) {
scanError.value = '该托盘已核对,请勿重复扫描'
return
}
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
scannedPalletIds.value.push(original.palletId)
idempotencyKey = ''
confirmError.value = ''
} catch (error) {
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
} finally {
if (isCurrent(currentSession)) scanLoading.value = false
}
}
function removeScannedPallet(palletId) {
if (!canScan.value || scanLoading.value) return
if (!scannedPalletIds.value.includes(palletId)) return
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
idempotencyKey = ''
confirmError.value = ''
}
async function confirmReceipt() {
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
if (!canConfirmReceipt.value) {
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
return
}
const currentSession = session
const shipmentId = activeShipmentId.value
confirmLoading.value = true
confirmError.value = ''
try {
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
const response = await confirmShipmentReceipt(shipmentId, {
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
})
if (!isCurrent(currentSession)) return
receiptAccepted.value = true
proxy.$modal.msgSuccess(response.msg || '收货成功')
emit('received')
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
} catch (error) {
if (isCurrent(currentSession)) {
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
}
} finally {
if (isCurrent(currentSession)) confirmLoading.value = false
}
}
function suspendPanel() {
suspended = true
listGeneration += 1
loading.value = false
closeReceipt()
}
function resumePanel() {
if (!suspended) return
suspended = false
loadReceipts()
}
onMounted(loadReceipts)
onBeforeUnmount(suspendPanel)
const receiptStateOptions = [
{ label: '待收货', value: 'PENDING' },
{ label: '已收货', value: 'RECEIVED' },
{ label: '历史未登记收货', value: 'LEGACY' },
{ label: '全部', value: undefined }
]
const receiptStateIndex = computed(() => receiptStateOptions.findIndex(item => item.value === queryParams.receiptState))
function changeReceiptState(event) {
const option = receiptStateOptions[Number(event.detail.value)]
if (!option) return
queryParams.receiptState = option.value
handleQuery()
}
function changePage(offset) {
const page = queryParams.pageNum + offset
if (loading.value || page < 1 || (page > 1 && (page - 1) * queryParams.pageSize >= total.value)) return
queryParams.pageNum = page
loadReceipts()
}
onShow(resumePanel)
onHide(suspendPanel)
onUnload(suspendPanel)
</script>
@@ -22,6 +22,7 @@ export const businessMenuEntries = [
icon: "i-mdi-truck-delivery",
iconClass: "bg-orange-50 text-orange-600",
permission: "business:shipment:list",
permissions: ["business:shipment:list", "business:shipment:receive"],
},
{
path: "/pages/business/usage/index",
@@ -80,6 +80,18 @@
@input="markPayloadChanged"
/>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">生产日期</text>
<picker mode="date" :value="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('productionDate', $event)">
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.productionDate || '选择来源生产日期' }}</view>
</picker>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">有效期至</text>
<picker mode="date" :value="form.expiryDate" :start="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('expiryDate', $event)">
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.expiryDate || '选择有效期截止日期' }}</view>
</picker>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">包装规格</text>
<input
@@ -97,9 +109,8 @@
v-model="form.initialPackages"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
type="number"
placeholder="请输入正整数"
:disabled="operationPending || !scannedPallet"
@input="markPayloadChanged"
placeholder="固定 100 包"
disabled
/>
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
@@ -177,8 +188,10 @@ const form = reactive({
warehouseId: null,
strainId: null,
batchNo: "",
productionDate: "",
expiryDate: "",
packageSpec: "",
initialPackages: "",
initialPackages: 100,
});
let listGeneration = 0;
let scanGeneration = 0;
@@ -251,8 +264,10 @@ function resetOperation() {
form.warehouseId = null;
form.strainId = null;
form.batchNo = "";
form.productionDate = "";
form.expiryDate = "";
form.packageSpec = "";
form.initialPackages = "";
form.initialPackages = 100;
scanError.value = "";
formError.value = "";
scanLoading.value = false;
@@ -280,7 +295,7 @@ function handleFillingScan(qrToken) {
flowState: pallet.flowState,
capacityPackages: pallet.capacityPackages,
};
form.qrToken = qrToken;
form.qrToken = pallet.qrToken || qrToken;
form.warehouseId = pallet.warehouseId;
loadStrainOptions();
})
@@ -313,6 +328,12 @@ function markPayloadChanged() {
if (!submitLoading.value) idempotencyKey.value = "";
}
function handleMaterialDateChange(field, event) {
if (operationPending.value) return;
form[field] = event.detail.value;
markPayloadChanged();
}
function ensureIdempotencyKey() {
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
return idempotencyKey.value;
@@ -329,8 +350,12 @@ function submitFilling() {
formError.value = "请完整填写菌种、批次和包装规格";
return;
}
if (!Number.isInteger(initialPackages) || initialPackages <= 0) {
formError.value = "初始包数必须为正整数";
if (!form.productionDate || !form.expiryDate || form.expiryDate < form.productionDate) {
formError.value = "请填写生产日期和有效期,且有效期不能早于生产日期";
return;
}
if (initialPackages !== 100) {
formError.value = "新装填固定为 100 包";
return;
}
submitLoading.value = true;
@@ -340,6 +365,8 @@ function submitFilling() {
warehouseId: form.warehouseId,
strainId: form.strainId,
batchNo: form.batchNo.trim(),
productionDate: form.productionDate,
expiryDate: form.expiryDate,
packageSpec: form.packageSpec.trim(),
initialPackages,
idempotencyKey: ensureIdempotencyKey(),
@@ -4,13 +4,13 @@
<view class="space-y-4 p-4 pb-24">
<view class="flex items-center justify-between">
<view>
<text class="block text-xl font-bold text-gray-800">发货出库</text>
<text class="block text-xl font-bold text-gray-800">{{ activeTab === 'receipt' ? '收货核对' : '发货出库' }}</text>
<text class="mt-1 block text-xs text-gray-500">
选择来源仓库和目标现场,扫码创建整托发货单
{{ activeTab === 'receipt' ? '扫描原托盘标签,逐托核对目标现场收货' : '选择来源仓库和目标现场,扫码创建整托发货单' }}
</text>
</view>
<button
v-if="canDraft"
v-if="canDraft && activeTab === 'shipment'"
class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80"
size="mini"
:disabled="operationPending"
@@ -20,12 +20,17 @@
</button>
</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 v-if="canReceive" class="flex gap-3">
<button v-if="canShipment" size="mini" :class="activeTab === 'shipment' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'shipment'">发货出库</button>
<button size="mini" :disabled="operationPending || editorVisible" :class="activeTab === 'receipt' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'receipt'">收货核对</button>
</view>
<template v-else>
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
<text class="text-sm text-gray-500">当前账号没有发货或收货权限</text>
</view>
<ShipmentReceiptPanel v-else-if="canReceive && activeTab === 'receipt'" @received="loadShipmentList" />
<template v-else-if="canShipment && activeTab === 'shipment'">
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-4 flex items-center justify-between">
<text class="font-bold text-gray-800">
@@ -155,8 +160,8 @@
>
<view class="flex items-center justify-between">
<text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text>
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
<text :class="row.status === 'DRAFT' || row.receiptState === 'PENDING' ? 'text-orange-500' : row.receiptState === 'LEGACY' ? 'text-gray-500' : 'text-green-500'" class="text-xs">
{{ shipmentStatusLabel(row) }}
</text>
</view>
<text class="mt-1 block text-xs text-gray-500">
@@ -195,6 +200,7 @@ import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import ShipmentReceiptPanel from "@/components/business/ShipmentReceiptPanel.vue";
import {
addShipment,
confirmShipment,
@@ -229,7 +235,7 @@ const confirmState = reactive({ shipmentId: null, key: "" });
let listGeneration = 0;
let scanGeneration = 0;
const canPage = computed(() =>
const canShipment = computed(() =>
proxy.$auth.hasPermiOr([
"business:shipment:list",
"business:shipment:query",
@@ -238,6 +244,9 @@ const canPage = computed(() =>
"business:shipment:confirm",
]),
);
const canReceive = computed(() => proxy.$auth.hasPermi("business:shipment:receive"));
const canPage = computed(() => canShipment.value || canReceive.value);
const activeTab = ref(canShipment.value ? "shipment" : "receipt");
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
const canDraft = computed(() =>
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
@@ -274,6 +283,11 @@ function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback;
}
function shipmentStatusLabel(row) {
if (row.status === "DRAFT") return "草稿";
return { PENDING: "待收货", RECEIVED: "已收货", LEGACY: "历史未登记收货" }[row.receiptState] || "已确认";
}
function warehouseName(id) {
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
}
@@ -5,7 +5,7 @@
<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 />
@@ -15,6 +15,25 @@
</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>
@@ -44,31 +63,81 @@
<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>
<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="lineOptions"
:range="linePickerOptions"
range-key="name"
:value="lineIndex"
:disabled="operationPending || optionLoading || !scannedPallet"
:disabled="operationPending || lineOptionLoading || !scannedPallet"
@change="handleLineChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedLineName || "请选择现场产线" }}
{{ 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>
@@ -93,7 +162,7 @@
</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 || !!usageResult"
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
@click="submitUsage"
>
{{ submitLoading ? "提交中" : "确认使用" }}
@@ -120,11 +189,15 @@
>
<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.lineId }} · 使用 {{ row.usedPackages }} 包
托盘 #{{ 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>
@@ -135,35 +208,54 @@
<script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
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 idempotencyKey = ref("");
const form = reactive({ qrToken: "", lineId: null, usedPackages: "" });
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(() =>
@@ -177,19 +269,225 @@ 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);
const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
const linePickerOptions = computed(() => [
{ id: null, name: "不选择产线" },
...lineOptions.value,
]);
const lineIndex = computed(() => {
const index = lineOptions.value.findIndex((item) => item.id === form.lineId);
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;
@@ -214,56 +512,104 @@ function resetUsageOperation() {
operationSession += 1;
scanGeneration += 1;
optionGeneration += 1;
fifoGeneration += 1;
scannedPallet.value = null;
usageResult.value = null;
lineOptions.value = [];
tunnelOptions.value = [];
lineLocked.value = false;
idempotencyKey.value = "";
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 loadLineOptions(siteId, session, scanRequestGeneration) {
function loadUsageOptions(pallet, session, scanRequestGeneration) {
const generation = ++optionGeneration;
const isCurrent = () => generation === optionGeneration &&
session === operationSession && scanRequestGeneration === scanGeneration;
optionLoading.value = true;
getUsageLineOptions(siteId)
.then((response) => {
if (
generation !== optionGeneration ||
session !== operationSession ||
scanRequestGeneration !== scanGeneration
) {
return;
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 = "该现场暂无可用隧道";
}
lineOptions.value = Array.isArray(response.data) ? response.data : [];
if (!lineOptions.value.length) operationError.value = "该现场暂无可用产线";
})
.catch((error) => {
if (generation !== optionGeneration || session !== operationSession) return;
lineOptions.value = [];
operationError.value = errorMessage(error, "产线选项加载失败,请稍后重新扫码");
if (!isCurrent()) return;
operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
})
.finally(() => {
if (generation === optionGeneration && session === operationSession) {
optionLoading.value = false;
}
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;
});
}
function handleUsageScan(qrToken) {
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 || session !== operationSession) return;
if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
const pallet = response.data;
if (
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
@@ -277,13 +623,26 @@ function handleUsageScan(qrToken) {
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 = qrToken;
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;
@@ -292,17 +651,18 @@ function handleUsageScan(qrToken) {
];
} else {
lineLocked.value = false;
form.lineId = null;
loadLineOptions(pallet.siteId, session, generation);
form.lineId = rejectedOperation?.payload.lineId ?? null;
}
loadUsageOptions(pallet, session, generation);
loadFifoRecommendation();
})
.catch((error) => {
if (generation === scanGeneration && session === operationSession) {
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
}
})
.finally(() => {
if (generation === scanGeneration && session === operationSession) {
if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
scanLoading.value = false;
}
});
@@ -318,63 +678,143 @@ function handleScanDuplicate() {
}
function handleLineChange(event) {
if (lineLocked.value) return;
const option = lineOptions.value[Number(event.detail.value)];
form.lineId = option?.id || null;
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 (!submitLoading.value) idempotencyKey.value = "";
if (operationPending.value) return;
submitError.value = "";
}
function ensureIdempotencyKey() {
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
return idempotencyKey.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;
}
}
function submitUsage() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
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 (!form.lineId) {
submitError.value = "请选择使用产线";
if (!tunnelLocked.value || !form.tunnelId) {
submitError.value = "请先选择隧道并确认入隧";
return;
}
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
submitError.value = "使用包数必须为正整数";
return;
}
if (usedPackages > scannedPallet.value.remainingPackages) {
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 = "";
addUsage({
qrToken: form.qrToken,
lineId: form.lineId,
usedPackages,
idempotencyKey: ensureIdempotencyKey(),
})
.then((response) => {
usageResult.value = response.data;
idempotencyKey.value = "";
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
loadUsageList();
})
.catch((error) => {
submitError.value = `${errorMessage(error, "现场使用提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
})
.finally(() => {
submitLoading.value = false;
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>
@@ -290,6 +290,7 @@ const INVENTORY_STATE_META = {
UNSTOCKED: { name: "未入库", tone: "slate" },
EMPTY_IN_WAREHOUSE: { name: "仓库空托盘", tone: "teal" },
FILLED_IN_WAREHOUSE: { name: "仓库有料", tone: "blue" },
IN_TRANSIT: { name: "在途待收货", tone: "blue" },
AT_SITE_UNUSED: { name: "现场未使用", tone: "purple" },
AT_SITE_PARTIAL: { name: "现场部分使用", tone: "orange" },
AT_SITE_EMPTY: { name: "现场空托盘", tone: "red" },
@@ -72,11 +72,17 @@ import { businessMenuEntries } from "@/config/businessMenu";
const { proxy } = getCurrentInstance();
const visibleBusinessEntries = computed(() =>
businessMenuEntries.filter((entry) => proxy.$auth.hasPermi(entry.permission)),
businessMenuEntries.filter(canOpenBusinessEntry),
);
function canOpenBusinessEntry(entry) {
return entry.permissions
? proxy.$auth.hasPermiOr(entry.permissions)
: proxy.$auth.hasPermi(entry.permission);
}
function handleBusinessEntry(entry) {
if (!proxy.$auth.hasPermi(entry.permission)) {
if (!canOpenBusinessEntry(entry)) {
proxy.$modal.msgError("当前账号没有该业务菜单权限");
return;
}
@@ -101,6 +101,7 @@ const request = async (config) => {
}
const body = getResponseBody(res);
const status = res.statusCode ?? res.status;
const code = body.code || 200;
const msg = errorCode[code] || body.msg || errorCode["default"];
if (code === 401) {
@@ -126,6 +127,11 @@ const request = async (config) => {
const error = new Error(msg);
error.response = res;
reject(error);
} else if (status != null && (status < 200 || status >= 300)) {
// uni.request 的 success 仅表示收到 HTTP 响应,不能据此确认业务成功。
const error = new Error(body.msg || `Request failed with status code ${status}`);
error.response = res;
reject(error);
} else {
resolve(body);
}
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import JSON5 from "json5";
const appRoot = fileURLToPath(new URL("..", import.meta.url));
@@ -33,7 +34,7 @@ const apiContracts = {
"src/api/business/inbound.js": [
["listInbound", "'/business/inbound/list'", "get"],
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
["scanInboundPallet", "'/business/inbound/scan/' + qrToken", "get"],
["scanInboundPallet", "'/business/inbound/scan/' + encodeURIComponent(qrToken)", "get"],
["getInbound", "'/business/inbound/' + inboundId", "get"],
["addInbound", "'/business/inbound'", "post"],
["updateInbound", "'/business/inbound'", "put"],
@@ -42,7 +43,7 @@ const apiContracts = {
"src/api/business/filling.js": [
["listFilling", "'/business/filling/list'", "get"],
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
["scanFillingPallet", "'/business/filling/scan/' + qrToken", "get"],
["scanFillingPallet", "'/business/filling/scan/' + encodeURIComponent(qrToken)", "get"],
["getFilling", "'/business/filling/' + fillingId", "get"],
["addFilling", "'/business/filling'", "post"],
],
@@ -50,7 +51,7 @@ const apiContracts = {
["listShipment", "'/business/shipment/list'", "get"],
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
["scanShipmentPallet", "'/business/shipment/scan/' + qrToken", "get"],
["scanShipmentPallet", "'/business/shipment/scan/' + encodeURIComponent(qrToken)", "get"],
["getShipment", "'/business/shipment/' + shipmentId", "get"],
["addShipment", "'/business/shipment'", "post"],
["updateShipment", "'/business/shipment'", "put"],
@@ -59,14 +60,14 @@ const apiContracts = {
"src/api/business/usage.js": [
["listUsage", "'/business/usage/list'", "get"],
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
["scanUsagePallet", "'/business/usage/scan/' + qrToken", "get"],
["scanUsagePallet", "'/business/usage/scan/' + encodeURIComponent(qrToken)", "get"],
["getUsage", "'/business/usage/' + usageId", "get"],
["addUsage", "'/business/usage'", "post"],
],
"src/api/business/return.js": [
["listReturn", "'/business/return/list'", "get"],
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
["scanReturnPallet", "'/business/return/scan/' + qrToken", "get"],
["scanReturnPallet", "'/business/return/scan/' + encodeURIComponent(qrToken)", "get"],
["requestReturn", "'/business/return/request'", "post"],
["getReturn", "'/business/return/' + returnId", "get"],
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
@@ -145,7 +146,8 @@ test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async
});
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
const pages = JSON.parse(
// uni-app 的 pages.json 允许开发模式注释,与构建端保持相同解析语义。
const pages = JSON5.parse(
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
);
const pagePaths = new Set(pages.pages.map((page) => page.path));
@@ -242,7 +244,7 @@ test("运营驾驶舱 API 对接现有扫码异常接口", async () => {
test("APP 页面标题与后端业务菜单名称一致", async () => {
const [pages, fillingSource] = await Promise.all([
JSON.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")),
JSON5.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")),
readFile(path.join(appRoot, "src/pages/business/filling/index.vue"), "utf8"),
]);
const fillingPage = pages.pages.find((page) => page.path === "pages/business/filling/index");
File diff suppressed because it is too large Load Diff