You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
besure_web/src/views/erp/stock/move/components/StockMoveItemForm.vue

820 lines
25 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
v-loading="formLoading"
label-width="0px"
:inline-message="true"
:disabled="disabled"
>
<el-table :data="formData" show-summary :summary-method="getSummaries" class="-mt-10px">
<el-table-column label="序号" type="index" align="center" width="60" />
<el-table-column :label="productColumnLabel" min-width="180">
<template #default="{ row, $index }">
<el-form-item :prop="`${$index}.productId`" :rules="formRules.productId" class="mb-0px!">
<el-input
:model-value="getProductDisplayName(row)"
readonly
:clearable="!disabled"
:placeholder="productPlaceholder"
@click="handleProductInputClick(row)"
@clear="handleProductClear(row)"
/>
</el-form-item>
</template>
</el-table-column>
<el-table-column label="调出仓库" min-width="125">
<template #default="{ row, $index }">
<el-form-item
:prop="`${$index}.fromWarehouseId`"
:rules="formRules.fromWarehouseId"
class="mb-0px!"
>
<el-select
v-model="row.fromWarehouseId"
clearable
filterable
:disabled="!row.productId"
placeholder="请选择调出仓库"
@change="onChangeFromWarehouse($event, row)"
>
<el-option
v-for="item in getFromWarehouseOptions(row)"
:key="getStockWarehouseId(item)"
:label="getStockWarehouseName(item)"
:value="getStockWarehouseId(item)"
/>
</el-select>
</el-form-item>
</template>
</el-table-column>
<el-table-column label="调出库区" min-width="125">
<template #default="{ row, $index }">
<el-form-item
:prop="`${$index}.fromAreaId`"
:rules="formRules.fromAreaId"
class="mb-0px!"
>
<el-select
v-model="row.fromAreaId"
clearable
filterable
:disabled="!row.fromWarehouseId"
placeholder="请选择调出库区"
@change="handleFromAreaChange(row)"
>
<el-option
v-for="item in getFromAreaOptions(row)"
:key="getStockAreaId(item)"
:label="getAreaLabel(item)"
:value="getStockAreaId(item)"
/>
</el-select>
</el-form-item>
</template>
</el-table-column>
<el-table-column label="编码" min-width="150">
<template #default="{ row }">
<el-form-item class="mb-0px!">
<el-input disabled v-model="row.productBarCode" />
</el-form-item>
</template>
</el-table-column>
<el-table-column label="库存" min-width="100">
<template #default="{ row }">
<el-form-item class="mb-0px!">
<el-input disabled v-model="row.stockCount" :formatter="erpCountInputFormatter" />
</el-form-item>
</template>
</el-table-column>
<el-table-column label="单位" min-width="80">
<template #default="{ row }">
<el-form-item class="mb-0px!">
<el-input disabled :model-value="getRowUnitName(row)" />
</el-form-item>
</template>
</el-table-column>
<el-table-column label="调入仓库" min-width="125">
<template #default="{ row, $index }">
<el-form-item
:prop="`${$index}.toWarehouseId`"
:rules="formRules.toWarehouseId"
class="mb-0px!"
>
<el-select
v-model="row.toWarehouseId"
clearable
filterable
:disabled="!row.productId"
placeholder="请选择调入仓库"
@change="onChangeToWarehouse(row)"
>
<el-option
v-for="item in getToWarehouseOptions(row)"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</template>
</el-table-column>
<el-table-column label="调入库区" min-width="125">
<template #default="{ row, $index }">
<el-form-item :prop="`${$index}.toAreaId`" :rules="formRules.toAreaId" class="mb-0px!">
<el-select
v-model="row.toAreaId"
clearable
filterable
:disabled="!row.toWarehouseId"
placeholder="请选择调入库区"
@change="handleToAreaChange(row)"
>
<el-option
v-for="item in getToAreaOptions(row)"
:key="item.id"
:label="getAreaLabel(item)"
:value="item.id"
/>
</el-select>
</el-form-item>
</template>
</el-table-column>
<el-table-column label="数量" prop="count" fixed="right" min-width="140">
<template #default="{ row, $index }">
<el-form-item :prop="`${$index}.count`" :rules="formRules.count" class="mb-0px!">
<el-input-number
v-model="row.count"
controls-position="right"
:min="0.001"
:precision="3"
class="!w-100%"
@change="handleCountChange(row)"
/>
</el-form-item>
</template>
</el-table-column>
<el-table-column label="备注" min-width="150">
<template #default="{ row, $index }">
<el-form-item :prop="`${$index}.remark`" class="mb-0px!">
<el-input v-model="row.remark" placeholder="请输入备注" />
</el-form-item>
</template>
</el-table-column>
<el-table-column v-if="!disabled" align="center" fixed="right" label="操作" width="60">
<template #default="{ $index }">
<el-button type="danger" @click="handleDelete($index)" link>
<Icon icon="ep:delete" />
</el-button>
</template>
</el-table-column>
</el-table>
</el-form>
<el-row justify="center" class="mt-3" v-if="!disabled">
<el-button @click="handleAdd" round>+ 添加调拨产品</el-button>
</el-row>
<Dialog
:title="productSelectTitle"
v-model="productDialogVisible"
width="900px"
class="stock-move-product-dialog"
>
<el-form :model="productQueryParams" :inline="true" class="-mb-15px">
<el-form-item :label="t('ErpStock.Item.code')">
<el-input
v-model="productQueryParams.barCode"
clearable
:placeholder="t('ErpStock.Item.placeholderCode')"
@keyup.enter="handleProductDialogQuery"
/>
</el-form-item>
<el-form-item :label="t('ErpStock.Item.name')">
<el-input
v-model="productQueryParams.name"
clearable
:placeholder="t('ErpStock.Item.placeholderName')"
@keyup.enter="handleProductDialogQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleProductDialogQuery">
<Icon icon="ep:search" class="mr-5px" /> {{ t('common.query') }}
</el-button>
</el-form-item>
</el-form>
<el-table
v-loading="productDialogLoading"
:data="productDialogList"
:row-key="getProductOptionRowKey"
:stripe="true"
:show-overflow-tooltip="true"
@row-click="handleProductDialogRowClick"
>
<el-table-column width="55" align="center">
<template #default="{ row }">
<el-radio v-model="selectedProductId" :label="getProductOptionId(row)">&nbsp;</el-radio>
</template>
</el-table-column>
<el-table-column :label="t('ErpStock.Item.code')" prop="barCode" min-width="160" />
<el-table-column :label="productColumnLabel" prop="name" min-width="160" />
<el-table-column :label="t('ErpStock.Item.spec')" prop="standard" min-width="120" />
<el-table-column
:label="t('ErpStock.Item.category')"
prop="subCategoryName"
min-width="120"
/>
<el-table-column
:label="t('ErpStock.Stock.stockDisplay')"
prop="stockDisplay"
min-width="120"
/>
<el-table-column :label="t('ErpStock.Item.unit')" prop="unitName" min-width="80" />
</el-table>
<div class="product-dialog-pagination">
<Pagination
:total="productDialogTotal"
v-model:page="productQueryParams.pageNo"
v-model:limit="productQueryParams.pageSize"
@pagination="getProductDialogList"
/>
</div>
<template #footer>
<el-button type="primary" :disabled="!selectedProductId" @click="confirmProductSelect">
{{ t('common.ok') }}
</el-button>
<el-button @click="productDialogVisible = false">{{ t('common.cancel') }}</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ProductApi } from '@/api/erp/product/product'
import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse'
import { StockMoveApi } from '@/api/erp/stock/move'
import { StockApi } from '@/api/erp/stock/stock'
import { erpCountInputFormatter, getSumValue } from '@/utils'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
const { t } = useI18n()
const props = defineProps<{
items?: any[]
categoryType?: number
disabled?: boolean
}>()
const formLoading = ref(false)
const formData = ref<any[]>([])
const sameAreaMessage = '调出库区和调入库区不能相同'
const formRules = reactive({
inId: [{ required: true, message: '调度编号不能为空', trigger: 'submit' }],
fromWarehouseId: [
{
required: true,
message: t('ErpStock.Move.item.validatorFromWarehouseRequired'),
trigger: 'submit'
}
],
fromAreaId: [
{ required: true, message: t('ErpStock.Item.validatorAreaRequired'), trigger: 'submit' },
{ validator: validateDifferentArea, trigger: ['change', 'submit'] }
],
toWarehouseId: [
{
required: true,
message: t('ErpStock.Move.item.validatorToWarehouseRequired'),
trigger: 'submit'
}
],
toAreaId: [
{ required: true, message: t('ErpStock.Item.validatorAreaRequired'), trigger: 'submit' },
{ validator: validateDifferentArea, trigger: ['change', 'submit'] }
],
productId: [
{ required: true, message: t('ErpStock.Item.validatorProductRequired'), trigger: 'submit' }
],
count: [
{ required: true, message: t('ErpStock.Item.validatorCountRequired'), trigger: 'submit' },
{ validator: validateStockMoveCount, trigger: ['change', 'submit'] }
]
})
const formRef = ref()
const warehouseList = ref<WarehouseVO[]>([])
const productList = ref<any[]>([])
const currentSelectRow = ref<any>()
const productDialogVisible = ref(false)
const productDialogLoading = ref(false)
const productDialogList = ref<any[]>([])
const productDialogTotal = ref(0)
const selectedProductId = ref<number | string>()
const productQueryParams = reactive({
pageNo: 1,
pageSize: 10,
categoryType: undefined as number | undefined,
stockNotZero: true,
barCode: undefined as string | undefined,
name: undefined as string | undefined
})
const categoryTypeLabel = computed(() => {
const option = getIntDictOptions(DICT_TYPE.MATERIAL_CLASSIFICATION_TYPE).find(
(item) => Number(item.value) === Number(props.categoryType)
)
return option?.label || t('ErpStock.Item.productName')
})
const productColumnLabel = computed(() => categoryTypeLabel.value)
const productPlaceholder = computed(() => `请选择${productColumnLabel.value}`)
const productSelectTitle = computed(() => `选择${productColumnLabel.value}`)
const getRowUnitName = (row) => row.productUnitName || '-'
watch(
() => props.items,
(val) => {
formData.value = val || []
ensureDefaultRow()
},
{ immediate: true }
)
watch(
() => props.categoryType,
async (val, oldVal) => {
await loadOptions()
if (oldVal !== undefined && val !== oldVal) {
formData.value.forEach(clearRowSelection)
}
ensureDefaultRow()
},
{ immediate: true }
)
function getSummaries(param: SummaryMethodProps) {
const { columns, data } = param
const sums: string[] = []
columns.forEach((column, index) => {
if (index === 0) {
sums[index] = t('common.total')
return
}
if (column.property === 'count') {
sums[index] = erpCountInputFormatter(
getSumValue(data.map((item) => Number(item[column.property])))
)
} else {
sums[index] = ''
}
})
return sums
}
function ensureDefaultRow() {
if (!props.disabled && formData.value.length === 0) {
handleAdd()
}
}
function handleAdd() {
formData.value.push({
id: undefined,
fromWarehouseId: undefined,
fromAreaId: undefined,
toWarehouseId: undefined,
toAreaId: undefined,
toAreaList: [],
productId: undefined,
productName: undefined,
productUnitName: undefined,
purchaseUnitName: undefined,
unitName: undefined,
productBarCode: undefined,
stockCount: undefined,
productStockList: [],
count: 1,
remark: undefined
})
}
function handleDelete(index) {
formData.value.splice(index, 1)
}
function isSameMoveArea(row) {
return (
row.fromAreaId !== undefined &&
row.toAreaId !== undefined &&
String(row.fromAreaId) === String(row.toAreaId)
)
}
function validateDifferentArea(rule, _value, callback) {
const rowIndex = Number(String(rule.field || '').split('.')[0])
const row = formData.value[rowIndex]
if (row && isSameMoveArea(row)) {
callback(new Error(sameAreaMessage))
return
}
callback()
}
function getNumberValue(value) {
if (value === undefined || value === null || value === '') return undefined
const numberValue = Number(String(value).replace(/,/g, ''))
return Number.isFinite(numberValue) ? numberValue : undefined
}
function getCountExceededMessage(row) {
const stockCount = getNumberValue(row.stockCount)
return stockCount === undefined
? '调拨数量不能大于库存'
: `调拨数量不能大于库存(当前库存:${erpCountInputFormatter(stockCount)}`
}
function isCountExceeded(row) {
const count = getNumberValue(row.count)
const stockCount = getNumberValue(row.stockCount)
return count !== undefined && stockCount !== undefined && count > stockCount
}
function validateStockMoveCount(rule, _value, callback) {
const rowIndex = Number(String(rule.field || '').split('.')[0])
const row = formData.value[rowIndex]
if (row && isCountExceeded(row)) {
callback(new Error(getCountExceededMessage(row)))
return
}
callback()
}
function warnIfCountExceeded(row) {
if (isCountExceeded(row)) {
ElMessage.warning(getCountExceededMessage(row))
}
}
function validateCountField(row) {
const rowIndex = formData.value.indexOf(row)
if (rowIndex < 0) return
formRef.value?.validateField(`${rowIndex}.count`).catch(() => undefined)
}
function handleCountChange(row) {
warnIfCountExceeded(row)
validateCountField(row)
}
function warnIfSameArea(row) {
if (isSameMoveArea(row)) {
ElMessage.warning(sameAreaMessage)
}
}
function validateAreaFields(row) {
const rowIndex = formData.value.indexOf(row)
if (rowIndex < 0) return
formRef.value
?.validateField([`${rowIndex}.fromAreaId`, `${rowIndex}.toAreaId`])
.catch(() => undefined)
}
async function handleFromAreaChange(row) {
await setStockCount(row)
warnIfSameArea(row)
validateAreaFields(row)
warnIfCountExceeded(row)
validateCountField(row)
}
function handleToAreaChange(row) {
warnIfSameArea(row)
validateAreaFields(row)
}
async function onChangeFromWarehouse(_warehouseId, row) {
const firstStock = getFromAreaOptions(row)[0]
row.fromAreaId = firstStock ? getStockAreaId(firstStock) : undefined
await setStockCount(row)
warnIfSameArea(row)
validateAreaFields(row)
warnIfCountExceeded(row)
validateCountField(row)
}
async function onChangeToWarehouse(row) {
row.toAreaId = undefined
row.toAreaList = []
if (!row.toWarehouseId) return
const warehouse = await WarehouseApi.getWarehouse(row.toWarehouseId)
row.toAreaList = warehouse?.areaList || []
}
function handleProductInputClick(row) {
if (props.disabled) return
openProductSelectDialog(row)
}
function handleProductClear(row) {
if (props.disabled) return
clearRowSelection(row)
}
function getProductDisplayName(row) {
return row.productName || productList.value.find((item) => item.id === row.productId)?.name || ''
}
async function openProductSelectDialog(row) {
if (!props.categoryType) return
currentSelectRow.value = row
selectedProductId.value = row.productId
productQueryParams.pageNo = 1
productQueryParams.categoryType = props.categoryType
productQueryParams.barCode = undefined
productQueryParams.name = undefined
productDialogVisible.value = true
await getProductDialogList()
}
async function getProductDialogList() {
productDialogLoading.value = true
try {
productQueryParams.categoryType = props.categoryType
productQueryParams.stockNotZero = true
const data = await StockApi.getStockPage(productQueryParams)
productDialogList.value = normalizeStockProductOptions(data?.list || [])
productDialogTotal.value = data?.total || 0
} finally {
productDialogLoading.value = false
}
}
function handleProductDialogQuery() {
productQueryParams.pageNo = 1
getProductDialogList()
}
function handleProductDialogRowClick(row: any) {
selectedProductId.value = getProductOptionId(row)
}
async function confirmProductSelect() {
if (props.disabled) return
const product = productDialogList.value.find(
(item) => String(getProductOptionId(item)) === String(selectedProductId.value)
)
if (!product || !currentSelectRow.value) return
fillRowByProduct(currentSelectRow.value, product)
await loadProductStockList(currentSelectRow.value)
productDialogVisible.value = false
}
function fillRowByProduct(row, product) {
row.productId = getProductOptionId(product)
row.productName = product.name || product.productName
row.productUnitName = product.unitName || product.purchaseUnitName
row.purchaseUnitName = product.purchaseUnitName
row.unitName = product.unitName || product.purchaseUnitName
row.productBarCode = product.barCode
row.fromWarehouseId = undefined
row.fromAreaId = undefined
row.toWarehouseId = undefined
row.toAreaId = undefined
row.toAreaList = []
row.stockCount = undefined
row.productStockList = []
}
function clearRowSelection(row) {
row.productId = undefined
row.productName = undefined
row.productUnitName = undefined
row.purchaseUnitName = undefined
row.unitName = undefined
row.productBarCode = undefined
row.stockCount = undefined
row.productStockList = []
row.fromWarehouseId = undefined
row.fromAreaId = undefined
row.toWarehouseId = undefined
row.toAreaId = undefined
row.toAreaList = []
}
async function loadOptions() {
const params = props.categoryType ? { categoryType: props.categoryType } : undefined
formLoading.value = true
try {
const [products, warehouses] = await Promise.all([
ProductApi.getProductSimpleList(params),
WarehouseApi.getWarehouseSimpleList(params)
])
productList.value = products || []
warehouseList.value = warehouses || []
} finally {
formLoading.value = false
}
}
function getProductOptionId(item) {
return item?.productId ?? item?.id
}
function getProductOptionRowKey(row) {
return [getProductOptionId(row), row.warehouseId, row.areaId, row.stockId || row.id]
.filter((item) => item !== undefined && item !== null && item !== '')
.join('-')
}
function normalizeStockProductOptions(list) {
const map = new Map()
;(list || []).forEach((item) => {
const productId = getProductOptionId(item)
if (!productId || map.has(String(productId))) return
map.set(String(productId), {
...item,
id: productId,
productId,
name: item.name || item.productName,
unitName: item.unitName || item.purchaseUnitName,
subCategoryName: item.subCategoryName || item.categoryName,
stockDisplay: item.stockDisplay || item.totalBaseCount || item.count || item.stockCount
})
})
return Array.from(map.values())
}
function normalizeProductStockList(data) {
if (Array.isArray(data)) return data
if (Array.isArray(data?.list)) return data.list
return []
}
async function loadProductStockList(row) {
if (!row.productId) {
row.productStockList = []
return
}
const data = await StockMoveApi.getProductStockList(row.productId)
row.productStockList = normalizeProductStockList(data)
await fillProductStockAreaInfo(row.productStockList)
const firstStock = row.productStockList[0]
row.fromWarehouseId = firstStock ? getStockWarehouseId(firstStock) : undefined
row.fromAreaId = firstStock ? getStockAreaId(firstStock) : undefined
row.stockCount = firstStock ? getStockCount(firstStock) : undefined
warnIfCountExceeded(row)
validateCountField(row)
}
async function fillProductStockAreaInfo(stockList) {
const warehouseIds = Array.from(
new Set((stockList || []).map((item) => getStockWarehouseId(item)).filter(Boolean))
)
await Promise.all(
warehouseIds.map(async (warehouseId) => {
const warehouse = await WarehouseApi.getWarehouse(warehouseId)
const areaList = warehouse?.areaList || []
stockList
.filter((item) => String(getStockWarehouseId(item)) === String(warehouseId))
.forEach((item) => {
const area = areaList.find(
(areaItem) => String(areaItem.id) === String(getStockAreaId(item))
)
if (!area) return
item.areaCode = area.areaCode
item.areaName = area.areaName
})
})
)
}
function appendOptionIfMissing(options, id, option, getId) {
if (id === undefined || id === null || !option) return options
const exists = options.some((item) => String(getId(item)) === String(id))
return exists ? options : [option, ...options]
}
function getFromWarehouseOptions(row) {
const stockList = row.productStockList || []
const warehouseMap = new Map()
stockList.forEach((item) => {
const warehouseId = getStockWarehouseId(item)
if (warehouseId !== undefined && !warehouseMap.has(warehouseId)) {
warehouseMap.set(warehouseId, item)
}
})
return appendOptionIfMissing(
Array.from(warehouseMap.values()),
row.fromWarehouseId,
row.fromWarehouseName
? { warehouseId: row.fromWarehouseId, warehouseName: row.fromWarehouseName }
: undefined,
getStockWarehouseId
)
}
function getFromAreaOptions(row) {
if (!row.fromWarehouseId) return []
const areaOptions = (row.productStockList || []).filter(
(item) => String(getStockWarehouseId(item)) === String(row.fromWarehouseId)
)
return appendOptionIfMissing(
areaOptions,
row.fromAreaId,
row.fromAreaName ? { areaId: row.fromAreaId, areaName: row.fromAreaName } : undefined,
getStockAreaId
)
}
function getStockWarehouseId(stock) {
return stock.warehouseId ?? stock.fromWarehouseId ?? stock.id
}
function getStockWarehouseName(stock) {
return stock.warehouseName || stock.fromWarehouseName || stock.name || getStockWarehouseId(stock)
}
function getStockAreaId(stock) {
return stock.areaId ?? stock.fromAreaId ?? stock.id
}
function getStockCount(stock) {
return stock.stockCount ?? stock.count
}
function getToWarehouseOptions(row) {
return appendOptionIfMissing(
warehouseList.value,
row.toWarehouseId,
row.toWarehouseName ? { id: row.toWarehouseId, name: row.toWarehouseName } : undefined,
(item) => item.id
)
}
function getToAreaOptions(row) {
const areaOptions =
Array.isArray(row.toAreaList) && row.toAreaList.length > 0
? row.toAreaList
: warehouseList.value.find((item) => String(item.id) === String(row.toWarehouseId))
?.areaList || []
return appendOptionIfMissing(
areaOptions,
row.toAreaId,
row.toAreaName ? { id: row.toAreaId, areaName: row.toAreaName } : undefined,
(item) => item.id
)
}
function getAreaLabel(item) {
const areaCode = item.areaCode || item.code
const areaName = item.areaName || item.fromAreaName || item.toAreaName || item.name
return areaCode ? `${areaCode} - ${areaName || ''}` : areaName
}
async function setStockCount(row) {
if (!row.productId || !row.fromWarehouseId || !row.fromAreaId) {
row.stockCount = undefined
return
}
if (!Array.isArray(row.productStockList) || row.productStockList.length === 0) {
await loadProductStockList(row)
}
const stock = row.productStockList.find(
(item) =>
String(getStockWarehouseId(item)) === String(row.fromWarehouseId) &&
String(getStockAreaId(item)) === String(row.fromAreaId)
)
row.stockCount = stock ? getStockCount(stock) : 0
}
function validate() {
const exceededRow = formData.value.find(isCountExceeded)
if (exceededRow) {
ElMessage.warning(getCountExceededMessage(exceededRow))
}
return formRef.value.validate()
}
defineExpose({ validate })
</script>
<style scoped>
.stock-move-product-dialog :deep(.el-dialog__body) {
padding-bottom: 8px;
}
.stock-move-product-dialog :deep(.el-dialog__footer) {
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.product-dialog-pagination {
display: flex;
justify-content: flex-end;
width: 100%;
margin-top: 16px;
padding-bottom: 12px;
overflow-x: auto;
}
.product-dialog-pagination :deep(.pagination-container) {
margin: 0;
}
</style>