现象:申请单里同一个物料有 2 个,扫第 2 个时报 「超出计划数量(计划: 1,已扫: 1,本次: 1)」。 根因:计划侧与已扫侧用了**不同的聚合方式**。 已扫侧 .filter().reduce() → 把【所有】匹配行加总 计划侧 .find() → 只取【第一条】匹配行 序列号物料在申请单里是「一物一行、每行 quantity=1」(因为每个序列号对应 一条独立库存行),于是 planQty 只拿到 1,而 alreadyScanned 是全部之和, 1 + 1 > 1 直接判超发。普通数量制物料计划单只有一行,两边一致,所以一直 没暴露 —— 只有序列号物料会踩中。 同一缺陷还有第二处:unscannedList 逐行比较 planQty 与「累计已扫」, 导致两行各自都被同一笔扫码"满足",扫 1 个就把整个物料判为扫完、未扫清单漏报。 修复: - validateAgainstPlan:计划侧改为 .filter().reduce() 汇总所有匹配行 - unscannedList:先按「名称+规格」合并计划行,再与同口径汇总的已扫数比较 用申请单 410(Opt1025 两行各 1 个)的真实数据模拟验证: 修复前 扫第2个 → 超出计划数量(计划:1,已扫:1,本次:1) ← 与现场报错逐字一致 修复后 扫第2个 → 通过 修复后 扫第3个 → 仍被拦(计划:2,已扫:2,本次:1),超发保护未失效
1494 lines
56 KiB
Vue
1494 lines
56 KiB
Vue
<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 class="header-price" v-if="totalAmount > 0">
|
||
¥{{ totalAmount.toFixed(2) }}
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ★ 出库模式:仅支持按单出库(已移除直接出库) -->
|
||
<div class="mode-switch-bar">
|
||
<el-tag type="primary" size="large" style="font-size: 14px;">按单出库</el-tag>
|
||
<span class="mode-hint">请先选择已审批通过的申请单,再扫码出库</span>
|
||
</div>
|
||
|
||
<!-- ★ 按单出库:审批单选择 -->
|
||
<div class="approval-request-select">
|
||
<el-select
|
||
v-model="selectedRequestId"
|
||
placeholder="请选择已审批通过的出库申请单"
|
||
filterable
|
||
clearable
|
||
style="width: 100%"
|
||
:loading="requestsLoading"
|
||
@change="handleRequestChange"
|
||
>
|
||
<el-option
|
||
v-for="req in approvalRequests"
|
||
:key="req.id"
|
||
:value="req.id"
|
||
:label="req.request_no"
|
||
>
|
||
<span>{{ req.request_no }}</span>
|
||
<!-- ★ 草稿进度徽标:一眼看出哪张单之前扫到一半,不必逐个点开试 -->
|
||
<el-tag v-if="draftProgress[req.id]" type="warning" size="small" effect="dark" style="margin-left:6px">
|
||
已扫 {{ draftProgress[req.id] }}
|
||
</el-tag>
|
||
<el-divider direction="vertical" />
|
||
<span>{{ req.applicant_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="selectedRequest" class="planned-items-section">
|
||
<div class="planned-header">
|
||
<span class="planned-title">计划出库清单</span>
|
||
<el-tag type="success" size="small">{{ selectedRequest.items?.length || 0 }} 种</el-tag>
|
||
</div>
|
||
<el-table :data="selectedRequest.items || []" 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="selectedRequest && 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="!selectedRequestId">
|
||
<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('outbound_create: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('outbound_create:operation')"
|
||
>
|
||
<template #prefix>
|
||
<el-icon><Scissor /></el-icon>
|
||
</template>
|
||
<template #append>
|
||
<el-button @click="handleManualInput" :disabled="!userStore.hasPermission('outbound_create: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 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 prop="sku" label="SKU" width="120" show-overflow-tooltip />
|
||
|
||
<el-table-column label="库存" width="70" align="center">
|
||
<template #default="{row}">
|
||
<el-tag :type="row.available_quantity > 0 ? 'success' : 'danger'" size="small">
|
||
{{ parseFloat(row.available_quantity) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column 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('outbound_create:operation')"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="操作" width="60" align="center" fixed="right">
|
||
<template #default="{$index}">
|
||
<el-button v-if="userStore.hasPermission('outbound_create:operation')" 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" :md="8">
|
||
<el-form-item label="出库类型" prop="outbound_type">
|
||
<el-select v-model="form.outbound_type" placeholder="请选择类型" style="width: 100%" clearable>
|
||
<el-option label="销售出库" value="SALES" />
|
||
<el-option label="内部领用" value="USE" />
|
||
<el-option label="生产出库" value="PRODUCTION" />
|
||
<el-option label="盘亏出库" value="LOSS" />
|
||
<el-option label="维修出库" value="REPAIR" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<el-col :span="24" :md="8">
|
||
<el-form-item label="领用人/客户" prop="consumer_name">
|
||
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<el-col :span="24" :md="8">
|
||
<el-form-item label="经办人 (库管)" prop="operator_name">
|
||
<el-select
|
||
v-model="form.operator_name"
|
||
filterable
|
||
allow-create
|
||
default-first-option
|
||
placeholder="选择或输入"
|
||
style="width: 100%"
|
||
>
|
||
<el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-form-item label="备注说明" prop="remark" required>
|
||
<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('outbound_create: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('outbound_create:operation')" @click="clearAll" icon="Refresh">清空</el-button>
|
||
<el-button v-if="userStore.hasPermission('outbound_create: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, onMounted, 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, submitOutbound, getOutboundList, getApprovalRequestList, getStockAlternatives,
|
||
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
|
||
} from '@/api/outbound'
|
||
import { uploadFile } from '@/api/common/upload'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
// --- 状态定义 ---
|
||
const barcodeInput = ref('')
|
||
const cartItems = ref<any[]>([])
|
||
const loading = ref(false)
|
||
const showCamera = ref(false)
|
||
const barcodeRef = ref()
|
||
const formRef = ref()
|
||
|
||
// ★ 双轨制模式
|
||
// ★ 已移除直接出库模式,所有出库必须按单出库(通过 selectedRequest 校验)
|
||
const approvalRequests = ref<any[]>([])
|
||
const selectedRequest = ref<any>(null)
|
||
const requestsLoading = ref(false)
|
||
|
||
// 签名相关
|
||
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 operatorOptions = ref<string[]>([])
|
||
|
||
const form = reactive({
|
||
outbound_type: '',
|
||
consumer_name: '',
|
||
operator_name: '',
|
||
remark: ''
|
||
})
|
||
|
||
const rules = {
|
||
outbound_type: [{ required: true, message: '请选择出库类型', trigger: 'change' }],
|
||
consumer_name: [{ required: true, message: '请输入领用人姓名', trigger: 'blur' }],
|
||
operator_name: [{ required: true, message: '请指定操作员', trigger: 'change' }],
|
||
remark: [{ required: true, message: '请填写备注/出库原因', trigger: 'blur' }]
|
||
}
|
||
|
||
// 计算总金额
|
||
const totalAmount = computed(() => {
|
||
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
|
||
})
|
||
|
||
// ★ 双轨制 computed
|
||
const selectedRequestId = computed({
|
||
get: () => selectedRequest.value?.id ?? null,
|
||
set: (val) => {
|
||
if (!val) {
|
||
selectedRequest.value = null
|
||
} else {
|
||
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
|
||
}
|
||
}
|
||
})
|
||
|
||
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
|
||
|
||
// ============================================================================
|
||
// ★ 备选库位
|
||
//
|
||
// 背景:申请单已把货预占在某个库位,工人到现场发现该库位进不去/找不到,
|
||
// 需要改扫同物料的其它批次(后端执行端已支持「物理覆盖」,按 base_id 校验
|
||
// 身份、允许换批次)。但改造前系统不告诉他还有哪些库位有货,只能凭记忆翻。
|
||
//
|
||
// 这里按需查询:点击图标才发请求,避免计划清单渲染时打出一片请求。
|
||
// 后端按 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 },
|
||
selectedRequest.value?.id,
|
||
'outbound'
|
||
)
|
||
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(() => {
|
||
// ★ 先按「名称+规格」把计划行**合并**再比较。
|
||
// 序列号物料是一物一行(每行 quantity=1),若逐行比较,同一笔扫码会让
|
||
// 每一行都算作"已满足"(planQty 1 - scannedQty 1 = 0),于是扫了 1 个
|
||
// 就把整个物料判为扫完,未扫清单漏报。
|
||
const planGroups = new Map<string, { name: string; spec: string; planQty: number }>()
|
||
for (const plan of plannedItems.value) {
|
||
const name = (plan.name || '').trim()
|
||
const spec = (plan.spec_model || '').trim()
|
||
const key = `${name}|${spec}`
|
||
const g = planGroups.get(key) || { name, spec, planQty: 0 }
|
||
g.planQty += Number(plan.quantity) || 0
|
||
planGroups.set(key, g)
|
||
}
|
||
|
||
// 已扫数量同样按「名称+规格」汇总
|
||
const scannedByKey = new Map<string, number>()
|
||
for (const ci of cartItems.value) {
|
||
const key = `${(ci.name || '').trim()}|${(ci.spec_model || ci.standard || '').trim()}`
|
||
scannedByKey.set(key, (scannedByKey.get(key) || 0) + (Number(ci.out_quantity) || 0))
|
||
}
|
||
|
||
return Array.from(planGroups.entries()).map(([key, g]) => {
|
||
const scannedQty = scannedByKey.get(key) || 0
|
||
return {
|
||
name: g.name,
|
||
spec: g.spec,
|
||
planQty: g.planQty,
|
||
scannedQty,
|
||
remaining: Math.max(0, g.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 getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
|
||
approvalRequests.value = res.data?.items || []
|
||
await loadDraftOverview() // ★ 一并取草稿进度,用于下拉徽标
|
||
} catch (e) {
|
||
console.error('加载审批单列表失败', e)
|
||
} finally {
|
||
requestsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleRequestChange = async (val: number | null) => {
|
||
// ★ 关键:此刻 selectedRequest 已被 v-model 的 setter 改成了新单,
|
||
// 所以必须用 activeRequestId 拿到「刚离开的那张单」来保存。
|
||
const leavingId = activeRequestId.value
|
||
await saveDraftNow(leavingId)
|
||
|
||
if (!val) {
|
||
selectedRequest.value = null
|
||
form.remark = ''
|
||
} else {
|
||
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
|
||
// ★ 按单出库:自动将申请单的申请原因填入备注,用户可修改
|
||
if (selectedRequest.value?.remark) {
|
||
form.remark = selectedRequest.value.remark
|
||
}
|
||
// ★ 自动带出申请时确定的出库类型(库管无需再选);旧单无类型则留待手动选择
|
||
if (selectedRequest.value?.outbound_type) {
|
||
form.outbound_type = selectedRequest.value.outbound_type
|
||
}
|
||
}
|
||
|
||
cartItems.value = []
|
||
signatureFile.value = null
|
||
signaturePreviewUrl.value = ''
|
||
activeRequestId.value = val // ★ 切换到新单,后续保存以它为准
|
||
|
||
// ★ 载入目标单据的草稿(若之前扫到一半,这里自动恢复)
|
||
if (val) await restoreDraft(val)
|
||
}
|
||
|
||
// ============================================================================
|
||
// ★ 扫码草稿
|
||
//
|
||
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来还要接着扫。
|
||
// 库存在申请审批通过时已**预占**,暂停期间不会被他人抢走,因此草稿只需
|
||
// 记住「扫到哪了」,不涉及任何库存操作 —— 即使草稿丢了也只是重扫,
|
||
// 不会造成库存错乱。
|
||
//
|
||
// 保存时机:扫码后防抖 800ms 自动存;切换单据/离开页面时立即存。
|
||
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
|
||
// ============================================================================
|
||
let draftTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
// 各单据的草稿进度 { request_id: 已扫项数 },用于下拉徽标
|
||
const draftProgress = ref<Record<number, number>>({})
|
||
|
||
const loadDraftOverview = async () => {
|
||
try {
|
||
const res: any = await getScanDraftOverview('outbound')
|
||
const map: Record<number, number> = {}
|
||
for (const d of res?.data?.drafts || []) {
|
||
map[d.request_id] = d.item_count
|
||
}
|
||
draftProgress.value = map
|
||
} catch (e) {
|
||
console.warn('加载草稿概览失败', e)
|
||
}
|
||
}
|
||
|
||
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
|
||
// 原先只存 (stock_id, source_table, sku, quantity),恢复后名称/规格/库存
|
||
// 都是空的、出库数显示 NaN —— 因为购物车表格绑定的是 name / spec_model /
|
||
// available_quantity / out_quantity,而这些字段根本没被存下来。
|
||
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 —— 用于精确知道「哪张单的草稿需要存」
|
||
//
|
||
// ★ 为什么需要它:v-model="selectedRequestId" 的 computed setter 会**先于**
|
||
// @change 把 selectedRequest 改成新单据。若在 handleRequestChange 里读
|
||
// selectedRequest.value,拿到的已经是新单 —— 会把 A 的内容存到 B 名下。
|
||
// 用一个独立的 ref 记录「当前界面上显示的是哪张单」,保存时以此为准。
|
||
const activeRequestId = ref<number | null>(null)
|
||
|
||
// 立即保存(切换单据、离开页面、提交前调用)
|
||
//
|
||
// 显式接收 requestId:切换场景下必须传「旧单ID」,不能依赖 selectedRequest。
|
||
const saveDraftNow = async (requestId?: number | null) => {
|
||
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||
const rid = requestId ?? activeRequestId.value
|
||
if (!rid) return
|
||
|
||
const items = draftPayload()
|
||
|
||
// ★ 空清单**不清除草稿**。
|
||
// 原先"空则清除"是为了处理用户主动清空列表的情况,但它会在切换单据时
|
||
// 误删目标单的旧草稿(切换瞬间购物车必然为空)—— 这正是"切回来就没了"
|
||
// 的直接原因。现在空清单只是不保存,清理由「提交成功」或用户显式清空触发。
|
||
if (items.length === 0) return
|
||
|
||
try {
|
||
await saveScanDraft('outbound', rid, items, selectedRequest.value?.request_no)
|
||
draftProgress.value[rid] = items.length
|
||
} catch (e) {
|
||
// 草稿保存失败不阻断作业:工人手上还在扫,报错反而干扰
|
||
console.warn('保存草稿失败', e)
|
||
}
|
||
}
|
||
|
||
// 防抖保存(扫码后调用)
|
||
const scheduleSaveDraft = () => {
|
||
if (draftTimer) clearTimeout(draftTimer)
|
||
draftTimer = setTimeout(() => saveDraftNow(), 800)
|
||
}
|
||
|
||
// 恢复草稿到购物车
|
||
//
|
||
// 草稿存的是完整购物车快照(含名称/规格/库位等展示字段),因此直接还原即可,
|
||
// 无需回查申请单的 items_json —— 历史单据的 items_json 不含 stock_id,
|
||
// 回查反而会错配。
|
||
const restoreDraft = async (requestId: number) => {
|
||
try {
|
||
const res: any = await getScanDraft('outbound', requestId)
|
||
const items = res?.data?.items || []
|
||
if (!items.length) return
|
||
|
||
// 归一化:老草稿用 quantity,新草稿用 out_quantity;缺失字段补默认值,
|
||
// 避免购物车出现 NaN(NaN 会在提交时被兜底成 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)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 用实时库存刷新购物车行的「库存」列。
|
||
*
|
||
* 草稿里的 available_quantity 是扫描那一刻的快照,恢复时可能已过期
|
||
* (期间别人出库/借出/报废都会消耗可用量)。这里按 base_id 查一次实时值,
|
||
* 避免工人基于陈旧数字扫满、到提交时才发现不够。
|
||
*
|
||
* 复用 /alternatives 端点 —— 它按 base_id 返回各库存行的实时可用量。
|
||
* ★ 传入本单 id 后,返回的 available_quantity 是**有效可用量**
|
||
* (实时值 + 本单预占),故本单锁定行不会因实时值为 0 而被过滤掉,
|
||
* 下面的「已扫数量 > 最新可用量」判定也不会误报。
|
||
* 失败不阻断:拿不到实时值就沿用草稿快照,至少不影响继续作业。
|
||
*/
|
||
const refreshStockFromDraft = async (requestId?: number | null) => {
|
||
const plan = selectedRequest.value?.items || []
|
||
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
|
||
if (!baseIds.length) return
|
||
|
||
// ★ 显式透传单据 id(而非依赖 selectedRequest 已解析),保证预占回加一定生效
|
||
const rid = requestId ?? selectedRequest.value?.id
|
||
|
||
try {
|
||
const results = await Promise.all(
|
||
(baseIds as number[]).map(bid =>
|
||
getStockAlternatives(bid, undefined, rid, 'outbound').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()
|
||
|
||
// ★ 计划侧必须**汇总所有匹配行**,不能只取第一行。
|
||
// 序列号物料在申请单里是「一物一行、每行 quantity=1」,用 .find() 只拿到 1,
|
||
// 而下面的已扫侧是 .reduce() 求总和 —— 两边聚合口径不一致,
|
||
// 扫到第 2 个就会误报「超出计划数量(计划: 1,已扫: 1,本次: 1)」。
|
||
const matchedPlans = plannedItems.value.filter((plan: any) => {
|
||
const planName = (plan.name || '').trim()
|
||
const planSpec = (plan.spec_model || '').trim()
|
||
return planName === normalizedName && planSpec === normalizedSpec
|
||
})
|
||
|
||
if (!matchedPlans.length) {
|
||
return `该物料【${normalizedName} × ${normalizedSpec}】不在计划清单中,请检查`
|
||
}
|
||
|
||
const planQty = matchedPlans.reduce((sum: number, plan: any) => sum + (Number(plan.quantity) || 0), 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 // 通过
|
||
}
|
||
|
||
// --- 初始化 ---
|
||
onMounted(() => {
|
||
// 加载已审批通过的申请单列表
|
||
loadApprovalRequests()
|
||
|
||
if (userStore.username) {
|
||
form.operator_name = userStore.username
|
||
operatorOptions.value.push(userStore.username)
|
||
}
|
||
loadHistoryOperators()
|
||
})
|
||
|
||
const loadHistoryOperators = async () => {
|
||
try {
|
||
const res = await getOutboundList({ page: 1, limit: 50 })
|
||
if (res.data && res.data.items) {
|
||
const names = new Set<string>()
|
||
if (userStore.username) names.add(userStore.username)
|
||
res.data.items.forEach((group: any) => {
|
||
if (group.operator_name) names.add(group.operator_name)
|
||
})
|
||
operatorOptions.value = Array.from(names)
|
||
}
|
||
} catch (e) {
|
||
console.error('加载历史操作员失败', e)
|
||
}
|
||
}
|
||
|
||
// --- 核心扫码逻辑 (适配 QrScanner 组件) ---
|
||
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('outbound_create:operation')) {
|
||
ElMessage.warning('无操作权限')
|
||
return
|
||
}
|
||
const code = barcodeInput.value.trim()
|
||
if (!code) return
|
||
|
||
// ★ 按单出库模式:必须先选择申请单
|
||
if (!selectedRequest.value) {
|
||
ElMessage.warning('请先选择要出库的审批申请单')
|
||
return
|
||
}
|
||
|
||
try {
|
||
loading.value = true
|
||
|
||
// 1. 检查购物车重复(直接模式走旧的追加逻辑,按单模式也复用但后续会校验)
|
||
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
|
||
}
|
||
|
||
// 2. 调用 API 查询
|
||
// ★ 带上本单 id:申请阶段已把本单要的货从 available_quantity 扣掉(预占),
|
||
// 不回加的话,被本单占满的行会显示 0,工人根本扫不进去。
|
||
const res = await getStockByBarcode(code, selectedRequest.value?.id, 'outbound')
|
||
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: parseFloat(item.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
|
||
// 注意:全屏扫码模式下,我们不需要 refocus input,因为用户还在看摄像头
|
||
// 只有在非全屏模式下才 focus
|
||
if (!showCamera.value) {
|
||
nextTick(() => { barcodeRef.value?.focus() })
|
||
}
|
||
}
|
||
}
|
||
|
||
const removeFromCart = (index: number) => {
|
||
if (!userStore.hasPermission('outbound_create:operation')) {
|
||
ElMessage.warning('无操作权限')
|
||
return
|
||
}
|
||
cartItems.value.splice(index, 1)
|
||
}
|
||
|
||
const clearAll = () => {
|
||
if (!userStore.hasPermission('outbound_create:operation')) {
|
||
ElMessage.warning('无操作权限')
|
||
return
|
||
}
|
||
ElMessageBox.confirm('确定清空所有已选商品吗?', '提示', { type: 'warning' })
|
||
.then(() => {
|
||
cartItems.value = []
|
||
form.consumer_name = ''
|
||
form.remark = ''
|
||
signatureFile.value = null
|
||
signaturePreviewUrl.value = ''
|
||
barcodeInput.value = ''
|
||
// ★ 按单模式:仅清空购物车,保留申请单选择
|
||
// 用户显式清空 = 放弃本次作业 → 同步清除草稿与徽标
|
||
const rid = activeRequestId.value
|
||
if (rid) {
|
||
clearScanDraft('outbound', rid).catch(() => {})
|
||
delete draftProgress.value[rid]
|
||
}
|
||
})
|
||
}
|
||
|
||
// --- 提交逻辑 ---
|
||
const submitForm = async () => {
|
||
if (!userStore.hasPermission('outbound_create:operation')) {
|
||
ElMessage.warning('无操作权限')
|
||
return
|
||
}
|
||
if (!formRef.value) return
|
||
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
|
||
// ★ 按单出库:提交前必须已选择审批申请单
|
||
if (!selectedRequest.value) {
|
||
ElMessage.warning('请先选择要出库的审批申请单')
|
||
return
|
||
}
|
||
// 记下单据ID:清空 cartItems 后 selectedRequest 可能被重置,草稿清除需要它
|
||
const submittedRequestId = selectedRequest.value.id
|
||
|
||
await formRef.value.validate(async (valid: boolean) => {
|
||
if (!valid) return
|
||
|
||
if (!signatureFile.value) {
|
||
ElMessage.error('请进行电子签名')
|
||
return
|
||
}
|
||
|
||
try {
|
||
loading.value = true
|
||
|
||
// 1. 上传签名
|
||
const uploadRes = await uploadFile(signatureFile.value)
|
||
const signatureUrl = uploadRes.data.url
|
||
|
||
// 2. 核心保护:坚决杜绝 undefined、null 和 0
|
||
const itemsPayload = cartItems.value.map(item => {
|
||
// 强制确保出库数量是一个大于 0 的有效数字
|
||
let safeQuantity = Number(item.out_quantity)
|
||
if (isNaN(safeQuantity) || safeQuantity <= 0) {
|
||
safeQuantity = 1 // 兜底:只要扫了码,最少出 1 个
|
||
}
|
||
|
||
return {
|
||
stock_id: item.id || 0,
|
||
source_table: item.source_table || '',
|
||
// 如果原数据 sku 是空,强制塞一个默认字符串,绝不传空值给后端引发 None 报错
|
||
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
|
||
barcode: item.barcode ? String(item.barcode) : '',
|
||
quantity: safeQuantity,
|
||
price: item.price ? Number(item.price) : 0
|
||
}
|
||
})
|
||
|
||
if (itemsPayload.length === 0) {
|
||
ElMessage.warning('请至少扫描一件物料后再提交出库')
|
||
return
|
||
}
|
||
|
||
// 3. 组装发给后端的包
|
||
const submitPayload: any = {
|
||
outbound_type: form.outbound_type,
|
||
request_id: selectedRequest.value ? selectedRequest.value.id : null,
|
||
consumer_name: form.consumer_name,
|
||
operator_name: form.operator_name,
|
||
remark: form.remark,
|
||
signature_path: signatureUrl,
|
||
items: itemsPayload
|
||
}
|
||
|
||
// 打印在前端控制台,你可以按 F12 在 Console 里核对这把"铁证"
|
||
console.log('准备提交给后端的最终数据:', JSON.parse(JSON.stringify(submitPayload)))
|
||
|
||
// 4. 发送请求
|
||
await submitOutbound(submitPayload)
|
||
|
||
ElMessage.success('出库成功')
|
||
|
||
// ★ 提交成功 → 清除该单据的草稿,避免下次打开时恢复出已提交的内容
|
||
if (submittedRequestId) {
|
||
clearScanDraft('outbound', submittedRequestId).catch(() => {})
|
||
delete draftProgress.value[submittedRequestId] // 同步移除下拉徽标
|
||
}
|
||
|
||
// 5. 成功后重置页面
|
||
cartItems.value = []
|
||
form.consumer_name = ''
|
||
form.remark = ''
|
||
signatureFile.value = null
|
||
|
||
// 根据你的项目实际变量重置签名组件,如果没有这句可以删掉
|
||
if (typeof signaturePreviewUrl !== 'undefined') {
|
||
signaturePreviewUrl.value = ''
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('出库报错:', error)
|
||
ElMessage.error('提交失败,请检查数据')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
}
|
||
|
||
// --- 签名逻辑 (Canvas) ---
|
||
const openSignatureDialog = () => {
|
||
if (!userStore.hasPermission('outbound_create: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 }
|
||
|
||
onUnmounted(() => {
|
||
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
||
if (draftTimer) clearTimeout(draftTimer)
|
||
// 其余清理(定时检测、事件监听)见文件末尾的统一 onUnmounted
|
||
})
|
||
|
||
// ★ 离开页面前:立即存草稿(防抖中未落盘的内容会丢)
|
||
//
|
||
// 只有清单非空时才提示 —— 空清单离开是正常操作,弹窗只会造成干扰。
|
||
// 草稿已在后端,所以提示文案是"可恢复"而非"会丢失",语气相应放松。
|
||
onBeforeRouteLeave(async (_to, _from, next) => {
|
||
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
|
||
const hasItems = cartItems.value.length > 0 && selectedRequest.value?.id
|
||
|
||
if (hasItems) {
|
||
await saveDraftNow() // 先确保落盘,再询问
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`当前单据【${selectedRequest.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
|
||
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗?`,
|
||
'离开确认',
|
||
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
|
||
)
|
||
} catch (e) {
|
||
return next(false) // 用户选择留下
|
||
}
|
||
}
|
||
next()
|
||
})
|
||
|
||
// 刷新/关闭浏览器时同步存草稿
|
||
//
|
||
// 注意:不能用异步请求(浏览器不会等待),改用 sendBeacon 在卸载期间
|
||
// 投递一个"尽力而为"的保存。失败也无妨 —— 扫码过程中的防抖保存已经
|
||
// 落盘了绝大部分内容,这里只补最后 800ms 的差量。
|
||
const handleBeforeUnload = () => {
|
||
const req = selectedRequest.value
|
||
if (!req?.id || cartItems.value.length === 0) return
|
||
try {
|
||
const token = localStorage.getItem('token') || ''
|
||
const blob = new Blob([JSON.stringify({
|
||
biz_type: 'outbound', request_id: req.id, request_no: req.request_no,
|
||
items: draftPayload(),
|
||
})], { type: 'application/json' })
|
||
// sendBeacon 无法自定义 header,故用查询参数带上 token(仅用于此处的
|
||
// 非敏感草稿保存;后端仍以 JWT 校验身份,缺失时该次保存被忽略)
|
||
navigator.sendBeacon?.(
|
||
`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob
|
||
)
|
||
} catch (e) { /* 尽力而为,失败不影响 */ }
|
||
}
|
||
|
||
// ============================================================================
|
||
// ★ 单据失效检测
|
||
//
|
||
// 场景:库管正在扫码,管理员/申请人把这张单撤回了(或已执行完)。
|
||
// 若不检测,库管会一直扫到提交时才发现单据已作废 —— 白扫一场。
|
||
//
|
||
// 检测方式:重新拉一次「已通过(status=1)」列表,看当前单据是否还在其中。
|
||
// 不在 → 说明已被撤回/驳回/执行,立即告知并清空界面。
|
||
// 复用现有列表接口,无需新增端点。
|
||
//
|
||
// 触发时机:
|
||
// · 页面重新可见时(PDA 熄屏唤醒、切回浏览器)
|
||
// · 每 60 秒一次(仅在购物车非空、即正在作业时)
|
||
// ============================================================================
|
||
const checkRequestStillValid = async () => {
|
||
const rid = activeRequestId.value
|
||
if (!rid || cartItems.value.length === 0) return // 没在作业,不打扰
|
||
|
||
try {
|
||
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
|
||
const stillValid = (res.data?.items || []).some((r: any) => r.id === rid)
|
||
if (stillValid) return
|
||
|
||
// 单据已不在「已通过」列表中 → 失效
|
||
const no = selectedRequest.value?.request_no || rid
|
||
cartItems.value = []
|
||
selectedRequest.value = null
|
||
activeRequestId.value = null
|
||
ElMessageBox.alert(
|
||
`申请单【${no}】已被撤回或已执行,不能再继续出库。\n\n` +
|
||
`本次已扫的内容已清空(草稿也已随之清除)。\n` +
|
||
`如需出库,请重新提交申请。`,
|
||
'⚠️ 申请单已失效',
|
||
{ type: 'warning', confirmButtonText: '知道了' }
|
||
).catch(() => {})
|
||
} catch (e) {
|
||
// 网络异常时不打断作业 —— 后端在提交时仍会做最终校验
|
||
console.warn('单据状态检测失败', e)
|
||
}
|
||
}
|
||
|
||
// 页面重新可见时检测(PDA 熄屏唤醒、切回标签页)
|
||
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)
|
||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
})
|
||
</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; }
|
||
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
|
||
|
||
/* ★ 双轨制模式切换 */
|
||
.mode-switch-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
padding: 12px 16px;
|
||
background: #f5f7fa;
|
||
border-radius: 8px;
|
||
border: 1px solid #e4e7ed;
|
||
}
|
||
.mode-hint { color: #909399; font-size: 13px; }
|
||
|
||
/* ★ 审批单选择 */
|
||
.approval-request-select { margin-bottom: 16px; }
|
||
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
|
||
|
||
/* ★ 计划清单 */
|
||
.planned-items-section {
|
||
margin-bottom: 16px;
|
||
padding: 12px;
|
||
background: #f0f9eb;
|
||
border: 1px solid #e1f3d8;
|
||
border-radius: 8px;
|
||
}
|
||
.planned-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
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; }
|
||
|
||
/* 图标放大到 18px,并加圆形底衬,在触屏上更易命中 */
|
||
.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;
|
||
}
|
||
/* 强制子组件(QrScanner)填满容器 */
|
||
: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>
|