袁伟杰: V2.0.002 其他入库其他出库扫码

This commit is contained in:
yuan
2026-05-13 19:48:21 +08:00
parent 2c203ff4cf
commit 7f84bbca79
13 changed files with 1686 additions and 6 deletions

View 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>

View 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>