Files
KCGL/inventory-web/src/views/transaction/borrow.vue
yueli b313fefdfa fix(web): 扫码页与备选库位带单据ID,按有效可用量限流
配合后端预占回加:扫码/借库/审批页面调用 /outbound/scan 与 /outbound/alternatives
时带上本单 id 与 biz_type,拿到的 available_quantity 即为「实时可用量 + 本单
预占」,本单锁定的行不会再因实时可用量为 0 而被拦住或从列表消失。

后端已在响应边界完成归一化,故前端模板与校验逻辑(:max、库存列展示、超量
警告、草稿存取)一律不动,只多传参数 —— 避免把同一语义散落到多个读写点。

- api/outbound.ts:getStockByBarcode / getStockAlternatives 加 requestId 与
  bizType 参数,导出 ScanBizType,ScanResult 补 reserved_quantity 等字段
- api/transaction.ts:同名的历史副本同步签名(当前无调用方,加注释指向唯一来源)
- views/outbound/create.vue、views/transaction/borrow.vue:各 3 处调用点带上
  单据 id;refreshStockFromDraft 改为显式透传 id,不依赖 computed 已解析
- views/borrow/approval/index.vue:审批页也带上本单 id,否则待审批单
  (预占已生效)锁定的行会因实时可用量为 0 而从备选列表消失

借库侧 request_id 是 BorrowApproval.id,靠 biz_type='borrow' 与出库单区分 ——
两张表 ID 空间独立,不可省。
2026-09-11 10:34:23 +08:00

1306 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container mobile-optimized">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header">
<div class="title-box">
<span>借库作业 (领用人签字)</span>
<el-tag v-if="cartItems.length > 0" type="warning" size="small" effect="dark">
已选 {{ cartItems.length }}
</el-tag>
</div>
</div>
</template>
<!-- 审批单选择下拉框 -->
<div class="approval-request-select">
<el-select
v-model="selectedApprovalId"
placeholder="请选择已通过审批的借库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleApprovalChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.borrower_name || '未知借库人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过status=1的审批单</p>
</div>
<!-- 计划借用清单预览与扫码出库页的计划出库清单对齐 -->
<div v-if="selectedApproval" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">计划借用清单</span>
<el-tag type="success" size="small">{{ plannedItems.length }} </el-tag>
</div>
<el-table :data="plannedItems" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<!-- 计划数量前置与出库页一致原先排在表格最右列多时易被裁掉 -->
<el-table-column label="计划数量" width="100" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold; font-size: 15px;">
{{ row.quantity ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<!-- 库位 + 备选库位现场进不去时可改扫其它批次 -->
<el-table-column label="库位" width="170">
<template #default="{ row }">
<el-popover
v-if="row.base_id"
placement="right"
:width="340"
trigger="click"
@show="loadAlternatives(row)"
>
<!-- 整个库位格子都是点击热区工人不必精准点图标 -->
<template #reference>
<span class="loc-cell" title="点击查看该物料的所有可选库位">
<span class="loc-text">{{ row.warehouse_location || '-' }}</span>
<el-icon class="alt-icon"><LocationInformation /></el-icon>
</span>
</template>
<div v-loading="altLoading" class="alt-panel">
<div class="alt-title">
该物料的可选库位
<span v-if="altTotal > 0" class="alt-total">合计可用 {{ altTotal }}</span>
</div>
<div v-if="!altLoading && altItems.length === 0" class="alt-empty">
暂无其它可用库位
</div>
<div v-for="a in altItems" :key="a.source_table + '_' + a.stock_id" class="alt-row">
<el-tag :type="a.is_locked ? 'success' : 'info'" size="small" effect="plain">
{{ a.is_locked ? '推荐' : '备选' }}
</el-tag>
<span class="alt-loc">{{ a.warehouse_location || '(无库位)' }}</span>
<span class="alt-qty">可用 {{ a.available_quantity }}</span>
</div>
<div v-if="altItems.length" class="alt-hint">
现场取不到推荐库位时可直接扫备选库位的条码借用
</div>
</div>
</el-popover>
<span v-else>{{ row.warehouse_location || '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section">
<!-- 扫码进度条 -->
<div v-if="selectedApproval && scanTotalQty > 0" class="scan-progress">
<div class="progress-info">
<span>扫码进度</span>
<el-tag type="success" size="small">{{ scanScannedTypes }}/{{ scanTotalTypes }} </el-tag>
<el-tag type="primary" size="small">{{ scanScannedQty }}/{{ scanTotalQty }} </el-tag>
<el-button
v-if="unscannedCount > 0"
type="warning" plain size="small" style="margin-left: auto;"
@click="showUnscannedDialog = true"
>
未扫清单 ({{ unscannedCount }})
</el-button>
</div>
<el-progress
:percentage="scanTotalQty > 0 ? Math.min(100, Math.round(scanScannedQty / scanTotalQty * 100)) : 0"
:stroke-width="8"
:color="scanScannedQty >= scanTotalQty ? '#67C23A' : '#409EFF'"
/>
</div>
<template v-if="!selectedApprovalId">
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">请先选择审批单</span>
</div>
<div class="input-box">
<el-input v-model="barcodeInput" placeholder="请先选择审批单" disabled size="large">
<template #prefix><el-icon><Scissor /></el-icon></template>
<template #append><el-button disabled>添加</el-button></template>
</el-input>
</div>
</template>
<template v-else>
<div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启全屏扫码</span>
</div>
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
<span class="text">无扫码权限</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
:disabled="!userStore.hasPermission('op_borrow:operation')"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('op_borrow:operation')">添加</el-button>
</template>
</el-input>
</div>
</template>
</div>
<div class="cart-section">
<div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column v-if="hasColumnPermission('name')" prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('available_quantity')" label="可用库存" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('out_quantity')" label="借用数" width="130" align="center">
<template #default="{row}">
<el-input-number
v-model="row.out_quantity"
:min="1"
:max="parseFloat(row.available_quantity)"
size="small"
style="width: 100px"
:disabled="!userStore.hasPermission('op_borrow:operation')"
/>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('op_borrow:operation')" label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无物品,请扫码借出" :image-size="80" />
</div>
<div v-if="cartItems.length > 0" class="form-section">
<el-divider content-position="left">借用登记信息</el-divider>
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
<el-row :gutter="15">
<el-col :span="24">
<el-form-item label="领用人/借用人" prop="borrower_name">
<el-input v-model="form.borrower_name" placeholder="请输入姓名" size="large" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="预计归还日期" prop="expected_return_time">
<el-date-picker
v-model="form.expected_return_time"
type="date"
placeholder="请选择日期"
style="width: 100%"
size="large"
value-format="YYYY-MM-DD"
:disabled="isIndefinite"
:disabled-date="disabledDate"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-checkbox v-model="isIndefinite" @change="handleIndefiniteChange">
无限期/长期借用不设归还期限
</el-checkbox>
</el-col>
</el-row>
<el-form-item label="备注说明" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="用途说明..." />
</el-form-item>
<el-form-item label="领用人签名确认" required>
<div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('op_borrow:operation')">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
</div>
<div v-else class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>点击此处进行全屏签名</span>
</div>
</div>
<div v-else class="signature-box" style="background-color: #f5f5f5; cursor: not-allowed;">
<div class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>无签名权限</span>
</div>
</div>
</el-form-item>
<div class="bottom-actions">
<el-button v-if="userStore.hasPermission('op_borrow:operation')" @click="clearAll" icon="Refresh">清空</el-button>
<el-button v-if="userStore.hasPermission('op_borrow:operation')" type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认借出
</el-button>
</div>
</el-form>
</div>
</el-card>
<div v-if="showCamera" class="fullscreen-scanner-overlay">
<div class="scanner-header">
<el-button circle icon="Close" @click="showCamera = false" class="close-btn" />
<span class="scanner-title">扫码模式</span>
<div class="scanner-placeholder"></div>
</div>
<div class="scanner-body">
<QrScanner @decode="onScanSuccess" />
</div>
<div class="scanner-footer">
<p>请将条码/二维码放入镜头范围</p>
<p v-if="cartItems.length > 0" class="current-count">已添加: {{ cartItems.length }} </p>
</div>
</div>
<!-- 未扫清单弹窗 -->
<el-dialog v-model="showUnscannedDialog" title="未扫清单" width="600px" destroy-on-close>
<el-alert
v-if="unscannedList.length > 0"
:title="`以下 ${unscannedList.length} 种物料还未扫满,请补扫:`"
type="warning" :closable="false" show-icon style="margin-bottom: 12px;"
/>
<el-table :data="unscannedList" border size="small" max-height="400">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="name" label="物料名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column label="计划" width="70" align="center">
<template #default="{ row }">{{ row.planQty }}</template>
</el-table-column>
<el-table-column label="已扫" width="70" align="center">
<template #default="{ row }">
<span style="color: #67C23A;">{{ row.scannedQty }}</span>
</template>
</el-table-column>
<el-table-column label="待扫" width="70" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.remaining }}</span>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="showUnscannedDialog = false">关闭</el-button>
<el-button type="primary" @click="showUnscannedDialog = false; showCamera = true">去扫码</el-button>
</template>
</el-dialog>
<el-dialog
v-model="showSignatureDialog"
fullscreen
destroy-on-close
:show-close="false"
class="fullscreen-signature-dialog"
@opened="initCanvas"
>
<div class="signature-wrapper">
<div class="signature-canvas-container" ref="canvasContainerRef">
<canvas
ref="nativeCanvasRef"
class="native-canvas"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="startDrawing"
@touchmove="draw"
@touchend="stopDrawing"
></canvas>
<div class="canvas-tip">请在此区域横屏书写</div>
</div>
<div class="signature-sidebar">
<div class="sidebar-title">电子签名</div>
<div class="sidebar-actions">
<el-button type="warning" @click="clearCanvas">重写</el-button>
<el-button @click="handleSignCancel">取消</el-button>
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, getStockAlternatives, getScanDraft, saveScanDraft, clearScanDraft } from '@/api/outbound'
import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
name: null,
spec_model: null,
sku: null,
borrower_name: null,
available_quantity: null,
out_quantity: null,
}
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
// --- 状态定义 ---
const barcodeInput = ref('')
const cartItems = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// ★ 审批单选择
const approvalRequests = ref<any[]>([])
const selectedApprovalId = ref<number | null>(null)
const requestsLoading = ref(false)
const selectedApproval = computed(() =>
selectedApprovalId.value
? approvalRequests.value.find(r => r.id === selectedApprovalId.value) ?? null
: null
)
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
// ============================================================================
// ★ 备选库位(与扫码出库页同款)
//
// 申请单已把货预占在某库位,工人现场可能进不去,需要改扫同物料的其它批次
// (后端执行端按 base_id 校验身份、允许换批次)。此处复用出库模块的
// /alternatives 端点:按 available_quantity > 0 过滤,只给真正能拿的库位。
// 点击图标才发请求,避免展开单据时打出一片并发查询。
// ============================================================================
const altLoading = ref(false)
const altItems = ref<any[]>([])
const altTotal = ref(0)
const loadAlternatives = async (row: any) => {
if (!row?.base_id) return
altLoading.value = true
altItems.value = []
altTotal.value = 0
try {
// ★ 带上本单 id本单锁定的批次实时可用量为 0不回加的话会从列表里消失
const res: any = await getStockAlternatives(
row.base_id,
{ stock_id: row.stock_id, source_table: row.source_table },
selectedApprovalId.value,
'borrow'
)
altItems.value = res?.data?.items || []
altTotal.value = res?.data?.total_available || 0
} catch (e) {
ElMessage.error('查询备选库位失败')
} finally {
altLoading.value = false
}
}
// ★ 扫码进度:总需扫数(计划数量总和)/ 已扫数(购物车数量总和)
const scanTotalQty = computed(() =>
plannedItems.value.reduce((sum, it) => sum + (Number(it.quantity) || 0), 0)
)
const scanScannedQty = computed(() =>
cartItems.value.reduce((sum, it) => sum + (Number(it.out_quantity) || 0), 0)
)
// 已扫物品种类数 / 计划物品种类数
const scanScannedTypes = computed(() => cartItems.value.length)
const scanTotalTypes = computed(() => plannedItems.value.length)
// ★ 未扫清单:计划中还没扫满的物料
const unscannedList = computed(() => {
return plannedItems.value.map(plan => {
const planQty = Number(plan.quantity) || 0
// 已扫数量(按名称+规格匹配)
const scannedQty = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || ci.standard || '').trim()
return ciName === (plan.name || '').trim() &&
ciSpec === (plan.spec_model || '').trim()
})
.reduce((sum, ci) => sum + (Number(ci.out_quantity) || 0), 0)
return {
name: plan.name || '',
spec: plan.spec_model || '',
planQty,
scannedQty,
remaining: Math.max(0, planQty - scannedQty)
}
}).filter(item => item.remaining > 0) // 只保留未扫满的
})
const unscannedCount = computed(() => unscannedList.value.length)
const showUnscannedDialog = ref(false)
// ★ 加载已通过审批的借库申请单列表
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getBorrowApprovalList({ status: 1, page: 1, limit: 100 })
approvalRequests.value = res.data?.items || []
} catch (e) {
console.error('加载借库审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
// ★ 切换审批单时:带出申请时填写的归还日期/长期借用,并清空购物车和签名,防止跨单据污染
const handleApprovalChange = async (val: number | null) => {
// ★ 关键selectedApproval 已被 computed 更新为新单,
// 故用 activeApprovalId 拿到「刚离开的那张单」来保存
const leavingId = activeApprovalId.value
await saveDraftNow(leavingId)
if (!val) {
selectedApprovalId.value = null
}
// 从申请单明细快照中带出"预计归还日期"(申请时通过 items_json 存储,无需改数据库)
const req = approvalRequests.value.find(r => r.id === val)
const firstItem = req?.items?.[0]
if (firstItem?.is_indefinite) {
isIndefinite.value = true
form.expected_return_time = ''
} else if (firstItem?.expected_return_time) {
isIndefinite.value = false
form.expected_return_time = firstItem.expected_return_time
} else {
// 旧申请单/未填时间:保持手动选择
isIndefinite.value = false
form.expected_return_time = ''
}
// ★ 自动关联:把该借库申请单的“申请原因(remark)”自动带入执行备注,库管无需重复填写(可手动改)
form.remark = (req && req.remark) || ''
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
activeApprovalId.value = val // ★ 切换到新单
// ★ 载入目标单据的草稿(之前扫到一半则自动恢复)
if (val) await restoreDraft(val)
}
// ============================================================================
// ★ 扫码草稿(与扫码出库页同款)
//
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来接着扫。
// 库存在申请审批通过时已预占,暂停期间不会被他人抢走,故草稿只记「扫到哪了」。
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
// ============================================================================
let draftTimer: ReturnType<typeof setTimeout> | null = null
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
// 原先只存 4 个字段,恢复后名称/库存为空、借用数显示 NaN。
const draftPayload = () => cartItems.value.map((it: any) => ({
id: it.id,
source_table: it.source_table,
sku: it.sku || '',
name: it.name || '',
spec_model: it.spec_model || '',
warehouse_location: it.warehouse_location || '',
barcode: it.barcode || '',
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
out_quantity: Number(it.out_quantity) || 0,
}))
// 监听中的单据ID
//
// ★ 为什么需要它selectedApproval 是由 selectedApprovalId 派生的 computed
// 切换时它**立刻**变成新单。若在 handleApprovalChange 里读 selectedApproval
// 拿到的已经是新单 —— 会把 A 的内容存到 B 名下。用一个独立的 ref 记录
// 「当前界面上显示的是哪张单」,保存时以此为准。
const activeApprovalId = ref<number | null>(null)
const saveDraftNow = async (requestId?: number | null) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const rid = requestId ?? activeApprovalId.value
if (!rid) return
const items = draftPayload()
// ★ 空清单不清除草稿:切换单据瞬间购物车必然为空,若此时清除会误删
// 目标单的旧草稿(这正是"切回来就没了"的直接原因)。清理由提交成功触发。
if (items.length === 0) return
try {
await saveScanDraft('borrow', rid, items, selectedApproval.value?.request_no)
} catch (e) {
console.warn('保存草稿失败', e) // 不阻断作业
}
}
const scheduleSaveDraft = () => {
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraftNow(), 800)
}
const restoreDraft = async (requestId: number) => {
try {
const res: any = await getScanDraft('borrow', requestId)
const items = res?.data?.items || []
if (!items.length) return
// 归一化:老草稿用 quantity新草稿用 out_quantity缺失字段补默认值
// 避免购物车出现 NaNNaN 提交时会被兜底成 1造成静默的数量错误
cartItems.value = items.map((it: any) => ({
...it,
id: it.id ?? it.stock_id,
out_quantity: Number(it.out_quantity ?? it.quantity) || 0,
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
name: it.name || '',
spec_model: it.spec_model || '',
}))
ElMessage.success(`已恢复上次的扫码进度(${items.length} 项)`)
// ★ 刷新库存:草稿里的 available_quantity 是扫描那一刻的快照,
// 期间别人出库/借出会消耗可用量,需重新拉取实时值。
await refreshStockFromDraft(requestId)
} catch (e) {
console.warn('恢复草稿失败', e)
}
}
/**
* 用实时库存刷新购物车行的「库存」列(与扫码出库页同款)。
* 复用 /alternatives 端点;失败不阻断,沿用草稿快照。
*
* ★ 传入本单 id 后,返回的 available_quantity 是**有效可用量**
* (实时值 + 本单预占)。借库申请提交时已冻结可用数,不回加的话
* 被本单占满的行会显示 0下面「已借数量 > 最新可用量」也会误报。
*/
const refreshStockFromDraft = async (requestId?: number | null) => {
const plan = selectedApproval.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return
// ★ 显式透传单据 id而非依赖 selectedApproval 已解析)
const rid = requestId ?? selectedApproval.value?.id ?? selectedApprovalId.value
try {
const results = await Promise.all(
(baseIds as number[]).map(bid =>
getStockAlternatives(bid, undefined, rid, 'borrow').catch(() => null)
)
)
const latestByKey = new Map<string, number>()
for (const r of results) {
for (const a of ((r as any)?.data?.items || [])) {
latestByKey.set(`${a.source_table}_${a.stock_id}`, Number(a.available_quantity) || 0)
}
}
if (!latestByKey.size) return
let changed = 0
for (const row of cartItems.value) {
const key = `${row.source_table}_${row.id}`
if (!latestByKey.has(key)) continue
const latest = latestByKey.get(key)!
if (latest !== Number(row.available_quantity)) {
row.available_quantity = latest
changed++
}
}
const over = cartItems.value.filter(
(r: any) => Number(r.out_quantity) > Number(r.available_quantity)
)
if (over.length > 0) {
ElMessage.warning(
`${over.length} 项物料的实际库存已少于你扫的数量` +
`(如 ${over[0].name || over[0].sku}),请核对后再提交`
)
} else if (changed > 0) {
ElMessage.info(`已刷新 ${changed} 项物料的实时库存`)
}
} catch (e) {
console.warn('刷新库存失败', e)
}
}
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在审批计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
// 购物车中已扫的同名同规格物料累计数量
const alreadyScanned = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || '').trim()
return ciName === normalizedName && ciSpec === normalizedSpec
})
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
if (alreadyScanned + scannedQty > planQty) {
return `${normalizedName} × ${normalizedSpec}】超出审批数量(审批: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty}`
}
return null
}
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
const signatureFile = ref<File | null>(null)
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLElement | null>(null)
const ctx = ref<CanvasRenderingContext2D | null>(null)
const isDrawing = ref(false)
const lastX = ref(0)
const lastY = ref(0)
const form = reactive({
borrower_name: '',
expected_return_time: '',
remark: ''
})
const rules = computed(() => ({
borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }],
expected_return_time: [
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
]
}))
const isIndefinite = ref(false)
const handleIndefiniteChange = (val: boolean) => {
if (val) form.expected_return_time = ''
}
const disabledDate = (time: Date) => {
return time.getTime() < Date.now() - 8.64e7
}
// --- 核心扫码逻辑 ---
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
if (loading.value) return
barcodeInput.value = trimCode
handleManualInput()
}
const handleManualInput = async () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
const code = barcodeInput.value.trim()
if (!code) return
// ★ 必须先选择审批单
if (!selectedApproval.value) {
ElMessage.warning('请先选择要执行借库的审批申请单')
return
}
try {
loading.value = true
// 查重:条码或 SKU 匹配已扫记录
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
// ★ 追加前仍需校验审批数量上限
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
// ★ 重复扫码:弹窗确认是否 +1防止手滑重复扫
try {
await ElMessageBox.confirm(
`${item.name} × ${item.spec_model}】已在清单中(当前已扫 ${item.out_quantity} 个)。\n\n确认再 +1 吗?`,
'重复扫码确认',
{ confirmButtonText: '确认 +1', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
barcodeInput.value = ''
return // 用户取消,不加
}
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
if (navigator.vibrate) navigator.vibrate(50)
} else {
ElMessage.warning(`库存不足 (余: ${maxQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
}
barcodeInput.value = ''
return
}
// 查库
// ★ 带上本单 id借库申请提交时已冻结可用数available_quantity 被扣减),
// 不回加的话,被本单占满的行会显示 0工人根本扫不进去。
// biz_type='borrow' 不可省 —— 出库单与借库单是两张独立表、ID 空间独立。
const res = await getStockByBarcode(code, selectedApprovalId.value, 'borrow')
if (res.data) {
const item = res.data
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.warning(`库存不足或已借出 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
barcodeInput.value = ''
return
}
// ★ 扫码加入前强校验:不在清单内或超量直接阻断
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
cartItems.value.push({
...item,
out_quantity: 1,
price: 0
})
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
}
} catch (error: any) {
if (error.response && error.response.status === 404) {
ElMessage.error(`未找到条码: ${code}`)
} else {
ElMessage.error('查询出错')
}
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
} finally {
loading.value = false
if (!showCamera.value) {
nextTick(() => { barcodeRef.value?.focus() })
}
}
}
const removeFromCart = (index: number) => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
cartItems.value.splice(index, 1)
}
const clearAll = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
form.borrower_name = ''
form.remark = ''
form.expected_return_time = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
isIndefinite.value = false
// 仅清空购物车,保留审批单选择
})
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
// 记下单据ID清空 cartItems 后可能被重置,草稿清除需要它
const submittedRequestId = selectedApprovalId.value
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
const requiredMsg = isIndefinite.value ? '请填写完整的必填项(姓名)' : '请填写完整的必填项(姓名、归还日期)'
ElMessage.error(requiredMsg)
return
}
if (!signatureFile.value) {
ElMessage.error('请领用人进行电子签名')
return
}
try {
loading.value = true
// 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
// ★ 规范 Payload只包含后端需要的最小字段
const itemsPayload = cartItems.value.map(item => {
let safeQty = Number(item.out_quantity)
if (isNaN(safeQty) || safeQty <= 0) safeQty = 1
return {
id: item.id || 0,
source_table: item.source_table || '',
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
out_quantity: safeQty
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交')
return
}
await dispatchBorrow({
approval_id: selectedApprovalId.value,
items: itemsPayload,
borrower_name: form.borrower_name,
signature_path: signatureUrl,
remark: form.remark,
expected_return_time: isIndefinite.value ? null : form.expected_return_time
})
ElMessage.success('借用成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('borrow', submittedRequestId).catch(() => {})
}
cartItems.value = []
form.borrower_name = ''
form.expected_return_time = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
showCamera.value = false
isIndefinite.value = false
} catch (error: any) {
console.error(error)
ElMessage.error(error.response?.data?.msg || '提交失败')
} finally {
loading.value = false
}
})
}
// --- 签名逻辑 ---
const openSignatureDialog = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无签名权限')
return
}
showSignatureDialog.value = true
}
const initCanvas = async () => {
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
canvas.width = container.clientWidth
canvas.height = container.clientHeight
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 4
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000000'
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
}
}
}
const getPos = (e: MouseEvent | TouchEvent) => {
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
const rect = nativeCanvasRef.value.getBoundingClientRect()
const clientX = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX
const clientY = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
return { x: clientX - rect.left, y: clientY - rect.top }
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
isDrawing.value = true
const { x, y } = getPos(e)
lastX.value = x; lastY.value = y
ctx.value?.beginPath()
ctx.value?.moveTo(x, y)
}
const draw = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const { x, y } = getPos(e)
ctx.value.lineTo(x, y)
ctx.value.stroke()
}
const stopDrawing = () => { isDrawing.value = false }
const clearCanvas = () => {
if (!ctx.value || !nativeCanvasRef.value) return
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
}
const handleSignConfirm = () => {
nativeCanvasRef.value?.toBlob((blob) => {
if (blob) {
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
signatureFile.value = file
signaturePreviewUrl.value = URL.createObjectURL(file)
showSignatureDialog.value = false
}
}, 'image/png')
}
const handleSignCancel = () => { showSignatureDialog.value = false }
// --- 初始化 ---
import { onMounted } from 'vue'
onMounted(() => {
loadApprovalRequests()
})
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
if (draftTimer) clearTimeout(draftTimer)
window.removeEventListener('beforeunload', handleBeforeUnload)
// 其余清理(定时检测、事件监听)见文件末尾的统一 onUnmounted
})
// ★ 离开页面前立即存草稿(防抖中未落盘的内容会丢)
onBeforeRouteLeave(async (_to, _from, next) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const hasItems = cartItems.value.length > 0 && selectedApproval.value?.id
if (hasItems) {
await saveDraftNow()
try {
await ElMessageBox.confirm(
`当前单据【${selectedApproval.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗`,
'离开确认',
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
)
} catch (e) {
return next(false)
}
}
next()
})
// 刷新/关闭浏览器时尽力保存sendBeacon 不阻塞卸载)
const handleBeforeUnload = () => {
const req = selectedApproval.value
if (!req?.id || cartItems.value.length === 0) return
try {
const token = localStorage.getItem('token') || ''
const blob = new Blob([JSON.stringify({
biz_type: 'borrow', request_id: req.id, request_no: req.request_no,
items: draftPayload(),
})], { type: 'application/json' })
navigator.sendBeacon?.(`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob)
} catch (e) { /* 尽力而为 */ }
}
// ============================================================================
// ★ 单据失效检测(与扫码出库页同款)
//
// 场景:库管正在扫码,这张单被撤回/驳回了。若不检测,库管会一直扫到
// 提交时才发现单据已作废 —— 白扫一场。
//
// 检测方式:重新拉一次「已通过(status=1)」列表,看当前单据是否还在其中。
// 触发页面重新可见时PDA 熄屏唤醒)+ 每 60 秒(仅购物车非空时)。
// ============================================================================
const checkRequestStillValid = async () => {
const rid = activeApprovalId.value
if (!rid || cartItems.value.length === 0) return // 没在作业,不打扰
try {
const res: any = await getBorrowApprovalList({ status: 1, page: 1, limit: 100 })
const stillValid = (res.data?.items || []).some((r: any) => r.id === rid)
if (stillValid) return
const no = selectedApproval.value?.request_no || rid
cartItems.value = []
selectedApprovalId.value = null
activeApprovalId.value = null
ElMessageBox.alert(
`申请单【${no}】已被撤回或已执行,不能再继续借出。\n\n` +
`本次已扫的内容已清空(草稿也已随之清除)。\n` +
`如需借出,请重新提交申请。`,
'⚠️ 申请单已失效',
{ type: 'warning', confirmButtonText: '知道了' }
).catch(() => {})
} catch (e) {
// 网络异常时不打断作业 —— 后端在提交时仍会做最终校验
console.warn('单据状态检测失败', e)
}
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') checkRequestStillValid()
}
let validCheckTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
document.addEventListener('visibilitychange', onVisibilityChange)
validCheckTimer = setInterval(checkRequestStillValid, 60000)
})
onUnmounted(() => {
if (validCheckTimer) clearInterval(validCheckTimer)
document.removeEventListener('visibilitychange', onVisibilityChange)
})
</script>
<style scoped>
.app-container.mobile-optimized {
padding: 10px; max-width: 600px; margin: 0 auto;
}
/* 头部 */
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
/* 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; }
/* 计划清单:与扫码出库页的「计划出库清单」保持一致的视觉 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
/* ★ 备选库位:库位格子整体可点击(与扫码出库页同款) */
.loc-cell {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.loc-cell:hover { background-color: #ecf5ff; }
.loc-cell .loc-text { color: #409EFF; }
.alt-icon {
font-size: 18px;
color: #409EFF;
vertical-align: middle;
flex-shrink: 0;
}
.loc-cell:hover .alt-icon { color: #66b1ff; }
.alt-panel { font-size: 13px; }
.alt-title {
font-weight: bold;
color: #303133;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.alt-total { font-weight: normal; color: #67C23A; font-size: 12px; }
.alt-empty { color: #909399; font-size: 12px; padding: 6px 0; }
.alt-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-bottom: 1px dashed #ebeef5;
}
.alt-row:last-of-type { border-bottom: none; }
.alt-loc { flex: 1; color: #409EFF; font-weight: 500; }
.alt-qty { color: #606266; font-size: 12px; }
.alt-hint {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #ebeef5;
color: #E6A23C;
font-size: 12px;
line-height: 1.5;
}
/* 扫码区 */
.scan-section { margin-bottom: 20px; }
.scan-progress {
background: #f5f7fa;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 12px;
}
.progress-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
}
.camera-placeholder {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer;
transition: all 0.3s;
}
.camera-placeholder:active { background: #e6e8eb; }
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 全屏扫码层 */
.fullscreen-scanner-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: #000; z-index: 9999; display: flex; flex-direction: column;
}
.scanner-header {
height: 60px; display: flex; align-items: center; justify-content: space-between;
padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff;
position: absolute; top: 0; width: 100%; z-index: 10;
}
.scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
.scanner-body {
flex: 1; width: 100%; position: relative; display: flex;
align-items: center; justify-content: center;
}
:deep(.qr-scanner-container) { width: 100% !important; height: 100% !important; border-radius: 0 !important; }
.scanner-footer {
position: absolute; bottom: 0; width: 100%; padding: 20px;
background: rgba(0,0,0,0.6); color: #fff; text-align: center; z-index: 10;
}
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
/* 表单与购物车 */
.cart-section { margin-bottom: 20px; }
.form-section { background: #fff; }
.signature-box {
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
}
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; }
/* 全屏签名弹窗 */
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
.signature-wrapper { display: flex; width: 100%; height: 100%; }
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.canvas-tip {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
}
.signature-sidebar {
width: 120px; background: #333; color: #fff;
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
}
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
@media screen and (max-width: 768px) {
.signature-wrapper { flex-direction: column; }
.signature-canvas-container { flex: 1; }
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
.sidebar-title { display: none; }
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>