更新登录页及优化相关功能
This commit is contained in:
@@ -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
Reference in New Issue
Block a user