487 lines
16 KiB
Vue
487 lines
16 KiB
Vue
<template>
|
||
<view class="min-h-screen bg-gray-50">
|
||
<scroll-view scroll-y class="h-screen">
|
||
<view class="space-y-4 p-4 pb-24">
|
||
<view 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>
|