移动端:增加测试批次模式隔离
This commit is contained in:
@@ -23,6 +23,18 @@
|
|||||||
>
|
>
|
||||||
> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug
|
> 另外谨慎升级 `package.json` 中锁定的 `pinia`/`vue`/`@vue/*` 相关包的版本,新版本可能 `uni-app` 没有兼容,造成一些奇怪的 bug
|
||||||
|
|
||||||
|
### 配置 PDA 后端地址
|
||||||
|
|
||||||
|
PDA 默认请求本机 `http://localhost:9099`,仅适用于后端运行在同一设备的本地开发。部署到独立 PDA 或局域网服务器时,在构建环境注入 `VITE_APP_BASE_API`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_APP_BASE_API=http://<服务器地址>:9099 pnpm run build:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把包含真实凭据的环境文件或地址配置提交到仓库。
|
||||||
|
|
||||||
|
PDA 测试模式、批次授权和失效回退规则请阅读[测试数据批次操作说明](../docs/test-data-batch-operations.md)。PDA 只允许选择后端返回的 ACTIVE 授权批次,退出登录会清除本地批次上下文。
|
||||||
|
|
||||||
### vscode
|
### vscode
|
||||||
|
|
||||||
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
|
使用 `vscode` 的开发者,请先安装 [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 智能提示与感应插件
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"packageManager": "pnpm@10.28.1",
|
"packageManager": "pnpm@10.28.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:business": "node --test tests/business-contract.test.mjs",
|
||||||
"dev": "uni -p mp-weixin",
|
"dev": "uni -p mp-weixin",
|
||||||
"build": "npm run build:mp-weixin",
|
"build": "npm run build:mp-weixin",
|
||||||
"dev:app": "uni -p app",
|
"dev:app": "uni -p app",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listFilling(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFillingStrainOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/strain-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanFillingPallet(qrToken) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/scan/" + qrToken,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilling(fillingId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling/" + fillingId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFilling(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/filling",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listInbound(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInboundWarehouseOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/warehouse-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanInboundPallet(qrToken, query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/scan/" + qrToken,
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInbound(inboundId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/" + inboundId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addInbound(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInbound(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmInbound(inboundId, data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/inbound/" + inboundId + "/confirm",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
headers: { repeatSubmit: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listReturn(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReturnWarehouseOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/warehouse-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanReturnPallet(qrToken, query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/scan/" + qrToken,
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestReturn(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/request",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReturn(returnId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/" + returnId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmReturn(returnId, data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/return/" + returnId + "/confirm",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listShipment(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipmentWarehouseOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/warehouse-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipmentSiteOptions() {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/site-options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanShipmentPallet(qrToken, query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/scan/" + qrToken,
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShipment(shipmentId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/" + shipmentId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addShipment(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateShipment(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmShipment(shipmentId, data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/shipment/" + shipmentId + "/confirm",
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export function listUsage(query) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/list",
|
||||||
|
method: "get",
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsageLineOptions(siteId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/line-options",
|
||||||
|
method: "get",
|
||||||
|
params: { siteId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanUsagePallet(qrToken) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/scan/" + qrToken,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsage(usageId) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage/" + usageId,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addUsage(data) {
|
||||||
|
return request({
|
||||||
|
url: "/business/usage",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
// PDA 只读取当前登录用户被授权且仍处于 ACTIVE 状态的批次。
|
||||||
|
export function listAvailableTestBatches() {
|
||||||
|
return request({
|
||||||
|
url: "/system/test-batch/options",
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<view class="w-full">
|
||||||
|
<view class="flex items-center rounded-xl bg-white p-2 shadow-sm">
|
||||||
|
<input
|
||||||
|
v-model="scanValue"
|
||||||
|
class="h-11 min-w-0 flex-1 px-3 text-sm text-gray-700"
|
||||||
|
type="text"
|
||||||
|
confirm-type="done"
|
||||||
|
maxlength="128"
|
||||||
|
:focus="focusRequested"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
placeholder="请扫描或输入托盘二维码后按回车"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
@input="clearError"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="ml-2 h-10 rounded-lg bg-blue-500 px-3 text-xs text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ loading ? "校验中" : "提交扫描" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="errorMessage" class="mt-2 block text-xs text-red-500">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onMounted, ref, watch } from "vue";
|
||||||
|
import { isDuplicateScan, normalizeScanValue } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
duplicateWindowMs: {
|
||||||
|
type: Number,
|
||||||
|
default: 500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["scan", "invalid", "duplicate"]);
|
||||||
|
const scanValue = ref("");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const focusRequested = ref(false);
|
||||||
|
let lastScanValue = "";
|
||||||
|
let lastScanAt = 0;
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
if (props.disabled || props.loading) return;
|
||||||
|
focusRequested.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
if (!props.disabled && !props.loading) {
|
||||||
|
focusRequested.value = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (props.disabled || props.loading) return;
|
||||||
|
|
||||||
|
let normalizedValue;
|
||||||
|
try {
|
||||||
|
normalizedValue = normalizeScanValue(scanValue.value);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message;
|
||||||
|
emit("invalid", error.message);
|
||||||
|
focusInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scannedAt = Date.now();
|
||||||
|
if (
|
||||||
|
isDuplicateScan(
|
||||||
|
normalizedValue,
|
||||||
|
lastScanValue,
|
||||||
|
scannedAt,
|
||||||
|
lastScanAt,
|
||||||
|
props.duplicateWindowMs,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
scanValue.value = "";
|
||||||
|
emit("duplicate", normalizedValue);
|
||||||
|
focusInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScanValue = normalizedValue;
|
||||||
|
lastScanAt = scannedAt;
|
||||||
|
scanValue.value = "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
emit("scan", normalizedValue);
|
||||||
|
focusInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(focusInput);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.disabled, props.loading],
|
||||||
|
([disabled, loading]) => {
|
||||||
|
if (!disabled && !loading) focusInput();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
v-if="testBatchStore.isTestMode"
|
||||||
|
class="flex items-start gap-3 rounded-xl border border-red-300 bg-red-50 px-3 py-3"
|
||||||
|
>
|
||||||
|
<view class="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-red-500 text-white">
|
||||||
|
<view class="i-mdi-flask-outline text-sm"></view>
|
||||||
|
</view>
|
||||||
|
<view class="min-w-0">
|
||||||
|
<text class="block text-sm font-bold text-red-700">测试数据</text>
|
||||||
|
<text class="mt-0.5 block break-all text-xs leading-5 text-red-600">
|
||||||
|
当前批次:{{ testBatchStore.currentBatchName || "已选测试批次" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTestBatchStore } from "@/store";
|
||||||
|
|
||||||
|
const testBatchStore = useTestBatchStore();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<view class="space-y-3 rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="flex items-center justify-between gap-3">
|
||||||
|
<view class="min-w-0">
|
||||||
|
<text class="block text-sm font-bold text-gray-800">数据模式</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ testBatchStore.isTestMode ? "当前操作隔离在测试批次" : "当前使用正式数据" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
:class="testBatchStore.isTestMode ? 'border-red-300 bg-red-50 text-red-700' : 'border-gray-200 bg-gray-50 text-gray-600'"
|
||||||
|
class="shrink-0 rounded-full border px-2.5 py-1 text-xs font-semibold"
|
||||||
|
>
|
||||||
|
{{ testBatchStore.isTestMode ? "测试数据" : "正式数据" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex rounded-xl bg-gray-100 p-1">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg py-2 text-xs transition-colors"
|
||||||
|
:class="!testBatchStore.isTestMode ? 'bg-white font-semibold text-gray-800 shadow-sm' : 'text-gray-500'"
|
||||||
|
:disabled="loading"
|
||||||
|
size="mini"
|
||||||
|
@click="handleModeClick('formal')"
|
||||||
|
>
|
||||||
|
正式
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-lg py-2 text-xs transition-colors"
|
||||||
|
:class="testBatchStore.isTestMode ? 'bg-red-500 font-semibold text-white shadow-sm' : 'text-gray-500'"
|
||||||
|
:disabled="loading || !activeOptions.length"
|
||||||
|
size="mini"
|
||||||
|
@click="handleModeClick('test')"
|
||||||
|
>
|
||||||
|
测试
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="testBatchStore.isTestMode || activeOptions.length" class="space-y-1">
|
||||||
|
<text class="block text-xs text-gray-500">测试批次</text>
|
||||||
|
<picker
|
||||||
|
:range="activeOptions"
|
||||||
|
range-key="batchName"
|
||||||
|
:value="selectedBatchIndex"
|
||||||
|
:disabled="loading || !activeOptions.length"
|
||||||
|
@change="handleBatchChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedBatch?.batchName || (loading ? "加载中..." : "暂无可用测试批次") }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loadError" class="flex items-center justify-between gap-2 rounded-lg bg-orange-50 px-3 py-2">
|
||||||
|
<text class="min-w-0 text-xs leading-5 text-orange-700">{{ loadError }}</text>
|
||||||
|
<button class="shrink-0 text-xs text-orange-700" size="mini" :disabled="loading" @click="loadOptions">
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useTestBatchStore } from "@/store";
|
||||||
|
|
||||||
|
const testBatchStore = useTestBatchStore();
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref("");
|
||||||
|
const activeOptions = computed(() =>
|
||||||
|
testBatchStore.options.filter((item) => item.status === "ACTIVE"),
|
||||||
|
);
|
||||||
|
const selectedBatch = computed(() =>
|
||||||
|
activeOptions.value.find(
|
||||||
|
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedBatchIndex = computed(() => {
|
||||||
|
const index = activeOptions.value.findIndex(
|
||||||
|
(item) => String(item.batchId) === String(testBatchStore.currentBatchId),
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorMessage(error) {
|
||||||
|
return error?.response?.data?.msg || error?.message || "测试批次加载失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
|
try {
|
||||||
|
await testBatchStore.refreshOptions();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.reason !== "TEST_BATCH_UNAVAILABLE") {
|
||||||
|
loadError.value = errorMessage(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmModeSwitch() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "切换数据模式",
|
||||||
|
content: "切换后会清空当前页面未提交的扫码和表单内容,确定继续吗?",
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "确定",
|
||||||
|
success: (result) => resolve(result.confirm === true),
|
||||||
|
fail: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModeClick(mode) {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (mode === "formal") {
|
||||||
|
if (!testBatchStore.isTestMode || !(await confirmModeSwitch())) return;
|
||||||
|
testBatchStore.leaveTestMode();
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testBatchStore.isTestMode) return;
|
||||||
|
const batch = activeOptions.value[0];
|
||||||
|
if (!batch) {
|
||||||
|
uni.showToast({ title: "暂无可用测试批次", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await confirmModeSwitch())) return;
|
||||||
|
try {
|
||||||
|
testBatchStore.selectBatch(batch);
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchChange(event) {
|
||||||
|
const batch = activeOptions.value[Number(event.detail.value)];
|
||||||
|
if (!batch || String(batch.batchId) === String(testBatchStore.currentBatchId)) return;
|
||||||
|
if (!(await confirmModeSwitch())) return;
|
||||||
|
try {
|
||||||
|
testBatchStore.selectBatch(batch);
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
} catch (error) {
|
||||||
|
uni.showToast({ title: error.message || "测试批次不可用", icon: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadOptions);
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// 应用全局配置
|
// 应用全局配置
|
||||||
export default {
|
export default {
|
||||||
baseUrl: "http://localhost:9099",
|
// PDA 部署到独立设备时由构建环境注入服务器地址;本地开发保留回环地址。
|
||||||
|
baseUrl: import.meta.env.VITE_APP_BASE_API || "http://localhost:9099",
|
||||||
// 应用信息
|
// 应用信息
|
||||||
appInfo: {
|
appInfo: {
|
||||||
// 应用名称
|
// 应用名称
|
||||||
|
|||||||
@@ -5,10 +5,24 @@ import store from "./store"; // store
|
|||||||
import { install } from "./plugins"; // plugins
|
import { install } from "./plugins"; // plugins
|
||||||
import "./permission"; // permission
|
import "./permission"; // permission
|
||||||
import { useDict } from "@/utils/dict";
|
import { useDict } from "@/utils/dict";
|
||||||
|
import { useTestBatchStore } from "./store";
|
||||||
|
|
||||||
|
let testBatchUnavailableListenerRegistered = false;
|
||||||
|
|
||||||
|
function registerTestBatchUnavailableListener() {
|
||||||
|
if (testBatchUnavailableListenerRegistered || typeof uni.$on !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testBatchUnavailableListenerRegistered = true;
|
||||||
|
uni.$on("test-batch-unavailable", () => {
|
||||||
|
useTestBatchStore().leaveTestMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = createSSRApp(App);
|
const app = createSSRApp(App);
|
||||||
app.use(store);
|
app.use(store);
|
||||||
|
registerTestBatchUnavailableListener();
|
||||||
app.config.globalProperties.useDict = useDict;
|
app.config.globalProperties.useDict = useDict;
|
||||||
install(app);
|
install(app);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,6 +25,36 @@
|
|||||||
"navigationBarTitleText": "工作台"
|
"navigationBarTitleText": "工作台"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/inbound/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "仓库入库"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/filling/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "托盘装填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/shipment/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "发货出库"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/usage/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "现场使用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/business/return/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "退回仓库"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/mine/index",
|
"path": "pages/mine/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">托盘装填</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
扫描空托盘,录入菌种、批次、规格和初始包数
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有装填操作权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">扫描待装填托盘</text>
|
||||||
|
<button
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="resetOperation"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleFillingScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受 EMPTY_IN_WAREHOUSE 状态托盘。
|
||||||
|
</text>
|
||||||
|
<text v-if="scanError" class="mt-2 block text-xs text-red-500">{{ scanError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-green-50 p-3">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">
|
||||||
|
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-green-600">{{ scannedPallet.flowState }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
仓库:{{ scannedPallet.warehouseName || `#${scannedPallet.warehouseId}` }} · 容量:{{ scannedPallet.capacityPackages }} 包
|
||||||
|
</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 class="space-y-3">
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">菌种</text>
|
||||||
|
<picker
|
||||||
|
:range="strainOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="strainIndex"
|
||||||
|
:disabled="operationPending || optionLoading || !scannedPallet"
|
||||||
|
@change="handleStrainChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedStrainName || "请选择启用菌种" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">生产批次</text>
|
||||||
|
<input
|
||||||
|
v-model="form.batchNo"
|
||||||
|
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="请输入生产批次"
|
||||||
|
:disabled="operationPending || !scannedPallet"
|
||||||
|
@input="markPayloadChanged"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">包装规格</text>
|
||||||
|
<input
|
||||||
|
v-model="form.packageSpec"
|
||||||
|
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="例如:500g/包"
|
||||||
|
:disabled="operationPending || !scannedPallet"
|
||||||
|
@input="markPayloadChanged"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">初始包数</text>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||||
|
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="formError" class="mt-3 block text-xs text-red-500">{{ formError }}</text>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-green-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canOperate || operationPending || scanLoading || !scannedPallet"
|
||||||
|
@click="submitFilling"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "提交中" : "确认装填" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">最近装填记录</text>
|
||||||
|
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadFillingList">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!fillingList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无装填记录
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in fillingList"
|
||||||
|
v-else
|
||||||
|
:key="row.fillingId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<text class="block font-medium text-gray-700">{{ row.fillingNo || `#${row.fillingId}` }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ row.strainNameSnapshot || row.strainCodeSnapshot }} · {{ row.batchNo }} · {{ row.initialPackages }} 包
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-400">托盘 #{{ row.palletId }} · {{ row.operatedAt || row.createTime || "—" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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 {
|
||||||
|
addFilling,
|
||||||
|
getFillingStrainOptions,
|
||||||
|
listFilling,
|
||||||
|
scanFillingPallet,
|
||||||
|
} from "@/api/business/filling";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const fillingList = ref([]);
|
||||||
|
const strainOptions = ref([]);
|
||||||
|
const scannedPallet = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const scanError = ref("");
|
||||||
|
const formError = ref("");
|
||||||
|
const idempotencyKey = ref("");
|
||||||
|
const form = reactive({
|
||||||
|
qrToken: "",
|
||||||
|
warehouseId: null,
|
||||||
|
strainId: null,
|
||||||
|
batchNo: "",
|
||||||
|
packageSpec: "",
|
||||||
|
initialPackages: "",
|
||||||
|
});
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:filling:list",
|
||||||
|
"business:filling:query",
|
||||||
|
"business:filling:add",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:filling:list"));
|
||||||
|
const canOperate = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:filling:query", "business:filling:add"]),
|
||||||
|
);
|
||||||
|
const operationPending = computed(() => submitLoading.value);
|
||||||
|
const strainIndex = computed(() => {
|
||||||
|
const index = strainOptions.value.findIndex((item) => item.id === form.strainId);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedStrainName = computed(
|
||||||
|
() => strainOptions.value.find((item) => item.id === form.strainId)?.name || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFillingList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listFilling({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
fillingList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
fillingList.value = [];
|
||||||
|
listError.value = errorMessage(error, "装填记录加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStrainOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:filling:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
getFillingStrainOptions()
|
||||||
|
.then((response) => {
|
||||||
|
strainOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
strainOptions.value = [];
|
||||||
|
formError.value = errorMessage(error, "菌种选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetOperation() {
|
||||||
|
scanGeneration += 1;
|
||||||
|
scannedPallet.value = null;
|
||||||
|
idempotencyKey.value = "";
|
||||||
|
form.qrToken = "";
|
||||||
|
form.warehouseId = null;
|
||||||
|
form.strainId = null;
|
||||||
|
form.batchNo = "";
|
||||||
|
form.packageSpec = "";
|
||||||
|
form.initialPackages = "";
|
||||||
|
scanError.value = "";
|
||||||
|
formError.value = "";
|
||||||
|
scanLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFillingScan(qrToken) {
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||||
|
resetOperation();
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanError.value = "";
|
||||||
|
scanFillingPallet(qrToken)
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (pallet?.flowState !== "EMPTY_IN_WAREHOUSE") {
|
||||||
|
scanError.value = "该托盘当前不可装填";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallet.value = {
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
warehouseId: pallet.warehouseId,
|
||||||
|
warehouseName: pallet.warehouseName,
|
||||||
|
flowState: pallet.flowState,
|
||||||
|
capacityPackages: pallet.capacityPackages,
|
||||||
|
};
|
||||||
|
form.qrToken = qrToken;
|
||||||
|
form.warehouseId = pallet.warehouseId;
|
||||||
|
loadStrainOptions();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration) {
|
||||||
|
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration) scanLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
resetOperation();
|
||||||
|
scanError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStrainChange(event) {
|
||||||
|
const option = strainOptions.value[Number(event.detail.value)];
|
||||||
|
form.strainId = option?.id || null;
|
||||||
|
markPayloadChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPayloadChanged() {
|
||||||
|
if (!submitLoading.value) idempotencyKey.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIdempotencyKey() {
|
||||||
|
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
|
||||||
|
return idempotencyKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitFilling() {
|
||||||
|
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||||
|
const initialPackages = Number(form.initialPackages);
|
||||||
|
if (!scannedPallet.value || !form.qrToken || !form.warehouseId) {
|
||||||
|
formError.value = "请先扫描待装填托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.strainId || !form.batchNo.trim() || !form.packageSpec.trim()) {
|
||||||
|
formError.value = "请完整填写菌种、批次和包装规格";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(initialPackages) || initialPackages <= 0) {
|
||||||
|
formError.value = "初始包数必须为正整数";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitLoading.value = true;
|
||||||
|
formError.value = "";
|
||||||
|
addFilling({
|
||||||
|
qrToken: form.qrToken,
|
||||||
|
warehouseId: form.warehouseId,
|
||||||
|
strainId: form.strainId,
|
||||||
|
batchNo: form.batchNo.trim(),
|
||||||
|
packageSpec: form.packageSpec.trim(),
|
||||||
|
initialPackages,
|
||||||
|
idempotencyKey: ensureIdempotencyKey(),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
idempotencyKey.value = "";
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "托盘装填成功");
|
||||||
|
resetOperation();
|
||||||
|
loadFillingList();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = `${errorMessage(error, "装填提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadFillingList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">仓库入库</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
扫描尚未首次入库的空托盘,保存草稿后确认
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="canDraft"
|
||||||
|
class="rounded-lg bg-blue-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startNewDraft"
|
||||||
|
>
|
||||||
|
新建草稿
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<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">
|
||||||
|
{{ editing ? "编辑入库草稿" : "新建入库草稿" }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">入库仓库</text>
|
||||||
|
<picker
|
||||||
|
:range="warehouseOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="warehouseIndex"
|
||||||
|
:disabled="optionLoading || detailLoading || operationPending"
|
||||||
|
@change="handleWarehouseChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedWarehouseName || "请选择授权仓库" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="!form.warehouseId || detailLoading || operationPending"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleInboundScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受 UNSTOCKED 托盘;同一托盘不会重复加入。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||||
|
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||||
|
<view
|
||||||
|
v-for="pallet in scannedPallets"
|
||||||
|
:key="pallet.palletId"
|
||||||
|
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<text class="block text-sm font-medium text-gray-700">
|
||||||
|
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="block text-xs text-gray-500">{{ pallet.flowState }}</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="text-xs text-red-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="removeScannedPallet(pallet.palletId)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||||
|
@click="saveDraft"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="cancelEditor"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">入库单</text>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="loading || operationPending"
|
||||||
|
@click="loadInboundList"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!inboundList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无入库单
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in inboundList"
|
||||||
|
v-else
|
||||||
|
:key="row.inboundId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">{{ row.inboundNo || `#${row.inboundId}` }}</text>
|
||||||
|
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||||
|
{{ row.status === "DRAFT" ? "草稿" : "已确认" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
仓库:{{ warehouseName(row.warehouseId) }} · 托盘:{{ row.items?.length || 0 }} 个
|
||||||
|
</text>
|
||||||
|
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startEditDraft(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canConfirm"
|
||||||
|
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="confirmDraft(row)"
|
||||||
|
>
|
||||||
|
{{ confirmLoading ? "确认中" : "确认" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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 {
|
||||||
|
addInbound,
|
||||||
|
confirmInbound,
|
||||||
|
getInbound,
|
||||||
|
getInboundWarehouseOptions,
|
||||||
|
listInbound,
|
||||||
|
scanInboundPallet,
|
||||||
|
updateInbound,
|
||||||
|
} from "@/api/business/inbound";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const inboundList = ref([]);
|
||||||
|
const warehouseOptions = ref([]);
|
||||||
|
const scannedPallets = ref([]);
|
||||||
|
const editorVisible = ref(false);
|
||||||
|
const editing = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const confirmLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const formError = ref("");
|
||||||
|
const scanError = ref("");
|
||||||
|
const confirmError = ref("");
|
||||||
|
const form = reactive({ inboundId: null, warehouseId: null });
|
||||||
|
const confirmState = reactive({ inboundId: null, key: "" });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:inbound:list",
|
||||||
|
"business:inbound:query",
|
||||||
|
"business:inbound:add",
|
||||||
|
"business:inbound:edit",
|
||||||
|
"business:inbound:confirm",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:inbound:list"));
|
||||||
|
const canDraft = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:add"]),
|
||||||
|
);
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:inbound:query", "business:inbound:edit"]),
|
||||||
|
);
|
||||||
|
const canConfirm = computed(() =>
|
||||||
|
proxy.$auth.hasPermi("business:inbound:confirm"),
|
||||||
|
);
|
||||||
|
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||||
|
const operationPending = computed(
|
||||||
|
() =>
|
||||||
|
submitLoading.value ||
|
||||||
|
confirmLoading.value ||
|
||||||
|
detailLoading.value,
|
||||||
|
);
|
||||||
|
const warehouseIndex = computed(() => {
|
||||||
|
const index = warehouseOptions.value.findIndex(
|
||||||
|
(item) => item.id === form.warehouseId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedWarehouseName = computed(
|
||||||
|
() => warehouseOptions.value.find((item) => item.id === form.warehouseId)?.name || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseName(id) {
|
||||||
|
return warehouseOptions.value.find((item) => item.id === id)?.name || `#${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
scanGeneration += 1;
|
||||||
|
editorVisible.value = false;
|
||||||
|
editing.value = false;
|
||||||
|
form.inboundId = null;
|
||||||
|
form.warehouseId = null;
|
||||||
|
scannedPallets.value = [];
|
||||||
|
detailLoading.value = false;
|
||||||
|
scanLoading.value = false;
|
||||||
|
formError.value = "";
|
||||||
|
scanError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInboundList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listInbound({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
inboundList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
inboundList.value = [];
|
||||||
|
listError.value = errorMessage(error, "入库单列表加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWarehouseOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:inbound:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
getInboundWarehouseOptions()
|
||||||
|
.then((response) => {
|
||||||
|
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warehouseOptions.value = [];
|
||||||
|
formError.value = errorMessage(error, "仓库选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewDraft() {
|
||||||
|
if (!canDraft.value || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
loadWarehouseOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditDraft(row) {
|
||||||
|
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
editing.value = true;
|
||||||
|
form.inboundId = row.inboundId;
|
||||||
|
loadWarehouseOptions();
|
||||||
|
detailLoading.value = true;
|
||||||
|
getInbound(row.inboundId)
|
||||||
|
.then((response) => {
|
||||||
|
const detail = response.data;
|
||||||
|
if (!detail || detail.status !== "DRAFT") {
|
||||||
|
formError.value = "该入库单已不是草稿状态,不能编辑";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.warehouseId = detail.warehouseId;
|
||||||
|
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||||
|
(item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
flowState: item.flowState || "UNSTOCKED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "入库单详情加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detailLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditor() {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWarehouseChange(event) {
|
||||||
|
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === form.warehouseId) return;
|
||||||
|
form.warehouseId = option.id;
|
||||||
|
scannedPallets.value = [];
|
||||||
|
scanError.value = "";
|
||||||
|
scanGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInboundScan(qrToken) {
|
||||||
|
if (!form.warehouseId || operationPending.value || scanLoading.value) {
|
||||||
|
scanError.value = "请先选择入库仓库";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanError.value = "";
|
||||||
|
scanInboundPallet(qrToken, { warehouseId: form.warehouseId })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (pallet?.flowState !== "UNSTOCKED") {
|
||||||
|
scanError.value = "该托盘不是未入库状态,不能加入入库草稿";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已在当前草稿中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
flowState: pallet.flowState,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration) {
|
||||||
|
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration) scanLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
scanError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScannedPallet(palletId) {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
scannedPallets.value = scannedPallets.value.filter(
|
||||||
|
(item) => item.palletId !== palletId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||||
|
if (!form.warehouseId) {
|
||||||
|
formError.value = "请选择入库仓库";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scannedPallets.value.length) {
|
||||||
|
formError.value = "请至少扫描一个托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitLoading.value = true;
|
||||||
|
formError.value = "";
|
||||||
|
const payload = {
|
||||||
|
warehouseId: form.warehouseId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||||
|
};
|
||||||
|
const submitRequest = editing.value
|
||||||
|
? updateInbound({ ...payload, inboundId: form.inboundId })
|
||||||
|
: addInbound(payload);
|
||||||
|
submitRequest
|
||||||
|
.then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "入库草稿保存成功");
|
||||||
|
resetEditor();
|
||||||
|
loadInboundList();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "入库草稿保存失败,请根据提示修正后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(content) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "系统提示",
|
||||||
|
content,
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "确定",
|
||||||
|
success: (result) => resolve(Boolean(result.confirm)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(inboundId) {
|
||||||
|
if (confirmState.inboundId !== inboundId) {
|
||||||
|
confirmState.inboundId = inboundId;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||||
|
return confirmState.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConfirmIdempotency() {
|
||||||
|
confirmState.inboundId = null;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDraft(row) {
|
||||||
|
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
if (!(await askConfirm(`确认入库单“${row.inboundNo || `#${row.inboundId}`}”?`))) return;
|
||||||
|
confirmLoading.value = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
try {
|
||||||
|
await confirmInbound(row.inboundId, {
|
||||||
|
idempotencyKey: ensureConfirmIdempotencyKey(row.inboundId),
|
||||||
|
});
|
||||||
|
clearConfirmIdempotency();
|
||||||
|
proxy.$modal.msgSuccess("入库确认成功");
|
||||||
|
loadInboundList();
|
||||||
|
} catch (error) {
|
||||||
|
confirmError.value = `${errorMessage(error, "入库确认失败")};再次确认将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadInboundList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">退回仓库</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
客户现场发起退回申请,仓库逐托扫描后确认收货
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有退回操作权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="canRequest" class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">发起退回申请</text>
|
||||||
|
<button class="text-xs text-gray-500" size="mini" :disabled="requestPending" @click="resetRequestOperation">
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">目标仓库</text>
|
||||||
|
<picker
|
||||||
|
:range="warehouseOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="warehouseIndex"
|
||||||
|
:disabled="optionLoading || requestPending"
|
||||||
|
@change="handleWarehouseChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedWarehouseName || "请选择目标仓库" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="!requestForm.targetWarehouseId || requestPending"
|
||||||
|
:loading="scanLoading.request"
|
||||||
|
@scan="handleRequestScan"
|
||||||
|
@invalid="handleRequestScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
同一申请只能包含同一客户现场的托盘;扫码成功后托盘由服务端冻结。
|
||||||
|
</text>
|
||||||
|
<text v-if="requestError" class="mt-2 block text-xs text-red-500">{{ requestError }}</text>
|
||||||
|
<view v-if="requestScannedPallets.length" class="mt-3 space-y-2">
|
||||||
|
<view
|
||||||
|
v-for="pallet in requestScannedPallets"
|
||||||
|
:key="pallet.palletId"
|
||||||
|
class="flex items-center justify-between rounded-xl bg-blue-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<text class="block text-sm text-gray-700">{{ pallet.palletCode || `#${pallet.palletId}` }}</text>
|
||||||
|
<text class="block text-xs text-gray-500">现场 #{{ pallet.siteId }} · 剩余 {{ pallet.remainingPackages }} 包</text>
|
||||||
|
</view>
|
||||||
|
<button class="text-xs text-red-500" size="mini" :disabled="requestPending" @click="removeRequestPallet(pallet.palletId)">
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text v-if="requestForm.sourceSiteId" class="mt-3 block text-xs text-gray-500">
|
||||||
|
来源现场:#{{ requestForm.sourceSiteId }} · {{ requestScannedPallets.length }} 个托盘
|
||||||
|
</text>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-blue-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!requestScannedPallets.length || requestPending || scanLoading.request"
|
||||||
|
@click="submitReturnRequest"
|
||||||
|
>
|
||||||
|
{{ requestSubmitLoading ? "申请中" : "提交退回申请" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="canConfirm" 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>
|
||||||
|
<text class="text-xs text-gray-400">必须完整扫描单据托盘</text>
|
||||||
|
</view>
|
||||||
|
<picker
|
||||||
|
:range="requestedReturns"
|
||||||
|
range-key="returnNo"
|
||||||
|
:value="requestedReturnIndex"
|
||||||
|
:disabled="confirmPending || detailLoading"
|
||||||
|
@change="handleConfirmSelection"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedReturnLabel || "请选择待确认退回单" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text v-if="confirmError" class="mt-2 block text-xs text-red-500">{{ confirmError }}</text>
|
||||||
|
<view v-if="confirmExpectedRows.length" class="mt-3 rounded-xl bg-orange-50 p-3">
|
||||||
|
<text class="block text-xs text-gray-500">
|
||||||
|
预期托盘 {{ confirmExpectedRows.length }} 个,已扫描 {{ confirmScannedPallets.length }} 个
|
||||||
|
</text>
|
||||||
|
<view class="mt-2 space-y-1">
|
||||||
|
<text v-for="row in confirmExpectedRows" :key="row.palletId" class="block text-xs text-gray-600">
|
||||||
|
{{ row.palletCode || `#${row.palletId}` }}
|
||||||
|
<text v-if="isConfirmPalletScanned(row.palletId)" class="text-green-600"> · 已扫描</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
class="mt-3"
|
||||||
|
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || detailLoading"
|
||||||
|
:loading="scanLoading.confirm"
|
||||||
|
@scan="handleConfirmScan"
|
||||||
|
@invalid="handleConfirmScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!confirmForm.returnId || !confirmExpectedRows.length || confirmPending || scanLoading.confirm || detailLoading"
|
||||||
|
@click="submitReturnConfirm"
|
||||||
|
>
|
||||||
|
{{ confirmSubmitLoading ? "确认中" : "确认退回收货" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">退回单</text>
|
||||||
|
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadReturnList">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!returnList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无退回单
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in returnList"
|
||||||
|
v-else
|
||||||
|
:key="row.returnId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">{{ row.returnNo || `#${row.returnId}` }}</text>
|
||||||
|
<text :class="row.status === 'REQUESTED' ? 'text-orange-500' : 'text-green-500'" class="text-xs">
|
||||||
|
{{ row.status === "REQUESTED" ? "待确认" : "已确认" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
现场 #{{ row.sourceSiteId }} → {{ warehouseName(row.targetWarehouseId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||||
|
</text>
|
||||||
|
<button
|
||||||
|
v-if="row.status === 'REQUESTED' && canConfirm"
|
||||||
|
class="mt-3 w-full rounded-lg bg-orange-50 py-2 text-xs text-orange-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="confirmPending"
|
||||||
|
@click="selectReturnRow(row)"
|
||||||
|
>
|
||||||
|
选择并确认
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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 {
|
||||||
|
confirmReturn,
|
||||||
|
getReturn,
|
||||||
|
getReturnWarehouseOptions,
|
||||||
|
listReturn,
|
||||||
|
requestReturn,
|
||||||
|
scanReturnPallet,
|
||||||
|
} from "@/api/business/return";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const returnList = ref([]);
|
||||||
|
const warehouseOptions = ref([]);
|
||||||
|
const requestScannedPallets = ref([]);
|
||||||
|
const confirmExpectedRows = ref([]);
|
||||||
|
const confirmExpectedPalletIds = ref([]);
|
||||||
|
const confirmScannedPallets = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const requestError = ref("");
|
||||||
|
const confirmError = ref("");
|
||||||
|
const scanLoading = reactive({ request: false, confirm: false });
|
||||||
|
const requestSubmitLoading = ref(false);
|
||||||
|
const confirmSubmitLoading = ref(false);
|
||||||
|
const requestIdempotencyKey = ref("");
|
||||||
|
const confirmIdempotencyKey = ref("");
|
||||||
|
const requestForm = reactive({ sourceSiteId: null, targetWarehouseId: null });
|
||||||
|
const confirmForm = reactive({ returnId: null });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let requestScanGeneration = 0;
|
||||||
|
let confirmScanGeneration = 0;
|
||||||
|
let detailGeneration = 0;
|
||||||
|
let requestSession = 0;
|
||||||
|
let confirmSession = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:return:list",
|
||||||
|
"business:return:query",
|
||||||
|
"business:return:request",
|
||||||
|
"business:return:confirm",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:return:list"));
|
||||||
|
const canRequest = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:request"]),
|
||||||
|
);
|
||||||
|
const canConfirm = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:return:query", "business:return:confirm"]),
|
||||||
|
);
|
||||||
|
const requestPending = computed(() => requestSubmitLoading.value);
|
||||||
|
const confirmPending = computed(() => confirmSubmitLoading.value);
|
||||||
|
const warehouseIndex = computed(() => {
|
||||||
|
const index = warehouseOptions.value.findIndex(
|
||||||
|
(item) => item.id === requestForm.targetWarehouseId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedWarehouseName = computed(
|
||||||
|
() => warehouseOptions.value.find((item) => item.id === requestForm.targetWarehouseId)?.name || "",
|
||||||
|
);
|
||||||
|
const requestedReturns = computed(() =>
|
||||||
|
returnList.value.filter((item) => item.status === "REQUESTED"),
|
||||||
|
);
|
||||||
|
const requestedReturnIndex = computed(() => {
|
||||||
|
const index = requestedReturns.value.findIndex(
|
||||||
|
(item) => item.returnId === confirmForm.returnId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedReturnLabel = computed(() => {
|
||||||
|
const item = requestedReturns.value.find((row) => row.returnId === confirmForm.returnId);
|
||||||
|
return item ? `${item.returnNo || `#${item.returnId}`} · 现场 #${item.sourceSiteId}` : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseName(id) {
|
||||||
|
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReturnList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listReturn({ pageNum: 1, pageSize: 30 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
returnList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
returnList.value = [];
|
||||||
|
listError.value = errorMessage(error, "退回单列表加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWarehouseOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:return:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
getReturnWarehouseOptions()
|
||||||
|
.then((response) => {
|
||||||
|
warehouseOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warehouseOptions.value = [];
|
||||||
|
requestError.value = errorMessage(error, "目标仓库选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRequestOperation() {
|
||||||
|
requestSession += 1;
|
||||||
|
requestScanGeneration += 1;
|
||||||
|
requestForm.sourceSiteId = null;
|
||||||
|
requestForm.targetWarehouseId = null;
|
||||||
|
requestScannedPallets.value = [];
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
requestError.value = "";
|
||||||
|
scanLoading.request = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWarehouseChange(event) {
|
||||||
|
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === requestForm.targetWarehouseId) return;
|
||||||
|
requestForm.targetWarehouseId = option.id;
|
||||||
|
requestForm.sourceSiteId = null;
|
||||||
|
requestScannedPallets.value = [];
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
requestError.value = "";
|
||||||
|
requestScanGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestScan(qrToken) {
|
||||||
|
if (!requestForm.targetWarehouseId || requestPending.value || scanLoading.request) {
|
||||||
|
requestError.value = "请先选择目标仓库";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = requestSession;
|
||||||
|
const generation = ++requestScanGeneration;
|
||||||
|
scanLoading.request = true;
|
||||||
|
requestError.value = "";
|
||||||
|
scanReturnPallet(qrToken, { mode: "request" })
|
||||||
|
.then((response) => {
|
||||||
|
if (session !== requestSession || generation !== requestScanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (!pallet?.siteId) {
|
||||||
|
requestError.value = "该托盘不在客户现场,不能发起退回";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestForm.sourceSiteId && pallet.siteId !== requestForm.sourceSiteId) {
|
||||||
|
requestError.value = "同一退回申请只能包含同一客户现场的托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已在退回申请中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestForm.sourceSiteId = requestForm.sourceSiteId || pallet.siteId;
|
||||||
|
requestScannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
siteId: pallet.siteId,
|
||||||
|
remainingPackages: pallet.remainingPackages,
|
||||||
|
});
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (session === requestSession && generation === requestScanGeneration) {
|
||||||
|
requestError.value = errorMessage(error, "退回托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (session === requestSession && generation === requestScanGeneration) {
|
||||||
|
scanLoading.request = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestScanInvalid(message) {
|
||||||
|
requestError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRequestPallet(palletId) {
|
||||||
|
if (requestPending.value) return;
|
||||||
|
requestScannedPallets.value = requestScannedPallets.value.filter(
|
||||||
|
(item) => item.palletId !== palletId,
|
||||||
|
);
|
||||||
|
if (!requestScannedPallets.value.length) requestForm.sourceSiteId = null;
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRequestIdempotencyKey() {
|
||||||
|
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey();
|
||||||
|
return requestIdempotencyKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReturnRequest() {
|
||||||
|
if (requestPending.value || scanLoading.request || !requestScannedPallets.value.length) return;
|
||||||
|
if (!requestForm.sourceSiteId || !requestForm.targetWarehouseId) {
|
||||||
|
requestError.value = "请选择目标仓库并扫描退回托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestSubmitLoading.value = true;
|
||||||
|
requestError.value = "";
|
||||||
|
try {
|
||||||
|
await requestReturn({
|
||||||
|
sourceSiteId: requestForm.sourceSiteId,
|
||||||
|
targetWarehouseId: requestForm.targetWarehouseId,
|
||||||
|
palletIds: requestScannedPallets.value.map((item) => item.palletId),
|
||||||
|
idempotencyKey: ensureRequestIdempotencyKey(),
|
||||||
|
});
|
||||||
|
requestIdempotencyKey.value = "";
|
||||||
|
proxy.$modal.msgSuccess("退回申请已提交");
|
||||||
|
resetRequestOperation();
|
||||||
|
loadReturnList();
|
||||||
|
} catch (error) {
|
||||||
|
requestError.value = `${errorMessage(error, "退回申请提交失败")};直接重试将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
requestSubmitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConfirmOperation() {
|
||||||
|
confirmSession += 1;
|
||||||
|
confirmScanGeneration += 1;
|
||||||
|
detailGeneration += 1;
|
||||||
|
confirmForm.returnId = null;
|
||||||
|
confirmExpectedRows.value = [];
|
||||||
|
confirmExpectedPalletIds.value = [];
|
||||||
|
confirmScannedPallets.value = [];
|
||||||
|
confirmIdempotencyKey.value = "";
|
||||||
|
confirmError.value = "";
|
||||||
|
detailLoading.value = false;
|
||||||
|
scanLoading.confirm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmSelection(event) {
|
||||||
|
const row = requestedReturns.value[Number(event.detail.value)];
|
||||||
|
selectReturnRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectReturnRow(row) {
|
||||||
|
if (!row || row.status !== "REQUESTED" || confirmPending.value) return;
|
||||||
|
resetConfirmOperation();
|
||||||
|
const session = confirmSession;
|
||||||
|
const generation = ++detailGeneration;
|
||||||
|
confirmForm.returnId = row.returnId;
|
||||||
|
detailLoading.value = true;
|
||||||
|
getReturn(row.returnId)
|
||||||
|
.then((response) => {
|
||||||
|
if (session !== confirmSession || generation !== detailGeneration) return;
|
||||||
|
const detail = response.data;
|
||||||
|
if (!detail || detail.status !== "REQUESTED") {
|
||||||
|
confirmError.value = "该退回单已不是待确认状态";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||||
|
(item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId);
|
||||||
|
if (!confirmExpectedPalletIds.value.length) confirmError.value = "该退回单没有托盘明细";
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (session === confirmSession && generation === detailGeneration) {
|
||||||
|
confirmError.value = errorMessage(error, "退回单详情加载失败,请稍后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (session === confirmSession && generation === detailGeneration) {
|
||||||
|
detailLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmScan(qrToken) {
|
||||||
|
if (!confirmForm.returnId || !confirmExpectedPalletIds.value.length || confirmPending.value || scanLoading.confirm) return;
|
||||||
|
const session = confirmSession;
|
||||||
|
const generation = ++confirmScanGeneration;
|
||||||
|
scanLoading.confirm = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
scanReturnPallet(qrToken, { mode: "confirm", returnId: confirmForm.returnId })
|
||||||
|
.then((response) => {
|
||||||
|
if (session !== confirmSession || generation !== confirmScanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
|
||||||
|
confirmError.value = "该托盘不属于所选退回单";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已完成扫码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmScannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
});
|
||||||
|
confirmIdempotencyKey.value = "";
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||||
|
confirmError.value = errorMessage(error, "到仓托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (session === confirmSession && generation === confirmScanGeneration) {
|
||||||
|
scanLoading.confirm = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmScanInvalid(message) {
|
||||||
|
confirmError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfirmPalletScanned(palletId) {
|
||||||
|
return confirmScannedPallets.value.some((item) => item.palletId === palletId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdSet(left, right) {
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
const leftSet = new Set(left);
|
||||||
|
const rightSet = new Set(right);
|
||||||
|
return (
|
||||||
|
leftSet.size === left.length &&
|
||||||
|
rightSet.size === right.length &&
|
||||||
|
right.every((id) => leftSet.has(id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(returnId) {
|
||||||
|
if (!confirmIdempotencyKey.value) confirmIdempotencyKey.value = createIdempotencyKey();
|
||||||
|
return confirmIdempotencyKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReturnConfirm() {
|
||||||
|
if (confirmPending.value || detailLoading.value || scanLoading.confirm) return;
|
||||||
|
const returnId = confirmForm.returnId;
|
||||||
|
const expectedIds = [...confirmExpectedPalletIds.value];
|
||||||
|
const scannedIds = confirmScannedPallets.value.map((item) => item.palletId);
|
||||||
|
if (!returnId || !sameIdSet(expectedIds, scannedIds)) {
|
||||||
|
confirmError.value = "必须逐个扫描并完整匹配退回单全部托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmSubmitLoading.value = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
try {
|
||||||
|
const latest = (await getReturn(returnId)).data;
|
||||||
|
const latestIds = (Array.isArray(latest.items) ? latest.items : []).map((item) => item.palletId);
|
||||||
|
if (latest.status !== "REQUESTED" || !sameIdSet(latestIds, scannedIds)) {
|
||||||
|
confirmError.value = "退回单状态或托盘明细已变化,请重新选择并扫码";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await confirmReturn(returnId, {
|
||||||
|
scannedPalletIds: scannedIds,
|
||||||
|
idempotencyKey: ensureConfirmIdempotencyKey(returnId),
|
||||||
|
});
|
||||||
|
confirmIdempotencyKey.value = "";
|
||||||
|
proxy.$modal.msgSuccess("退回确认成功");
|
||||||
|
resetConfirmOperation();
|
||||||
|
loadReturnList();
|
||||||
|
} catch (error) {
|
||||||
|
confirmError.value = `${errorMessage(error, "退回确认失败")};直接重试将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
confirmSubmitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadReturnList();
|
||||||
|
loadWarehouseOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">发货出库</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
选择来源仓库和目标现场,扫码创建整托发货单
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
v-if="canDraft"
|
||||||
|
class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startNewDraft"
|
||||||
|
>
|
||||||
|
新建草稿
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<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">
|
||||||
|
{{ editing ? "编辑发货草稿" : "新建发货草稿" }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-gray-400">托盘数量 {{ scannedPallets.length }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">来源仓库</text>
|
||||||
|
<picker
|
||||||
|
:range="warehouseOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="warehouseIndex"
|
||||||
|
:disabled="optionLoading || detailLoading || operationPending"
|
||||||
|
@change="handleWarehouseChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedWarehouseName || "请选择来源仓库" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">目标客户现场</text>
|
||||||
|
<picker
|
||||||
|
:range="siteOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="siteIndex"
|
||||||
|
:disabled="optionLoading || detailLoading || operationPending"
|
||||||
|
@change="handleSiteChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedSiteName || "请选择目标现场" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">连续扫码</text>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="!form.sourceWarehouseId || !form.targetSiteId || detailLoading || operationPending"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleShipmentScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受来源仓库内有剩余包数的 FILLED_IN_WAREHOUSE 托盘。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text v-if="scanError" class="mb-3 block text-xs text-red-500">{{ scanError }}</text>
|
||||||
|
<text v-if="formError" class="mb-3 block text-xs text-red-500">{{ formError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallets.length" class="mb-4 space-y-2">
|
||||||
|
<view
|
||||||
|
v-for="pallet in scannedPallets"
|
||||||
|
:key="pallet.palletId"
|
||||||
|
class="rounded-xl bg-orange-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<view>
|
||||||
|
<text class="block text-sm font-medium text-gray-700">
|
||||||
|
{{ pallet.palletCode || `#${pallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="block text-xs text-gray-500">
|
||||||
|
{{ pallet.strainName || pallet.strainCode || "未提供菌种" }} · 剩余 {{ pallet.remainingPackages }} 包
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="text-xs text-red-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="removeScannedPallet(pallet.palletId)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-400">
|
||||||
|
批次:{{ pallet.batchNo || "—" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="flex gap-3">
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-orange-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canSaveDraft || operationPending || scanLoading || detailLoading"
|
||||||
|
@click="saveDraft"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "保存中" : editing ? "保存修改" : "保存草稿" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 rounded-xl bg-gray-100 py-3 text-sm text-gray-600 active:opacity-80"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="cancelEditor"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">发货单</text>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-500"
|
||||||
|
size="mini"
|
||||||
|
:disabled="loading || operationPending"
|
||||||
|
@click="loadShipmentList"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<text v-if="confirmError" class="mb-3 block text-xs text-red-500">{{ confirmError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!shipmentList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无发货单
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in shipmentList"
|
||||||
|
v-else
|
||||||
|
:key="row.shipmentId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ warehouseName(row.sourceWarehouseId) }} → {{ siteName(row.targetSiteId) }} · 托盘 {{ row.items?.length || 0 }} 个
|
||||||
|
</text>
|
||||||
|
<view v-if="row.status === 'DRAFT'" class="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
|
class="flex-1 rounded-lg bg-blue-50 py-2 text-xs text-blue-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="startEditDraft(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canConfirm"
|
||||||
|
class="flex-1 rounded-lg bg-green-50 py-2 text-xs text-green-600"
|
||||||
|
size="mini"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="confirmDraft(row)"
|
||||||
|
>
|
||||||
|
{{ confirmLoading ? "确认中" : "确认" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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 {
|
||||||
|
addShipment,
|
||||||
|
confirmShipment,
|
||||||
|
getShipment,
|
||||||
|
getShipmentSiteOptions,
|
||||||
|
getShipmentWarehouseOptions,
|
||||||
|
listShipment,
|
||||||
|
scanShipmentPallet,
|
||||||
|
updateShipment,
|
||||||
|
} from "@/api/business/shipment";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const shipmentList = ref([]);
|
||||||
|
const warehouseOptions = ref([]);
|
||||||
|
const siteOptions = ref([]);
|
||||||
|
const scannedPallets = ref([]);
|
||||||
|
const editorVisible = ref(false);
|
||||||
|
const editing = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const confirmLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const formError = ref("");
|
||||||
|
const scanError = ref("");
|
||||||
|
const confirmError = ref("");
|
||||||
|
const form = reactive({ shipmentId: null, sourceWarehouseId: null, targetSiteId: null });
|
||||||
|
const confirmState = reactive({ shipmentId: null, key: "" });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:shipment:list",
|
||||||
|
"business:shipment:query",
|
||||||
|
"business:shipment:add",
|
||||||
|
"business:shipment:edit",
|
||||||
|
"business:shipment:confirm",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
|
||||||
|
const canDraft = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
|
||||||
|
);
|
||||||
|
const canEdit = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:edit"]),
|
||||||
|
);
|
||||||
|
const canConfirm = computed(() => proxy.$auth.hasPermi("business:shipment:confirm"));
|
||||||
|
const canSaveDraft = computed(() => (editing.value ? canEdit.value : canDraft.value));
|
||||||
|
const operationPending = computed(
|
||||||
|
() =>
|
||||||
|
submitLoading.value ||
|
||||||
|
confirmLoading.value ||
|
||||||
|
detailLoading.value,
|
||||||
|
);
|
||||||
|
const warehouseIndex = computed(() => {
|
||||||
|
const index = warehouseOptions.value.findIndex(
|
||||||
|
(item) => item.id === form.sourceWarehouseId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const siteIndex = computed(() => {
|
||||||
|
const index = siteOptions.value.findIndex((item) => item.id === form.targetSiteId);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedWarehouseName = computed(
|
||||||
|
() => warehouseOptions.value.find((item) => item.id === form.sourceWarehouseId)?.name || "",
|
||||||
|
);
|
||||||
|
const selectedSiteName = computed(
|
||||||
|
() => siteOptions.value.find((item) => item.id === form.targetSiteId)?.name || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warehouseName(id) {
|
||||||
|
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteName(id) {
|
||||||
|
return siteOptions.value.find((item) => item.id === id)?.name || `现场 #${id || "-"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor() {
|
||||||
|
scanGeneration += 1;
|
||||||
|
editorVisible.value = false;
|
||||||
|
editing.value = false;
|
||||||
|
form.shipmentId = null;
|
||||||
|
form.sourceWarehouseId = null;
|
||||||
|
form.targetSiteId = null;
|
||||||
|
scannedPallets.value = [];
|
||||||
|
detailLoading.value = false;
|
||||||
|
scanLoading.value = false;
|
||||||
|
formError.value = "";
|
||||||
|
scanError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShipmentList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listShipment({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
shipmentList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
shipmentList.value = [];
|
||||||
|
listError.value = errorMessage(error, "发货单列表加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOptions() {
|
||||||
|
if (!proxy.$auth.hasPermi("business:shipment:query")) return;
|
||||||
|
optionLoading.value = true;
|
||||||
|
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()])
|
||||||
|
.then(([warehouseResponse, siteResponse]) => {
|
||||||
|
warehouseOptions.value = Array.isArray(warehouseResponse.data)
|
||||||
|
? warehouseResponse.data
|
||||||
|
: [];
|
||||||
|
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warehouseOptions.value = [];
|
||||||
|
siteOptions.value = [];
|
||||||
|
formError.value = errorMessage(error, "仓库或现场选项加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
optionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNewDraft() {
|
||||||
|
if (!canDraft.value || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
loadOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditDraft(row) {
|
||||||
|
if (!canEdit.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
editorVisible.value = true;
|
||||||
|
editing.value = true;
|
||||||
|
form.shipmentId = row.shipmentId;
|
||||||
|
loadOptions();
|
||||||
|
detailLoading.value = true;
|
||||||
|
getShipment(row.shipmentId)
|
||||||
|
.then((response) => {
|
||||||
|
const detail = response.data;
|
||||||
|
if (!detail || detail.status !== "DRAFT") {
|
||||||
|
formError.value = "该发货单已不是草稿状态,不能编辑";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.sourceWarehouseId = detail.sourceWarehouseId;
|
||||||
|
form.targetSiteId = detail.targetSiteId;
|
||||||
|
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map(
|
||||||
|
(item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
strainName: item.strainName || item.strainCode,
|
||||||
|
batchNo: item.batchNo,
|
||||||
|
remainingPackages: item.remainingPackages ?? item.packageQty ?? 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "发货单详情加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
detailLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditor() {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
resetEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScannedPallets() {
|
||||||
|
scannedPallets.value = [];
|
||||||
|
scanError.value = "";
|
||||||
|
scanGeneration += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWarehouseChange(event) {
|
||||||
|
const option = warehouseOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === form.sourceWarehouseId) return;
|
||||||
|
form.sourceWarehouseId = option.id;
|
||||||
|
clearScannedPallets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSiteChange(event) {
|
||||||
|
const option = siteOptions.value[Number(event.detail.value)];
|
||||||
|
if (!option || option.id === form.targetSiteId) return;
|
||||||
|
form.targetSiteId = option.id;
|
||||||
|
clearScannedPallets();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShipmentScan(qrToken) {
|
||||||
|
if (
|
||||||
|
!form.sourceWarehouseId ||
|
||||||
|
!form.targetSiteId ||
|
||||||
|
operationPending.value ||
|
||||||
|
scanLoading.value
|
||||||
|
) {
|
||||||
|
scanError.value = "请先选择来源仓库和目标现场";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanError.value = "";
|
||||||
|
scanShipmentPallet(qrToken, {
|
||||||
|
sourceWarehouseId: form.sourceWarehouseId,
|
||||||
|
targetSiteId: form.targetSiteId,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (pallet?.flowState !== "FILLED_IN_WAREHOUSE" || pallet.remainingPackages <= 0) {
|
||||||
|
scanError.value = "该托盘不是来源仓库内的有料托盘,不能发货";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msg("该托盘已在当前发货草稿中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
strainName: pallet.strainName || pallet.strainCode,
|
||||||
|
batchNo: pallet.batchNo,
|
||||||
|
remainingPackages: pallet.remainingPackages,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration) {
|
||||||
|
scanError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration) scanLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
scanError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScannedPallet(palletId) {
|
||||||
|
if (operationPending.value) return;
|
||||||
|
scannedPallets.value = scannedPallets.value.filter(
|
||||||
|
(item) => item.palletId !== palletId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDraft() {
|
||||||
|
if (!canSaveDraft.value || operationPending.value || scanLoading.value) return;
|
||||||
|
if (!form.sourceWarehouseId || !form.targetSiteId) {
|
||||||
|
formError.value = "请选择来源仓库和目标现场";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!scannedPallets.value.length) {
|
||||||
|
formError.value = "请至少扫描一个有料托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitLoading.value = true;
|
||||||
|
formError.value = "";
|
||||||
|
const payload = {
|
||||||
|
sourceWarehouseId: form.sourceWarehouseId,
|
||||||
|
targetSiteId: form.targetSiteId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId),
|
||||||
|
};
|
||||||
|
const submitRequest = editing.value
|
||||||
|
? updateShipment({ ...payload, shipmentId: form.shipmentId })
|
||||||
|
: addShipment(payload);
|
||||||
|
submitRequest
|
||||||
|
.then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "发货草稿保存成功");
|
||||||
|
resetEditor();
|
||||||
|
loadShipmentList();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
formError.value = errorMessage(error, "发货草稿保存失败,请根据提示修正后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submitLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(content) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "系统提示",
|
||||||
|
content,
|
||||||
|
cancelText: "取消",
|
||||||
|
confirmText: "确定",
|
||||||
|
success: (result) => resolve(Boolean(result.confirm)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(shipmentId) {
|
||||||
|
if (confirmState.shipmentId !== shipmentId) {
|
||||||
|
confirmState.shipmentId = shipmentId;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
if (!confirmState.key) confirmState.key = createIdempotencyKey();
|
||||||
|
return confirmState.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConfirmIdempotency() {
|
||||||
|
confirmState.shipmentId = null;
|
||||||
|
confirmState.key = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDraft(row) {
|
||||||
|
if (!canConfirm.value || row.status !== "DRAFT" || operationPending.value) return;
|
||||||
|
if (!(await askConfirm(`确认发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`))) return;
|
||||||
|
confirmLoading.value = true;
|
||||||
|
confirmError.value = "";
|
||||||
|
try {
|
||||||
|
await confirmShipment(row.shipmentId, {
|
||||||
|
idempotencyKey: ensureConfirmIdempotencyKey(row.shipmentId),
|
||||||
|
});
|
||||||
|
clearConfirmIdempotency();
|
||||||
|
proxy.$modal.msgSuccess("发货确认成功");
|
||||||
|
loadShipmentList();
|
||||||
|
} catch (error) {
|
||||||
|
confirmError.value = `${errorMessage(error, "发货确认失败")};再次确认将复用本次幂等键`;
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadShipmentList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
<template>
|
||||||
|
<view class="min-h-screen bg-gray-50">
|
||||||
|
<scroll-view scroll-y class="h-screen">
|
||||||
|
<view class="space-y-4 p-4 pb-24">
|
||||||
|
<view>
|
||||||
|
<text class="block text-xl font-bold text-gray-800">客户现场使用</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
扫描现场托盘,首次使用绑定产线并记录本次消耗包数
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<TestBatchBanner />
|
||||||
|
|
||||||
|
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<text class="text-sm text-gray-500">当前账号没有现场使用权限</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">扫描待使用托盘</text>
|
||||||
|
<button class="text-xs text-gray-500" size="mini" :disabled="operationPending" @click="resetUsageOperation">
|
||||||
|
重新扫描
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<PalletScanInput
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleUsageScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<text class="mt-2 block text-xs text-gray-400">
|
||||||
|
仅接受 AT_SITE_UNUSED 或 AT_SITE_PARTIAL 且仍有余量的托盘。
|
||||||
|
</text>
|
||||||
|
<text v-if="operationError" class="mt-2 block text-xs text-red-500">{{ operationError }}</text>
|
||||||
|
|
||||||
|
<view v-if="scannedPallet" class="mt-4 rounded-xl bg-purple-50 p-3">
|
||||||
|
<view class="flex items-center justify-between">
|
||||||
|
<text class="font-medium text-gray-700">
|
||||||
|
{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}
|
||||||
|
</text>
|
||||||
|
<text class="text-xs text-purple-600">{{ scannedPallet.flowState }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
|
||||||
|
</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 class="mb-3">
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">使用产线</text>
|
||||||
|
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
|
||||||
|
{{ selectedLineName }}(已由托盘绑定)
|
||||||
|
</view>
|
||||||
|
<picker
|
||||||
|
v-else
|
||||||
|
:range="lineOptions"
|
||||||
|
range-key="name"
|
||||||
|
:value="lineIndex"
|
||||||
|
:disabled="operationPending || optionLoading || !scannedPallet"
|
||||||
|
@change="handleLineChange"
|
||||||
|
>
|
||||||
|
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
|
||||||
|
{{ selectedLineName || "请选择现场产线" }}
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view>
|
||||||
|
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
|
||||||
|
<input
|
||||||
|
v-model="form.usedPackages"
|
||||||
|
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入正整数"
|
||||||
|
:disabled="operationPending || !scannedPallet"
|
||||||
|
@input="markPayloadChanged"
|
||||||
|
/>
|
||||||
|
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
|
||||||
|
不能超过当前剩余 {{ scannedPallet.remainingPackages }} 包,最终以服务端校验为准
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="submitError" class="mt-3 block text-xs text-red-500">{{ submitError }}</text>
|
||||||
|
<view v-if="usageResult" class="mt-3 rounded-xl bg-green-50 p-3">
|
||||||
|
<text class="block text-sm font-medium text-green-700">使用记录已提交</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-600">
|
||||||
|
使用后剩余 {{ usageResult.remainingPackages }} 包 · 状态 {{ usageResult.flowState }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
|
||||||
|
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !!usageResult"
|
||||||
|
@click="submitUsage"
|
||||||
|
>
|
||||||
|
{{ submitLoading ? "提交中" : "确认使用" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rounded-2xl bg-white p-4 shadow-sm">
|
||||||
|
<view class="mb-3 flex items-center justify-between">
|
||||||
|
<text class="font-bold text-gray-800">最近使用记录</text>
|
||||||
|
<button class="text-xs text-blue-500" size="mini" :disabled="loading" @click="loadUsageList">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<text v-if="listError" class="mb-3 block text-xs text-red-500">{{ listError }}</text>
|
||||||
|
<view v-if="loading" class="py-6 text-center text-xs text-gray-400">加载中...</view>
|
||||||
|
<view v-else-if="!usageList.length" class="py-6 text-center text-xs text-gray-400">
|
||||||
|
暂无使用记录
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="row in usageList"
|
||||||
|
v-else
|
||||||
|
:key="row.usageId"
|
||||||
|
class="mb-3 rounded-xl border border-gray-100 p-3 last:mb-0"
|
||||||
|
>
|
||||||
|
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-500">
|
||||||
|
托盘 #{{ row.palletId }} · 产线 #{{ row.lineId }} · 使用 {{ row.usedPackages }} 包
|
||||||
|
</text>
|
||||||
|
<text class="mt-1 block text-xs text-gray-400">
|
||||||
|
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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 {
|
||||||
|
addUsage,
|
||||||
|
getUsageLineOptions,
|
||||||
|
listUsage,
|
||||||
|
scanUsagePallet,
|
||||||
|
} from "@/api/business/usage";
|
||||||
|
import { createIdempotencyKey } from "@/utils/palletScan";
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
const usageList = ref([]);
|
||||||
|
const lineOptions = ref([]);
|
||||||
|
const scannedPallet = ref(null);
|
||||||
|
const usageResult = ref(null);
|
||||||
|
const lineLocked = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const optionLoading = ref(false);
|
||||||
|
const scanLoading = ref(false);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const listError = ref("");
|
||||||
|
const operationError = ref("");
|
||||||
|
const submitError = ref("");
|
||||||
|
const idempotencyKey = ref("");
|
||||||
|
const form = reactive({ qrToken: "", lineId: null, usedPackages: "" });
|
||||||
|
let listGeneration = 0;
|
||||||
|
let scanGeneration = 0;
|
||||||
|
let optionGeneration = 0;
|
||||||
|
let operationSession = 0;
|
||||||
|
|
||||||
|
const canPage = computed(() =>
|
||||||
|
proxy.$auth.hasPermiOr([
|
||||||
|
"business:usage:list",
|
||||||
|
"business:usage:query",
|
||||||
|
"business:usage:add",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
|
||||||
|
const canOperate = computed(() =>
|
||||||
|
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
|
||||||
|
);
|
||||||
|
const operationPending = computed(() => submitLoading.value);
|
||||||
|
const lineIndex = computed(() => {
|
||||||
|
const index = lineOptions.value.findIndex((item) => item.id === form.lineId);
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
});
|
||||||
|
const selectedLineName = computed(
|
||||||
|
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function errorMessage(error, fallback) {
|
||||||
|
return error?.response?.data?.msg || error?.message || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadUsageList() {
|
||||||
|
if (!canList.value) return;
|
||||||
|
const generation = ++listGeneration;
|
||||||
|
loading.value = true;
|
||||||
|
listError.value = "";
|
||||||
|
listUsage({ pageNum: 1, pageSize: 20 })
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
usageList.value = Array.isArray(response.rows) ? response.rows : [];
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation !== listGeneration) return;
|
||||||
|
usageList.value = [];
|
||||||
|
listError.value = errorMessage(error, "使用记录加载失败,请稍后重试");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === listGeneration) loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUsageOperation() {
|
||||||
|
operationSession += 1;
|
||||||
|
scanGeneration += 1;
|
||||||
|
optionGeneration += 1;
|
||||||
|
scannedPallet.value = null;
|
||||||
|
usageResult.value = null;
|
||||||
|
lineOptions.value = [];
|
||||||
|
lineLocked.value = false;
|
||||||
|
idempotencyKey.value = "";
|
||||||
|
form.qrToken = "";
|
||||||
|
form.lineId = null;
|
||||||
|
form.usedPackages = "";
|
||||||
|
operationError.value = "";
|
||||||
|
submitError.value = "";
|
||||||
|
scanLoading.value = false;
|
||||||
|
optionLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLineOptions(siteId, session, scanRequestGeneration) {
|
||||||
|
const generation = ++optionGeneration;
|
||||||
|
optionLoading.value = true;
|
||||||
|
getUsageLineOptions(siteId)
|
||||||
|
.then((response) => {
|
||||||
|
if (
|
||||||
|
generation !== optionGeneration ||
|
||||||
|
session !== operationSession ||
|
||||||
|
scanRequestGeneration !== scanGeneration
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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, "产线选项加载失败,请稍后重新扫码");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === optionGeneration && session === operationSession) {
|
||||||
|
optionLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUsageScan(qrToken) {
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||||
|
resetUsageOperation();
|
||||||
|
const session = operationSession;
|
||||||
|
const generation = ++scanGeneration;
|
||||||
|
scanLoading.value = true;
|
||||||
|
scanUsagePallet(qrToken)
|
||||||
|
.then((response) => {
|
||||||
|
if (generation !== scanGeneration || session !== operationSession) return;
|
||||||
|
const pallet = response.data;
|
||||||
|
if (
|
||||||
|
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
|
||||||
|
pallet.remainingPackages <= 0
|
||||||
|
) {
|
||||||
|
operationError.value = "该托盘当前不可记录使用";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scannedPallet.value = {
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
siteId: pallet.siteId,
|
||||||
|
currentLineId: pallet.currentLineId,
|
||||||
|
strainCode: pallet.strainCode,
|
||||||
|
strainName: pallet.strainName,
|
||||||
|
batchNo: pallet.batchNo,
|
||||||
|
remainingPackages: pallet.remainingPackages,
|
||||||
|
flowState: pallet.flowState,
|
||||||
|
};
|
||||||
|
form.qrToken = qrToken;
|
||||||
|
if (pallet.currentLineId != null) {
|
||||||
|
lineLocked.value = true;
|
||||||
|
form.lineId = pallet.currentLineId;
|
||||||
|
lineOptions.value = [
|
||||||
|
{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `产线 #${pallet.currentLineId}` },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
lineLocked.value = false;
|
||||||
|
form.lineId = null;
|
||||||
|
loadLineOptions(pallet.siteId, session, generation);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (generation === scanGeneration && session === operationSession) {
|
||||||
|
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (generation === scanGeneration && session === operationSession) {
|
||||||
|
scanLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
resetUsageOperation();
|
||||||
|
operationError.value = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msg("扫码过快,请勿重复扫描");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLineChange(event) {
|
||||||
|
if (lineLocked.value) return;
|
||||||
|
const option = lineOptions.value[Number(event.detail.value)];
|
||||||
|
form.lineId = option?.id || null;
|
||||||
|
markPayloadChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPayloadChanged() {
|
||||||
|
if (!submitLoading.value) idempotencyKey.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureIdempotencyKey() {
|
||||||
|
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
|
||||||
|
return idempotencyKey.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUsage() {
|
||||||
|
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return;
|
||||||
|
const usedPackages = Number(form.usedPackages);
|
||||||
|
if (!scannedPallet.value || !form.qrToken) {
|
||||||
|
submitError.value = "请先扫描待使用托盘";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.lineId) {
|
||||||
|
submitError.value = "请选择使用产线";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
|
||||||
|
submitError.value = "使用包数必须为正整数";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (usedPackages > scannedPallet.value.remainingPackages) {
|
||||||
|
submitError.value = "使用包数不能超过当前剩余包数";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadUsageList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -135,7 +135,7 @@ const captchaEnabled = ref(true);
|
|||||||
const register = ref(false);
|
const register = ref(false);
|
||||||
const loginForm = ref({
|
const loginForm = ref({
|
||||||
username: "admin",
|
username: "admin",
|
||||||
password: "admin123",
|
password: "",
|
||||||
code: "",
|
code: "",
|
||||||
uuid: "",
|
uuid: "",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<view class="flex h-full flex-col bg-gray-50 overflow-hidden">
|
<view class="flex h-full flex-col bg-gray-50 overflow-hidden">
|
||||||
<scroll-view scroll-y class="flex-1" :show-scrollbar="false">
|
<scroll-view scroll-y class="flex-1" :show-scrollbar="false">
|
||||||
<view class="p-4 pb-24 space-y-6">
|
<view class="p-4 pb-24 space-y-6">
|
||||||
|
<TestBatchModeSwitch />
|
||||||
|
|
||||||
<!-- Banner -->
|
<!-- Banner -->
|
||||||
<view
|
<view
|
||||||
class="relative h-40 w-full overflow-hidden rounded-2xl shadow-lg shadow-indigo-100"
|
class="relative h-40 w-full overflow-hidden rounded-2xl shadow-lg shadow-indigo-100"
|
||||||
@@ -82,6 +84,36 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- PDA Business Section -->
|
||||||
|
<view v-if="visibleBusinessEntries.length">
|
||||||
|
<view class="mb-4 flex items-center space-x-2">
|
||||||
|
<view class="h-4 w-1 rounded-full bg-emerald-500"></view>
|
||||||
|
<text class="text-base font-bold text-gray-800">现场业务</text>
|
||||||
|
</view>
|
||||||
|
<view class="rounded-2xl bg-white p-5 shadow-sm">
|
||||||
|
<view class="grid grid-cols-3 gap-4">
|
||||||
|
<view
|
||||||
|
v-for="entry in visibleBusinessEntries"
|
||||||
|
:key="entry.path"
|
||||||
|
class="flex flex-col items-center space-y-1.5 active:opacity-60"
|
||||||
|
@click="handleBusinessEntry(entry)"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="flex size-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600"
|
||||||
|
>
|
||||||
|
<view :class="entry.icon" class="text-xl"></view>
|
||||||
|
</view>
|
||||||
|
<text class="text-center text-[11px] font-medium text-gray-600">
|
||||||
|
{{ entry.title }}
|
||||||
|
</text>
|
||||||
|
<text class="text-center text-[10px] text-gray-400">
|
||||||
|
{{ entry.description }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- System Management Section -->
|
<!-- System Management Section -->
|
||||||
<view>
|
<view>
|
||||||
<view class="mb-4 flex items-center space-x-2">
|
<view class="mb-4 flex items-center space-x-2">
|
||||||
@@ -202,7 +234,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, getCurrentInstance } from "vue";
|
import { computed, ref, getCurrentInstance } from "vue";
|
||||||
|
import TestBatchModeSwitch from "@/components/business/TestBatchModeSwitch.vue";
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const current = ref(0);
|
const current = ref(0);
|
||||||
@@ -212,6 +245,76 @@ const data = ref([
|
|||||||
{ image: "/static/images/banner/banner02.jpg" },
|
{ image: "/static/images/banner/banner02.jpg" },
|
||||||
{ image: "/static/images/banner/banner03.jpg" },
|
{ image: "/static/images/banner/banner03.jpg" },
|
||||||
]);
|
]);
|
||||||
|
const businessEntries = [
|
||||||
|
{
|
||||||
|
path: "/pages/business/inbound/index",
|
||||||
|
title: "仓库入库",
|
||||||
|
description: "扫描托盘登记入库",
|
||||||
|
icon: "i-mdi-package-down",
|
||||||
|
color: "blue",
|
||||||
|
permissions: [
|
||||||
|
"business:inbound:list",
|
||||||
|
"business:inbound:query",
|
||||||
|
"business:inbound:add",
|
||||||
|
"business:inbound:edit",
|
||||||
|
"business:inbound:confirm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/filling/index",
|
||||||
|
title: "托盘装填",
|
||||||
|
description: "录入菌种和包数",
|
||||||
|
icon: "i-mdi-package-variant-closed-plus",
|
||||||
|
color: "green",
|
||||||
|
permissions: [
|
||||||
|
"business:filling:list",
|
||||||
|
"business:filling:query",
|
||||||
|
"business:filling:add",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/shipment/index",
|
||||||
|
title: "发货出库",
|
||||||
|
description: "扫码创建发货单",
|
||||||
|
icon: "i-mdi-truck-delivery",
|
||||||
|
color: "orange",
|
||||||
|
permissions: [
|
||||||
|
"business:shipment:list",
|
||||||
|
"business:shipment:query",
|
||||||
|
"business:shipment:add",
|
||||||
|
"business:shipment:edit",
|
||||||
|
"business:shipment:confirm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/usage/index",
|
||||||
|
title: "现场使用",
|
||||||
|
description: "绑定产线记录消耗",
|
||||||
|
icon: "i-mdi-factory",
|
||||||
|
color: "purple",
|
||||||
|
permissions: [
|
||||||
|
"business:usage:list",
|
||||||
|
"business:usage:query",
|
||||||
|
"business:usage:add",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/pages/business/return/index",
|
||||||
|
title: "退回仓库",
|
||||||
|
description: "申请和确认退回",
|
||||||
|
icon: "i-mdi-undo-variant",
|
||||||
|
color: "red",
|
||||||
|
permissions: [
|
||||||
|
"business:return:list",
|
||||||
|
"business:return:query",
|
||||||
|
"business:return:request",
|
||||||
|
"business:return:confirm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const visibleBusinessEntries = computed(() =>
|
||||||
|
businessEntries.filter((entry) => proxy.$auth.hasPermiOr(entry.permissions)),
|
||||||
|
);
|
||||||
|
|
||||||
function clickBannerItem(item) {
|
function clickBannerItem(item) {
|
||||||
console.info(item);
|
console.info(item);
|
||||||
@@ -229,6 +332,14 @@ function handleToUserList() {
|
|||||||
handleBuilding();
|
handleBuilding();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBusinessEntry(entry) {
|
||||||
|
if (!proxy.$auth.hasPermiOr(entry.permissions)) {
|
||||||
|
proxy.$modal.msgError("当前账号没有该业务权限");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
proxy.$tab.navigateTo(entry.path);
|
||||||
|
}
|
||||||
|
|
||||||
function handleBuilding() {
|
function handleBuilding() {
|
||||||
proxy.$modal.msg("模块建设中~");
|
proxy.$modal.msg("模块建设中~");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import { useUserStore } from "./modules/user";
|
import { useUserStore } from "./modules/user";
|
||||||
import { useConfigStore } from "./modules/config";
|
import { useConfigStore } from "./modules/config";
|
||||||
|
import { useTestBatchStore } from "./modules/testBatch";
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
|
||||||
export default pinia;
|
export default pinia;
|
||||||
|
|
||||||
export { useUserStore, useConfigStore };
|
export { useUserStore, useConfigStore, useTestBatchStore };
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { listAvailableTestBatches } from "@/api/system/testBatch";
|
||||||
|
import storage from "@/utils/storage";
|
||||||
|
import constant from "@/utils/constant";
|
||||||
|
|
||||||
|
export const useTestBatchStore = defineStore("testBatch", () => {
|
||||||
|
const options = ref([]);
|
||||||
|
const currentBatchId = ref(String(storage.get(constant.testBatchId) || ""));
|
||||||
|
const currentBatchName = ref(storage.get(constant.testBatchName) || "");
|
||||||
|
const scopeVersion = ref(0);
|
||||||
|
const isTestMode = computed(() => Boolean(currentBatchId.value));
|
||||||
|
|
||||||
|
function leaveTestMode() {
|
||||||
|
currentBatchId.value = "";
|
||||||
|
currentBatchName.value = "";
|
||||||
|
storage.remove(constant.testBatchId);
|
||||||
|
storage.remove(constant.testBatchName);
|
||||||
|
scopeVersion.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOptions() {
|
||||||
|
try {
|
||||||
|
const response = await listAvailableTestBatches();
|
||||||
|
const available = (Array.isArray(response.data) ? response.data : []).filter(
|
||||||
|
(item) => item && item.status === "ACTIVE" && item.batchId != null,
|
||||||
|
);
|
||||||
|
options.value = available;
|
||||||
|
|
||||||
|
const current = available.find(
|
||||||
|
(item) => String(item.batchId) === String(currentBatchId.value),
|
||||||
|
);
|
||||||
|
if (current) {
|
||||||
|
currentBatchId.value = String(current.batchId);
|
||||||
|
currentBatchName.value = current.batchName || "";
|
||||||
|
storage.set(constant.testBatchId, currentBatchId.value);
|
||||||
|
storage.set(constant.testBatchName, currentBatchName.value);
|
||||||
|
} else if (currentBatchId.value) {
|
||||||
|
// 本地缓存不能代替后端授权;选项接口未返回当前批次时立即回到正式模式。
|
||||||
|
leaveTestMode();
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
} catch (error) {
|
||||||
|
options.value = [];
|
||||||
|
leaveTestMode();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBatch(batch) {
|
||||||
|
const allowed = options.value.find(
|
||||||
|
(item) =>
|
||||||
|
String(item.batchId) === String(batch?.batchId) && item.status === "ACTIVE",
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new Error("所选测试批次当前不可用");
|
||||||
|
}
|
||||||
|
currentBatchId.value = String(allowed.batchId);
|
||||||
|
currentBatchName.value = allowed.batchName || "";
|
||||||
|
storage.set(constant.testBatchId, currentBatchId.value);
|
||||||
|
storage.set(constant.testBatchName, currentBatchName.value);
|
||||||
|
scopeVersion.value += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
options.value = [];
|
||||||
|
leaveTestMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
currentBatchId,
|
||||||
|
currentBatchName,
|
||||||
|
scopeVersion,
|
||||||
|
isTestMode,
|
||||||
|
refreshOptions,
|
||||||
|
selectBatch,
|
||||||
|
leaveTestMode,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useTestBatchStore;
|
||||||
@@ -15,6 +15,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
const id = ref(storage.get(constant.id));
|
const id = ref(storage.get(constant.id));
|
||||||
const name = ref(storage.get(constant.name));
|
const name = ref(storage.get(constant.name));
|
||||||
const avatar = ref(storage.get(constant.avatar));
|
const avatar = ref(storage.get(constant.avatar));
|
||||||
|
const admin = ref(storage.get(constant.admin) === true || storage.get(constant.admin) === "true");
|
||||||
const roles = ref(storage.get(constant.roles));
|
const roles = ref(storage.get(constant.roles));
|
||||||
const permissions = ref(storage.get(constant.permissions));
|
const permissions = ref(storage.get(constant.permissions));
|
||||||
|
|
||||||
@@ -33,6 +34,10 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
avatar.value = val;
|
avatar.value = val;
|
||||||
storage.set(constant.avatar, val);
|
storage.set(constant.avatar, val);
|
||||||
};
|
};
|
||||||
|
const SET_ADMIN = (val) => {
|
||||||
|
admin.value = val === true;
|
||||||
|
storage.set(constant.admin, admin.value);
|
||||||
|
};
|
||||||
const SET_ROLES = (val) => {
|
const SET_ROLES = (val) => {
|
||||||
roles.value = val;
|
roles.value = val;
|
||||||
storage.set(constant.roles, val);
|
storage.set(constant.roles, val);
|
||||||
@@ -81,6 +86,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
} else {
|
} else {
|
||||||
SET_ROLES(["ROLE_DEFAULT"]);
|
SET_ROLES(["ROLE_DEFAULT"]);
|
||||||
}
|
}
|
||||||
|
SET_ADMIN(user.admin === true);
|
||||||
SET_ID(userid);
|
SET_ID(userid);
|
||||||
SET_NAME(username);
|
SET_NAME(username);
|
||||||
SET_AVATAR(avatar);
|
SET_AVATAR(avatar);
|
||||||
@@ -115,6 +121,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
avatar,
|
avatar,
|
||||||
|
admin,
|
||||||
roles,
|
roles,
|
||||||
permissions,
|
permissions,
|
||||||
SET_AVATAR,
|
SET_AVATAR,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ const constant = {
|
|||||||
avatar: "user_avatar",
|
avatar: "user_avatar",
|
||||||
id: "user_id",
|
id: "user_id",
|
||||||
name: "user_name",
|
name: "user_name",
|
||||||
|
admin: "user_admin",
|
||||||
roles: "user_roles",
|
roles: "user_roles",
|
||||||
permissions: "user_permissions",
|
permissions: "user_permissions",
|
||||||
|
testBatchId: "test_batch_id",
|
||||||
|
testBatchName: "test_batch_name",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default constant;
|
export default constant;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export function normalizeScanValue(value) {
|
||||||
|
const normalizedValue = String(value ?? "").trim();
|
||||||
|
if (!normalizedValue) {
|
||||||
|
throw new Error("二维码不能为空");
|
||||||
|
}
|
||||||
|
if (normalizedValue.length > 128) {
|
||||||
|
throw new Error("二维码长度不能超过 128 个字符");
|
||||||
|
}
|
||||||
|
return normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDuplicateScan(
|
||||||
|
value,
|
||||||
|
lastValue,
|
||||||
|
scannedAt,
|
||||||
|
lastScannedAt,
|
||||||
|
duplicateWindowMs,
|
||||||
|
) {
|
||||||
|
const elapsed = scannedAt - lastScannedAt;
|
||||||
|
return (
|
||||||
|
value === lastValue &&
|
||||||
|
elapsed >= 0 &&
|
||||||
|
elapsed <= duplicateWindowMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIdempotencyKey(
|
||||||
|
cryptoProvider =
|
||||||
|
typeof globalThis === "undefined" ? undefined : globalThis.crypto,
|
||||||
|
) {
|
||||||
|
if (typeof cryptoProvider?.randomUUID === "function") {
|
||||||
|
return cryptoProvider.randomUUID();
|
||||||
|
}
|
||||||
|
if (typeof cryptoProvider?.getRandomValues !== "function") {
|
||||||
|
throw new Error("当前环境不支持安全随机数,请使用 HTTPS 或 localhost 访问系统");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = cryptoProvider.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (byte) =>
|
||||||
|
byte.toString(16).padStart(2, "0"),
|
||||||
|
).join("");
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { getToken } from "@/utils/auth";
|
import { getToken } from "@/utils/auth";
|
||||||
import errorCode from "@/utils/errorCode";
|
import errorCode from "@/utils/errorCode";
|
||||||
|
import constant from "@/utils/constant";
|
||||||
|
import storage from "@/utils/storage";
|
||||||
import { useUserStore } from "@/store/modules/user";
|
import { useUserStore } from "@/store/modules/user";
|
||||||
import { toast, showConfirm, tansParams } from "@/utils/common";
|
import { toast, showConfirm, tansParams } from "@/utils/common";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +16,37 @@ import {
|
|||||||
|
|
||||||
let timeout = 10000;
|
let timeout = 10000;
|
||||||
const baseUrl = config.baseUrl;
|
const baseUrl = config.baseUrl;
|
||||||
|
let testBatchRecoveryStarted = false;
|
||||||
|
|
||||||
|
function getResponseBody(payload) {
|
||||||
|
return payload?.data && typeof payload.data === "object" ? payload.data : payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponseData(payload) {
|
||||||
|
const body = getResponseBody(payload);
|
||||||
|
return body.data && typeof body.data === "object" ? body.data : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestBatchUnavailable(payload) {
|
||||||
|
return getResponseData(payload)?.reason === "TEST_BATCH_UNAVAILABLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverUnavailableTestBatch(payload) {
|
||||||
|
if (testBatchRecoveryStarted) return;
|
||||||
|
testBatchRecoveryStarted = true;
|
||||||
|
const body = getResponseBody(payload);
|
||||||
|
const message = body.msg || "当前测试批次不可用,已切回正式数据模式";
|
||||||
|
storage.remove(constant.testBatchId);
|
||||||
|
storage.remove(constant.testBatchName);
|
||||||
|
if (typeof uni.$emit === "function") {
|
||||||
|
uni.$emit("test-batch-unavailable", message);
|
||||||
|
}
|
||||||
|
uni.showToast({ title: message, icon: "none", duration: 3500 });
|
||||||
|
uni.reLaunch({ url: "/pages/work/index" });
|
||||||
|
setTimeout(() => {
|
||||||
|
testBatchRecoveryStarted = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
const request = async (config) => {
|
const request = async (config) => {
|
||||||
// 是否需要设置 token
|
// 是否需要设置 token
|
||||||
@@ -23,6 +56,10 @@ const request = async (config) => {
|
|||||||
if (getToken() && !isToken) {
|
if (getToken() && !isToken) {
|
||||||
config.header["Authorization"] = "Bearer " + getToken();
|
config.header["Authorization"] = "Bearer " + getToken();
|
||||||
}
|
}
|
||||||
|
const testBatchId = storage.get(constant.testBatchId);
|
||||||
|
if (getToken() && testBatchId) {
|
||||||
|
config.header["X-Test-Batch-Id"] = String(testBatchId);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
config = await encryptTransportRequest(config);
|
config = await encryptTransportRequest(config);
|
||||||
@@ -55,8 +92,17 @@ const request = async (config) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = res.data.code || 200;
|
if (isTestBatchUnavailable(res)) {
|
||||||
const msg = errorCode[code] || res.data.msg || errorCode["default"];
|
recoverUnavailableTestBatch(res);
|
||||||
|
const error = new Error(getResponseBody(res).msg || "当前测试批次不可用");
|
||||||
|
error.response = res;
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = getResponseBody(res);
|
||||||
|
const code = body.code || 200;
|
||||||
|
const msg = errorCode[code] || body.msg || errorCode["default"];
|
||||||
if (code === 401) {
|
if (code === 401) {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
"登录状态已过期,您可以继续留在该页面,或者重新登录?",
|
"登录状态已过期,您可以继续留在该页面,或者重新登录?",
|
||||||
@@ -81,7 +127,7 @@ const request = async (config) => {
|
|||||||
error.response = res;
|
error.response = res;
|
||||||
reject(error);
|
reject(error);
|
||||||
} else {
|
} else {
|
||||||
resolve(res.data);
|
resolve(body);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
@@ -100,10 +146,15 @@ const request = async (config) => {
|
|||||||
return request(config);
|
return request(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTestBatchUnavailable(error?.response)) {
|
||||||
|
recoverUnavailableTestBatch(error.response);
|
||||||
|
}
|
||||||
|
|
||||||
const response = error.response;
|
const response = error.response;
|
||||||
const responseStatus = response?.status ?? response?.statusCode;
|
const responseStatus = response?.status ?? response?.statusCode;
|
||||||
const responseCode = response?.data?.code;
|
const responseBody = getResponseBody(response);
|
||||||
const responseMsg = response?.data?.msg;
|
const responseCode = responseBody?.code;
|
||||||
|
const responseMsg = responseBody?.msg;
|
||||||
if (responseMsg) {
|
if (responseMsg) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: responseMsg,
|
title: responseMsg,
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ let storageNodeKeys = [
|
|||||||
constant.avatar,
|
constant.avatar,
|
||||||
constant.id,
|
constant.id,
|
||||||
constant.name,
|
constant.name,
|
||||||
|
constant.admin,
|
||||||
constant.roles,
|
constant.roles,
|
||||||
constant.permissions,
|
constant.permissions,
|
||||||
|
constant.testBatchId,
|
||||||
|
constant.testBatchName,
|
||||||
];
|
];
|
||||||
|
|
||||||
const storage = {
|
const storage = {
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
const appRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||||
|
|
||||||
|
const apiContracts = {
|
||||||
|
"src/api/business/inbound.js": [
|
||||||
|
["listInbound", "'/business/inbound/list'", "get"],
|
||||||
|
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
|
||||||
|
["scanInboundPallet", "'/business/inbound/scan/' + qrToken", "get"],
|
||||||
|
["getInbound", "'/business/inbound/' + inboundId", "get"],
|
||||||
|
["addInbound", "'/business/inbound'", "post"],
|
||||||
|
["updateInbound", "'/business/inbound'", "put"],
|
||||||
|
["confirmInbound", "'/business/inbound/' + inboundId + '/confirm'", "put"],
|
||||||
|
],
|
||||||
|
"src/api/business/filling.js": [
|
||||||
|
["listFilling", "'/business/filling/list'", "get"],
|
||||||
|
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
|
||||||
|
["scanFillingPallet", "'/business/filling/scan/' + qrToken", "get"],
|
||||||
|
["getFilling", "'/business/filling/' + fillingId", "get"],
|
||||||
|
["addFilling", "'/business/filling'", "post"],
|
||||||
|
],
|
||||||
|
"src/api/business/shipment.js": [
|
||||||
|
["listShipment", "'/business/shipment/list'", "get"],
|
||||||
|
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
|
||||||
|
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
|
||||||
|
["scanShipmentPallet", "'/business/shipment/scan/' + qrToken", "get"],
|
||||||
|
["getShipment", "'/business/shipment/' + shipmentId", "get"],
|
||||||
|
["addShipment", "'/business/shipment'", "post"],
|
||||||
|
["updateShipment", "'/business/shipment'", "put"],
|
||||||
|
["confirmShipment", "'/business/shipment/' + shipmentId + '/confirm'", "put"],
|
||||||
|
],
|
||||||
|
"src/api/business/usage.js": [
|
||||||
|
["listUsage", "'/business/usage/list'", "get"],
|
||||||
|
["getUsageLineOptions", "'/business/usage/line-options'", "get"],
|
||||||
|
["scanUsagePallet", "'/business/usage/scan/' + 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"],
|
||||||
|
["requestReturn", "'/business/return/request'", "post"],
|
||||||
|
["getReturn", "'/business/return/' + returnId", "get"],
|
||||||
|
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceForFunction(source, functionName) {
|
||||||
|
const match = source.match(
|
||||||
|
new RegExp(`export function ${escapeRegExp(functionName)}\\s*\\(`),
|
||||||
|
);
|
||||||
|
const start = match?.index ?? -1;
|
||||||
|
assert.notEqual(start, -1, `${functionName} 导出不存在`);
|
||||||
|
const end = source.indexOf("\n}", start);
|
||||||
|
return source.slice(start, end === -1 ? source.length : end + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("五个 PDA API 模块使用现有业务路径和 HTTP 方法", async (t) => {
|
||||||
|
for (const [relativePath, contracts] of Object.entries(apiContracts)) {
|
||||||
|
await t.test(relativePath, async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
||||||
|
for (const [functionName, url, method] of contracts) {
|
||||||
|
const functionSource = sourceForFunction(source, functionName);
|
||||||
|
const normalizedFunctionSource = functionSource.replaceAll("'", '"');
|
||||||
|
const normalizedUrl = url.replaceAll("'", '"');
|
||||||
|
assert.match(
|
||||||
|
normalizedFunctionSource,
|
||||||
|
new RegExp(`url:\\s*${escapeRegExp(normalizedUrl)}`),
|
||||||
|
);
|
||||||
|
assert.match(functionSource, new RegExp(`method:\\s*["']${method}["']`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("扫码值只去除首尾空白并拒绝空值", async () => {
|
||||||
|
const { normalizeScanValue } = await importSource("src/utils/palletScan.js");
|
||||||
|
assert.equal(normalizeScanValue(" QR-001\n"), "QR-001");
|
||||||
|
assert.throws(() => normalizeScanValue(" \n\t "), /二维码不能为空/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("同一扫码值只在短时窗口内被识别为重复", async () => {
|
||||||
|
const { isDuplicateScan } = await importSource("src/utils/palletScan.js");
|
||||||
|
assert.equal(isDuplicateScan("QR-001", "QR-001", 1200, 1000, 500), true);
|
||||||
|
assert.equal(isDuplicateScan("QR-001", "QR-002", 1200, 1000, 500), false);
|
||||||
|
assert.equal(isDuplicateScan("QR-001", "QR-001", 1801, 1000, 500), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("幂等键优先使用 randomUUID,回退时使用 UUID v4 安全随机字节", async () => {
|
||||||
|
const { createIdempotencyKey } = await importSource("src/utils/palletScan.js");
|
||||||
|
const expected = "12345678-1234-4123-8123-123456789abc";
|
||||||
|
assert.equal(createIdempotencyKey({ randomUUID: () => expected }), expected);
|
||||||
|
assert.equal(
|
||||||
|
createIdempotencyKey({
|
||||||
|
getRandomValues(bytes) {
|
||||||
|
bytes.fill(0);
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"00000000-0000-4000-8000-000000000000",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/components/business/PalletScanInput.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /@confirm=["']handleSubmit["']/);
|
||||||
|
assert.match(source, /normalizeScanValue/);
|
||||||
|
assert.match(source, /isDuplicateScan/);
|
||||||
|
assert.match(source, /emit\(["']scan["']/);
|
||||||
|
assert.match(source, /emit\(["']invalid["']/);
|
||||||
|
assert.match(source, /emit\(["']duplicate["']/);
|
||||||
|
assert.match(source, /loading/);
|
||||||
|
assert.doesNotMatch(source, /uni\.scanCode|camera|摄像头/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
|
||||||
|
const pages = JSON.parse(
|
||||||
|
await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const pagePaths = new Set(pages.pages.map((page) => page.path));
|
||||||
|
for (const pagePath of [
|
||||||
|
"pages/business/inbound/index",
|
||||||
|
"pages/business/filling/index",
|
||||||
|
"pages/business/shipment/index",
|
||||||
|
"pages/business/usage/index",
|
||||||
|
"pages/business/return/index",
|
||||||
|
]) {
|
||||||
|
assert.equal(pagePaths.has(pagePath), true, `${pagePath} 路由缺失`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workSource = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/work/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(workSource, /hasPermiOr/);
|
||||||
|
assert.match(workSource, /business:inbound/);
|
||||||
|
assert.match(workSource, /business:filling/);
|
||||||
|
assert.match(workSource, /business:shipment/);
|
||||||
|
assert.match(workSource, /business:usage/);
|
||||||
|
assert.match(workSource, /business:return/);
|
||||||
|
assert.match(workSource, /pages\/business\/inbound\/index/);
|
||||||
|
assert.match(workSource, /pages\/business\/filling\/index/);
|
||||||
|
assert.match(workSource, /pages\/business\/shipment\/index/);
|
||||||
|
assert.match(workSource, /pages\/business\/usage\/index/);
|
||||||
|
assert.match(workSource, /pages\/business\/return\/index/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 测试模式附加批次请求头并在五个业务页持续提示", async () => {
|
||||||
|
const requestSource = await readFile(
|
||||||
|
path.join(appRoot, "src/utils/request.js"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(requestSource, /X-Test-Batch-Id/);
|
||||||
|
assert.match(requestSource, /TEST_BATCH_UNAVAILABLE/);
|
||||||
|
assert.match(requestSource, /uni\.\$emit\(["']test-batch-unavailable["']/);
|
||||||
|
assert.match(requestSource, /reLaunch\(\{\s*url:\s*["']\/pages\/work\/index["']/);
|
||||||
|
|
||||||
|
for (const page of ["inbound", "filling", "shipment", "usage", "return"]) {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /TestBatchBanner/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 不再暴露入库和发货草稿物理删除", async () => {
|
||||||
|
for (const file of ["src/api/business/inbound.js", "src/api/business/shipment.js"]) {
|
||||||
|
const source = await readFile(path.join(appRoot, file), "utf8");
|
||||||
|
assert.doesNotMatch(source, /delInbound|delShipment|method:\s*["']delete["']/);
|
||||||
|
}
|
||||||
|
for (const page of ["inbound", "shipment"]) {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, `src/pages/business/${page}/index.vue`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(source, /deleteDraft|deleteLoading|business:(?:inbound|shipment):remove/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("登录页只预填账号,不把管理员密码写入源码", async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, "src/pages/login.vue"), "utf8");
|
||||||
|
assert.match(source, /username:\s*["']admin["']/);
|
||||||
|
assert.match(source, /password:\s*["']["']/);
|
||||||
|
assert.doesNotMatch(source, /password:\s*["'][^"']+["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("PDA 后端地址支持 VITE_APP_BASE_API 并保留本地默认值", async () => {
|
||||||
|
const source = await readFile(path.join(appRoot, "src/config.js"), "utf8");
|
||||||
|
assert.match(source, /import\.meta\.env\.VITE_APP_BASE_API/);
|
||||||
|
assert.match(source, /VITE_APP_BASE_API\s*\|\|\s*["']http:\/\/localhost:9099["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("入库页面遵循 UNSTOCKED 扫码、草稿和幂等确认契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/inbound/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanInboundPallet/);
|
||||||
|
assert.match(source, /UNSTOCKED/);
|
||||||
|
assert.match(source, /warehouseId/);
|
||||||
|
assert.match(source, /palletIds/);
|
||||||
|
assert.match(source, /getInbound\(/);
|
||||||
|
assert.match(source, /updateInbound/);
|
||||||
|
assert.match(source, /confirmInbound/);
|
||||||
|
assert.match(source, /idempotencyKey/);
|
||||||
|
assert.match(source, /business:inbound:(?:query|add|edit|confirm)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("发货页面遵循来源仓库、目标现场和整单确认契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/shipment/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanShipmentPallet/);
|
||||||
|
assert.match(source, /FILLED_IN_WAREHOUSE/);
|
||||||
|
assert.match(source, /remainingPackages/);
|
||||||
|
assert.match(source, /sourceWarehouseId/);
|
||||||
|
assert.match(source, /targetSiteId/);
|
||||||
|
assert.match(source, /palletIds/);
|
||||||
|
assert.match(source, /getShipment\(/);
|
||||||
|
assert.match(source, /updateShipment/);
|
||||||
|
assert.match(source, /confirmShipment/);
|
||||||
|
assert.match(source, /idempotencyKey/);
|
||||||
|
assert.match(source, /business:shipment:(?:query|add|edit|confirm)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("装填页面遵循空托盘状态和 FillingCreateModel 字段契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/filling/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanFillingPallet/);
|
||||||
|
assert.match(source, /EMPTY_IN_WAREHOUSE/);
|
||||||
|
for (const field of [
|
||||||
|
"qrToken",
|
||||||
|
"warehouseId",
|
||||||
|
"strainId",
|
||||||
|
"batchNo",
|
||||||
|
"packageSpec",
|
||||||
|
"initialPackages",
|
||||||
|
"idempotencyKey",
|
||||||
|
]) {
|
||||||
|
assert.match(source, new RegExp(field));
|
||||||
|
}
|
||||||
|
assert.match(source, /addFilling/);
|
||||||
|
assert.match(source, /business:filling:(?:query|add)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("现场使用页面遵循产线绑定、余量和使用结果契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/usage/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanUsagePallet/);
|
||||||
|
assert.match(source, /AT_SITE_UNUSED/);
|
||||||
|
assert.match(source, /AT_SITE_PARTIAL/);
|
||||||
|
assert.match(source, /currentLineId/);
|
||||||
|
assert.match(source, /getUsageLineOptions/);
|
||||||
|
assert.match(source, /usedPackages/);
|
||||||
|
assert.match(source, /remainingPackages/);
|
||||||
|
assert.match(source, /flowState/);
|
||||||
|
assert.match(source, /addUsage/);
|
||||||
|
assert.match(source, /idempotencyKey/);
|
||||||
|
assert.match(source, /business:usage:(?:query|add)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("退回页面实现申请与确认两阶段的集合和幂等契约", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(appRoot, "src/pages/business/return/index.vue"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(source, /PalletScanInput/);
|
||||||
|
assert.match(source, /scanReturnPallet/);
|
||||||
|
assert.match(source, /mode:\s*["']request["']/);
|
||||||
|
assert.match(source, /mode:\s*["']confirm["']/);
|
||||||
|
assert.match(source, /sourceSiteId/);
|
||||||
|
assert.match(source, /targetWarehouseId/);
|
||||||
|
assert.match(source, /palletIds/);
|
||||||
|
assert.match(source, /scannedPalletIds/);
|
||||||
|
assert.match(source, /requestReturn/);
|
||||||
|
assert.match(source, /getReturn\(/);
|
||||||
|
assert.match(source, /confirmReturn/);
|
||||||
|
assert.match(source, /sameIdSet/);
|
||||||
|
assert.match(source, /REQUESTED/);
|
||||||
|
assert.match(source, /requestIdempotencyKey/);
|
||||||
|
assert.match(source, /confirmIdempotencyKey/);
|
||||||
|
assert.match(source, /business:return:(?:query|request|confirm)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSource(relativePath) {
|
||||||
|
const source = await readFile(path.join(appRoot, relativePath), "utf8");
|
||||||
|
return import(`data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user