袁伟杰: V2.0.002 其他入库其他出库扫码
This commit is contained in:
403
src/pages-erp/stock-in/scan/index.vue
Normal file
403
src/pages-erp/stock-in/scan/index.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<view class="yd-page-container scan-page">
|
||||
<wd-navbar
|
||||
title="扫码其它入库"
|
||||
left-arrow
|
||||
placeholder
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<view class="scan-page__content">
|
||||
<view class="scan-section">
|
||||
<view class="scan-section__title">
|
||||
单据信息
|
||||
</view>
|
||||
<picker :range="warehouses" range-key="name" :value="selectedWarehouseIndex" @change="handleWarehouseChange">
|
||||
<view class="scan-field">
|
||||
{{ currentWarehouse?.name || '请选择入库仓库' }}
|
||||
</view>
|
||||
</picker>
|
||||
<picker :range="suppliers" range-key="name" :value="selectedSupplierIndex" @change="handleSupplierChange">
|
||||
<view class="scan-field">
|
||||
{{ currentSupplierName }}
|
||||
</view>
|
||||
</picker>
|
||||
<input v-model="remark" class="scan-field" placeholder="请输入备注">
|
||||
</view>
|
||||
|
||||
<view class="scan-section">
|
||||
<view class="scan-section__title">
|
||||
扫码录入
|
||||
</view>
|
||||
<ScanInput
|
||||
v-model="scanCode"
|
||||
placeholder="请先选择仓库,再扫描产品条码"
|
||||
:loading="scanLoading"
|
||||
@submit="handleScanSubmit"
|
||||
@camera-scan="handleCameraScan"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="scan-section">
|
||||
<view class="scan-section__title">
|
||||
入库明细
|
||||
</view>
|
||||
<ItemList :items="items" @increase="increaseCount" @decrease="decreaseCount" @remove="removeItem" />
|
||||
</view>
|
||||
|
||||
<SubmitBar :loading="submitting" submit-text="提交其它入库" @submit="handleSubmit" @secondary="clearItems" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProductByBarCode } from '@/api/erp/product'
|
||||
import type { StockIn, StockInItem } from '@/api/erp/stock-in'
|
||||
import type { SupplierSimple } from '@/api/erp/supplier'
|
||||
import type { Warehouse } from '@/api/erp/warehouse'
|
||||
import type { ItemCardValue } from '@/components/erp-scan/item-list.vue'
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { getProductByBarCode } from '@/api/erp/product'
|
||||
import { createStockIn } from '@/api/erp/stock-in'
|
||||
import { getSupplierSimpleList } from '@/api/erp/supplier'
|
||||
import { getWarehouseSimpleList } from '@/api/erp/warehouse'
|
||||
import ItemList from '@/components/erp-scan/item-list.vue'
|
||||
import ScanInput from '@/components/erp-scan/scan-input.vue'
|
||||
import SubmitBar from '@/components/erp-scan/submit-bar.vue'
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
import { buildScanMergeKey, normalizeCameraScanResult, normalizeScanCode } from '@/utils/scan'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
interface StockInScanItem extends ItemCardValue {
|
||||
remark?: string
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const scanCode = ref('')
|
||||
const scanLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const remark = ref('')
|
||||
const warehouses = ref<Warehouse[]>([])
|
||||
const suppliers = ref<SupplierSimple[]>([])
|
||||
const selectedWarehouseId = ref<number>()
|
||||
const selectedSupplierId = ref<number>()
|
||||
const items = ref<StockInScanItem[]>([])
|
||||
|
||||
const currentWarehouse = computed(() =>
|
||||
warehouses.value.find(item => item.id === selectedWarehouseId.value),
|
||||
)
|
||||
const currentSupplierName = computed(() => suppliers.value.find(item => item.id === selectedSupplierId.value)?.name || '请选择供应商(可选)')
|
||||
const selectedWarehouseIndex = computed(() => Math.max(warehouses.value.findIndex(item => item.id === selectedWarehouseId.value), 0))
|
||||
const selectedSupplierIndex = computed(() => {
|
||||
const index = suppliers.value.findIndex(item => item.id === selectedSupplierId.value)
|
||||
return index < 0 ? 0 : index
|
||||
})
|
||||
|
||||
/**
|
||||
* 功能说明:返回上一页,若没有上一级页面则回到其它入库列表页。
|
||||
* 适用场景:用户从快捷页返回库存业务列表。
|
||||
* @return 无
|
||||
* 注意事项:使用增强返回逻辑,避免页面栈为空时返回失败。
|
||||
*/
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages-erp/stock-in/index')
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:加载扫码其它入库页依赖的基础下拉数据。
|
||||
* 适用场景:页面首次进入时准备仓库和供应商数据。
|
||||
* @return 无
|
||||
* 注意事项:默认仓库优先取系统默认仓库,减少仓库人员开始作业前的额外点击。
|
||||
*/
|
||||
async function loadInitialData() {
|
||||
const [warehouseList, supplierList] = await Promise.all([
|
||||
getWarehouseSimpleList(),
|
||||
getSupplierSimpleList(),
|
||||
])
|
||||
|
||||
warehouses.value = warehouseList
|
||||
suppliers.value = supplierList
|
||||
selectedWarehouseId.value = warehouseList.find(item => item.defaultStatus)?.id || warehouseList[0]?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:切换当前入库仓库。
|
||||
* 适用场景:操作员选择本次扫码作业仓库。
|
||||
* @param event picker 事件
|
||||
* @return 无
|
||||
* 注意事项:已录入明细不会被自动改仓,避免把历史扫码明细误迁到新仓库。
|
||||
*/
|
||||
function handleWarehouseChange(event: Record<string, any>) {
|
||||
const warehouse = warehouses.value[Number(event.detail.value)]
|
||||
selectedWarehouseId.value = warehouse?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:切换当前供应商。
|
||||
* 适用场景:其它入库单头维护可选供应商。
|
||||
* @param event picker 事件
|
||||
* @return 无
|
||||
* 注意事项:供应商当前不是强校验项,因此仅在存在时传给后端。
|
||||
*/
|
||||
function handleSupplierChange(event: Record<string, any>) {
|
||||
const supplier = suppliers.value[Number(event.detail.value)]
|
||||
selectedSupplierId.value = supplier?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:把扫码命中的产品合并到当前入库明细。
|
||||
* 适用场景:扫码枪连续扫到同一产品时自动累计数量。
|
||||
* @param product 条码命中的产品
|
||||
* @return 无
|
||||
* 注意事项:重复扫码按“仓库 + 产品”合并,避免同一产品刷出多行影响 PDA 作业效率。
|
||||
*/
|
||||
function appendScannedProduct(product: ProductByBarCode) {
|
||||
const warehouseId = selectedWarehouseId.value as number
|
||||
const targetKey = buildScanMergeKey({ warehouseId, productId: product.id, count: 1 })
|
||||
const existedIndex = items.value.findIndex(item => buildScanMergeKey({
|
||||
warehouseId: item.warehouseId,
|
||||
productId: item.productId,
|
||||
count: item.count,
|
||||
}) === targetKey)
|
||||
|
||||
if (existedIndex >= 0) {
|
||||
items.value[existedIndex].count += 1
|
||||
return
|
||||
}
|
||||
|
||||
items.value.unshift({
|
||||
warehouseId,
|
||||
warehouseName: currentWarehouse.value?.name,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productBarCode: product.barCode,
|
||||
productSpec: product.standard,
|
||||
productUnitName: product.unitName,
|
||||
productPrice: Number(product.minPrice || 0),
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:处理扫码提交。
|
||||
* 适用场景:扫码枪回车或用户点击确认按钮。
|
||||
* @param sourceCode 可选的外部条码输入值
|
||||
* @return 无
|
||||
* 注意事项:未选择仓库时直接拦截,避免条码产品被录入到错误仓库上下文。
|
||||
*/
|
||||
async function handleScanSubmit(sourceCode?: string) {
|
||||
if (scanLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedWarehouseId.value) {
|
||||
toast.error('请先选择仓库')
|
||||
return
|
||||
}
|
||||
|
||||
const barCode = normalizeScanCode(sourceCode ?? scanCode.value)
|
||||
if (!barCode) {
|
||||
toast.error('请先扫描条码')
|
||||
return
|
||||
}
|
||||
|
||||
scanLoading.value = true
|
||||
try {
|
||||
const product = await getProductByBarCode(barCode)
|
||||
if (!product?.id) {
|
||||
toast.error('未匹配到启用中的产品')
|
||||
return
|
||||
}
|
||||
|
||||
appendScannedProduct(product)
|
||||
scanCode.value = ''
|
||||
} finally {
|
||||
scanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:触发相机扫码,并将结果接入现有扫码录入流程。
|
||||
* 适用场景:普通手机没有外接扫码枪时,通过摄像头录入条码。
|
||||
* @return 无
|
||||
* 注意事项:扫码取消应静默返回,不应污染当前输入框内容或产生误报。
|
||||
*/
|
||||
function handleCameraScan() {
|
||||
if (scanLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
success: async (result) => {
|
||||
const barCode = normalizeCameraScanResult(result)
|
||||
if (!barCode) {
|
||||
return
|
||||
}
|
||||
|
||||
scanCode.value = barCode
|
||||
await handleScanSubmit(barCode)
|
||||
},
|
||||
fail: (error) => {
|
||||
// 用户主动取消扫码不属于异常流程,这里静默返回,避免产生干扰提示。
|
||||
if (String(error?.errMsg || '').includes('cancel')) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.error('相机扫码失败,请重试')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:增加指定明细数量。
|
||||
* 适用场景:扫码后人工补正入库数量。
|
||||
* @param index 明细下标
|
||||
* @return 无
|
||||
* 注意事项:其它入库不需要前端库存拦截,只做数量累加。
|
||||
*/
|
||||
function increaseCount(index: number) {
|
||||
items.value[index].count += 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:减少指定明细数量。
|
||||
* 适用场景:误扫后人工回退数量。
|
||||
* @param index 明细下标
|
||||
* @return 无
|
||||
* 注意事项:数量最低保留 1,避免留下 0 数量脏明细。
|
||||
*/
|
||||
function decreaseCount(index: number) {
|
||||
if (items.value[index].count <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value[index].count -= 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:删除指定明细。
|
||||
* 适用场景:误扫错误产品后移除整行。
|
||||
* @param index 明细下标
|
||||
* @return 无
|
||||
* 注意事项:这里只删除当前行,不联动其他已录入产品。
|
||||
*/
|
||||
function removeItem(index: number) {
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:清空当前录入明细。
|
||||
* 适用场景:整单作废后重新扫码。
|
||||
* @return 无
|
||||
* 注意事项:清空后保留当前仓库和供应商上下文,避免重新开始时重复选择。
|
||||
*/
|
||||
function clearItems() {
|
||||
items.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:将页面明细转换成其它入库接口需要的明细结构。
|
||||
* 适用场景:提交前组装请求体。
|
||||
* @return 其它入库明细数组
|
||||
* 注意事项:这里只保留后端真正需要的字段,避免把展示字段一并带入接口契约。
|
||||
*/
|
||||
function buildSubmitItems(): StockInItem[] {
|
||||
return items.value.map(item => ({
|
||||
warehouseId: item.warehouseId,
|
||||
productId: item.productId,
|
||||
productPrice: item.productPrice,
|
||||
count: item.count,
|
||||
remark: item.remark,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:提交扫码其它入库单。
|
||||
* 适用场景:扫码录入完成后生成正式其它入库单。
|
||||
* @return 无
|
||||
* 注意事项:提交失败时保留当前录入数据,方便用户修正后重试。
|
||||
*/
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (items.value.length === 0) {
|
||||
toast.error('请先扫描入库明细')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: StockIn = {
|
||||
supplierId: selectedSupplierId.value,
|
||||
inTime: new Date().toISOString(),
|
||||
remark: remark.value,
|
||||
items: buildSubmitItems(),
|
||||
}
|
||||
|
||||
await createStockIn(payload)
|
||||
toast.success('入库成功')
|
||||
clearItems()
|
||||
remark.value = ''
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:初始化扫码其它入库页。
|
||||
* 适用场景:页面首次挂载时准备基础数据。
|
||||
* @return 无
|
||||
* 注意事项:初始化失败时由请求封装统一提示,这里不额外重复 toast。
|
||||
*/
|
||||
onMounted(async () => {
|
||||
await loadInitialData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scan-page {
|
||||
background: #f4f6f8;
|
||||
|
||||
&__content {
|
||||
padding: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-section {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #fff;
|
||||
|
||||
&__title {
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-field {
|
||||
height: 84rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 0 24rpx;
|
||||
line-height: 84rpx;
|
||||
border: 2rpx solid #e5e6eb;
|
||||
border-radius: 12rpx;
|
||||
background: #f7f8fa;
|
||||
color: #1f2329;
|
||||
}
|
||||
</style>
|
||||
455
src/pages-erp/stock-out/scan/index.vue
Normal file
455
src/pages-erp/stock-out/scan/index.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<view class="yd-page-container scan-page">
|
||||
<wd-navbar
|
||||
title="扫码其它出库"
|
||||
left-arrow
|
||||
placeholder
|
||||
safe-area-inset-top
|
||||
fixed
|
||||
@click-left="handleBack"
|
||||
/>
|
||||
|
||||
<view class="scan-page__content">
|
||||
<view class="scan-section">
|
||||
<view class="scan-section__title">
|
||||
单据信息
|
||||
</view>
|
||||
<picker :range="warehouses" range-key="name" :value="selectedWarehouseIndex" @change="handleWarehouseChange">
|
||||
<view class="scan-field">
|
||||
{{ currentWarehouse?.name || '请选择出库仓库' }}
|
||||
</view>
|
||||
</picker>
|
||||
<picker :range="customers" range-key="name" :value="selectedCustomerIndex" @change="handleCustomerChange">
|
||||
<view class="scan-field">
|
||||
{{ currentCustomerName }}
|
||||
</view>
|
||||
</picker>
|
||||
<input v-model="remark" class="scan-field" placeholder="请输入备注">
|
||||
</view>
|
||||
|
||||
<view class="scan-section">
|
||||
<view class="scan-section__title">
|
||||
扫码录入
|
||||
</view>
|
||||
<ScanInput
|
||||
v-model="scanCode"
|
||||
placeholder="请先选择仓库,再扫描产品条码"
|
||||
:loading="scanLoading"
|
||||
@submit="handleScanSubmit"
|
||||
@camera-scan="handleCameraScan"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="scan-section">
|
||||
<view class="scan-section__title">
|
||||
出库明细
|
||||
</view>
|
||||
<ItemList :items="items" :show-stock="true" @increase="increaseCount" @decrease="decreaseCount" @remove="removeItem" />
|
||||
</view>
|
||||
|
||||
<SubmitBar :loading="submitting" submit-text="提交其它出库" @submit="handleSubmit" @secondary="clearItems" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CustomerSimple } from '@/api/erp/customer'
|
||||
import type { ProductByBarCode } from '@/api/erp/product'
|
||||
import type { Stock } from '@/api/erp/stock'
|
||||
import type { StockOut, StockOutItem } from '@/api/erp/stock-out'
|
||||
import type { Warehouse } from '@/api/erp/warehouse'
|
||||
import type { ItemCardValue } from '@/components/erp-scan/item-list.vue'
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { getCustomerSimpleList } from '@/api/erp/customer'
|
||||
import { getProductByBarCode } from '@/api/erp/product'
|
||||
import { getStock2 } from '@/api/erp/stock'
|
||||
import { createStockOut } from '@/api/erp/stock-out'
|
||||
import { getWarehouseSimpleList } from '@/api/erp/warehouse'
|
||||
import ItemList from '@/components/erp-scan/item-list.vue'
|
||||
import ScanInput from '@/components/erp-scan/scan-input.vue'
|
||||
import SubmitBar from '@/components/erp-scan/submit-bar.vue'
|
||||
import { navigateBackPlus } from '@/utils'
|
||||
import { buildScanMergeKey, normalizeCameraScanResult, normalizeScanCode } from '@/utils/scan'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '',
|
||||
navigationStyle: 'custom',
|
||||
},
|
||||
})
|
||||
|
||||
interface StockOutScanItem extends ItemCardValue {
|
||||
remark?: string
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const scanCode = ref('')
|
||||
const scanLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const remark = ref('')
|
||||
const warehouses = ref<Warehouse[]>([])
|
||||
const customers = ref<CustomerSimple[]>([])
|
||||
const selectedWarehouseId = ref<number>()
|
||||
const selectedCustomerId = ref<number>()
|
||||
const items = ref<StockOutScanItem[]>([])
|
||||
|
||||
const currentWarehouse = computed(() =>
|
||||
warehouses.value.find(item => item.id === selectedWarehouseId.value),
|
||||
)
|
||||
const currentCustomerName = computed(() => customers.value.find(item => item.id === selectedCustomerId.value)?.name || '请选择客户(可选)')
|
||||
const selectedWarehouseIndex = computed(() => Math.max(warehouses.value.findIndex(item => item.id === selectedWarehouseId.value), 0))
|
||||
const selectedCustomerIndex = computed(() => {
|
||||
const index = customers.value.findIndex(item => item.id === selectedCustomerId.value)
|
||||
return index < 0 ? 0 : index
|
||||
})
|
||||
|
||||
/**
|
||||
* 功能说明:返回上一页,若没有上一级页面则回到其它出库列表页。
|
||||
* 适用场景:用户从快捷页返回库存业务列表。
|
||||
* @return 无
|
||||
* 注意事项:使用增强返回逻辑,避免页面栈为空时返回失败。
|
||||
*/
|
||||
function handleBack() {
|
||||
navigateBackPlus('/pages-erp/stock-out/index')
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:加载扫码其它出库页依赖的基础下拉数据。
|
||||
* 适用场景:页面首次进入时准备仓库和客户数据。
|
||||
* @return 无
|
||||
* 注意事项:默认仓库优先取系统默认仓库,减少开始作业前的额外点击。
|
||||
*/
|
||||
async function loadInitialData() {
|
||||
const [warehouseList, customerList] = await Promise.all([
|
||||
getWarehouseSimpleList(),
|
||||
getCustomerSimpleList(),
|
||||
])
|
||||
|
||||
warehouses.value = warehouseList
|
||||
customers.value = customerList
|
||||
selectedWarehouseId.value = warehouseList.find(item => item.defaultStatus)?.id || warehouseList[0]?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:切换当前出库仓库。
|
||||
* 适用场景:操作员选择本次扫码作业仓库。
|
||||
* @param event picker 事件
|
||||
* @return 无
|
||||
* 注意事项:已录入明细不会被自动改仓,避免中途切仓导致历史扫码上下文错乱。
|
||||
*/
|
||||
function handleWarehouseChange(event: Record<string, any>) {
|
||||
const warehouse = warehouses.value[Number(event.detail.value)]
|
||||
selectedWarehouseId.value = warehouse?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:切换当前客户。
|
||||
* 适用场景:其它出库单头维护可选客户信息。
|
||||
* @param event picker 事件
|
||||
* @return 无
|
||||
* 注意事项:客户当前不是必填项,但保留该字段便于后续业务追溯。
|
||||
*/
|
||||
function handleCustomerChange(event: Record<string, any>) {
|
||||
const customer = customers.value[Number(event.detail.value)]
|
||||
selectedCustomerId.value = customer?.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:按产品和仓库查询即时库存。
|
||||
* 适用场景:扫码新增明细前或手工增加数量前进行库存校验。
|
||||
* @param productId 产品编号
|
||||
* @return 当前仓库下的库存信息
|
||||
* 注意事项:出库页需要每次实时查询,避免沿用旧库存误放行。
|
||||
*/
|
||||
async function loadStockInfo(productId: number): Promise<Stock> {
|
||||
return await getStock2(productId, selectedWarehouseId.value as number)
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:校验目标数量是否超过当前库存。
|
||||
* 适用场景:扫码新增、重复扫码累计、手工增加数量。
|
||||
* @param stockCount 当前库存数量
|
||||
* @param nextCount 目标数量
|
||||
* @param productName 产品名称
|
||||
* @return 是否允许继续
|
||||
* 注意事项:不在前端提前拦截的话,仓库人员可能连续扫码后才在提交时整体失败,体验会很差。
|
||||
*/
|
||||
function validateStock(stockCount: number, nextCount: number, productName: string): boolean {
|
||||
if (Number(stockCount || 0) < nextCount) {
|
||||
toast.error(`${productName} 库存不足`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:把扫码命中的产品合并到当前出库明细。
|
||||
* 适用场景:扫码枪连续扫到同一产品时自动累计数量。
|
||||
* @param product 条码命中的产品
|
||||
* @return 无
|
||||
* 注意事项:重复扫码前会先刷新库存并验证累计后数量,避免页面沿用旧库存误放行。
|
||||
*/
|
||||
async function appendScannedProduct(product: ProductByBarCode) {
|
||||
const warehouseId = selectedWarehouseId.value as number
|
||||
const targetKey = buildScanMergeKey({ warehouseId, productId: product.id, count: 1 })
|
||||
const existedIndex = items.value.findIndex(item => buildScanMergeKey({
|
||||
warehouseId: item.warehouseId,
|
||||
productId: item.productId,
|
||||
count: item.count,
|
||||
}) === targetKey)
|
||||
const stockInfo = await loadStockInfo(product.id)
|
||||
const stockCount = Number(stockInfo?.count || 0)
|
||||
|
||||
if (existedIndex >= 0) {
|
||||
const nextCount = items.value[existedIndex].count + 1
|
||||
if (!validateStock(stockCount, nextCount, product.name)) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value[existedIndex].count = nextCount
|
||||
items.value[existedIndex].stockCount = stockCount
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateStock(stockCount, 1, product.name)) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value.unshift({
|
||||
warehouseId,
|
||||
warehouseName: currentWarehouse.value?.name,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
productBarCode: product.barCode,
|
||||
productSpec: product.standard,
|
||||
productUnitName: product.unitName,
|
||||
productPrice: Number(stockInfo?.unitPrice ?? product.minPrice ?? 0),
|
||||
stockCount,
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:处理扫码提交。
|
||||
* 适用场景:扫码枪回车或用户点击确认按钮。
|
||||
* @param sourceCode 可选的外部条码输入值
|
||||
* @return 无
|
||||
* 注意事项:未选择仓库时直接拦截,避免库存查询与出库校验失去仓库上下文。
|
||||
*/
|
||||
async function handleScanSubmit(sourceCode?: string) {
|
||||
if (scanLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedWarehouseId.value) {
|
||||
toast.error('请先选择仓库')
|
||||
return
|
||||
}
|
||||
|
||||
const barCode = normalizeScanCode(sourceCode ?? scanCode.value)
|
||||
if (!barCode) {
|
||||
toast.error('请先扫描条码')
|
||||
return
|
||||
}
|
||||
|
||||
scanLoading.value = true
|
||||
try {
|
||||
const product = await getProductByBarCode(barCode)
|
||||
if (!product?.id) {
|
||||
toast.error('未匹配到启用中的产品')
|
||||
return
|
||||
}
|
||||
|
||||
await appendScannedProduct(product)
|
||||
scanCode.value = ''
|
||||
} finally {
|
||||
scanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:触发相机扫码,并将结果接入现有扫码录入流程。
|
||||
* 适用场景:普通手机没有外接扫码枪时,通过摄像头录入条码。
|
||||
* @return 无
|
||||
* 注意事项:扫码取消应静默返回,避免把用户主动取消误判成系统错误。
|
||||
*/
|
||||
function handleCameraScan() {
|
||||
if (scanLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
success: async (result) => {
|
||||
const barCode = normalizeCameraScanResult(result)
|
||||
if (!barCode) {
|
||||
return
|
||||
}
|
||||
|
||||
scanCode.value = barCode
|
||||
await handleScanSubmit(barCode)
|
||||
},
|
||||
fail: (error) => {
|
||||
// 用户主动取消扫码不属于异常流程,这里静默返回,避免产生干扰提示。
|
||||
if (String(error?.errMsg || '').includes('cancel')) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.error('相机扫码失败,请重试')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:增加指定明细数量。
|
||||
* 适用场景:扫码后人工补正出库数量。
|
||||
* @param index 明细下标
|
||||
* @return 无
|
||||
* 注意事项:每次加数量前都重新校验库存,避免页面显示的库存已被其他单据消耗后继续放行。
|
||||
*/
|
||||
async function increaseCount(index: number) {
|
||||
const item = items.value[index]
|
||||
const stockInfo = await loadStockInfo(item.productId)
|
||||
const nextCount = item.count + 1
|
||||
if (!validateStock(Number(stockInfo?.count || 0), nextCount, item.productName)) {
|
||||
return
|
||||
}
|
||||
|
||||
item.stockCount = Number(stockInfo?.count || 0)
|
||||
item.count = nextCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:减少指定明细数量。
|
||||
* 适用场景:误扫后人工回退数量。
|
||||
* @param index 明细下标
|
||||
* @return 无
|
||||
* 注意事项:数量最低保留 1,避免出现 0 数量明细继续提交。
|
||||
*/
|
||||
function decreaseCount(index: number) {
|
||||
if (items.value[index].count <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value[index].count -= 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:删除指定明细。
|
||||
* 适用场景:误扫错误产品后移除整行。
|
||||
* @param index 明细下标
|
||||
* @return 无
|
||||
* 注意事项:这里只删除当前行,不联动其他产品数量和库存展示。
|
||||
*/
|
||||
function removeItem(index: number) {
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:清空当前录入明细。
|
||||
* 适用场景:整单作废后重新扫码。
|
||||
* @return 无
|
||||
* 注意事项:清空后保留当前仓库和客户上下文,便于同一作业场景继续录单。
|
||||
*/
|
||||
function clearItems() {
|
||||
items.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:将页面明细转换成其它出库接口需要的明细结构。
|
||||
* 适用场景:提交前组装请求体。
|
||||
* @return 其它出库明细数组
|
||||
* 注意事项:这里只保留后端真正需要的字段,避免把展示字段一并带入接口契约。
|
||||
*/
|
||||
function buildSubmitItems(): StockOutItem[] {
|
||||
return items.value.map(item => ({
|
||||
warehouseId: item.warehouseId,
|
||||
productId: item.productId,
|
||||
productPrice: item.productPrice,
|
||||
count: item.count,
|
||||
remark: item.remark,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:提交扫码其它出库单。
|
||||
* 适用场景:扫码录入完成后生成正式其它出库单。
|
||||
* @return 无
|
||||
* 注意事项:前端库存校验只做即时提醒,最终正确性以后端校验结果为准。
|
||||
*/
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (items.value.length === 0) {
|
||||
toast.error('请先扫描出库明细')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: StockOut = {
|
||||
customerId: selectedCustomerId.value,
|
||||
outTime: new Date().toISOString(),
|
||||
remark: remark.value,
|
||||
items: buildSubmitItems(),
|
||||
}
|
||||
|
||||
await createStockOut(payload)
|
||||
toast.success('出库成功')
|
||||
clearItems()
|
||||
remark.value = ''
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能说明:初始化扫码其它出库页。
|
||||
* 适用场景:页面首次挂载时准备基础数据。
|
||||
* @return 无
|
||||
* 注意事项:初始化失败时由请求封装统一提示,这里不额外重复 toast。
|
||||
*/
|
||||
onMounted(async () => {
|
||||
await loadInitialData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.scan-page {
|
||||
background: #f4f6f8;
|
||||
|
||||
&__content {
|
||||
padding: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-section {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #fff;
|
||||
|
||||
&__title {
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2329;
|
||||
}
|
||||
}
|
||||
|
||||
.scan-field {
|
||||
height: 84rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 0 24rpx;
|
||||
line-height: 84rpx;
|
||||
border: 2rpx solid #e5e6eb;
|
||||
border-radius: 12rpx;
|
||||
background: #f7f8fa;
|
||||
color: #1f2329;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user