功能:新增托盘业务操作页面
This commit is contained in:
@@ -171,9 +171,91 @@ const masterPageContracts = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const businessPageContracts = {
|
||||||
|
inbound: {
|
||||||
|
relativePath: 'src/views/business/inbound/index.vue',
|
||||||
|
apiNames: [
|
||||||
|
'addInbound',
|
||||||
|
'confirmInbound',
|
||||||
|
'delInbound',
|
||||||
|
'getInbound',
|
||||||
|
'getInboundWarehouseOptions',
|
||||||
|
'listInbound',
|
||||||
|
'scanInboundPallet',
|
||||||
|
'updateInbound'
|
||||||
|
],
|
||||||
|
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'remove'],
|
||||||
|
payloads: {
|
||||||
|
addInbound: ['palletIds', 'warehouseId'],
|
||||||
|
updateInbound: ['inboundId', 'palletIds', 'warehouseId'],
|
||||||
|
confirmInbound: ['idempotencyKey']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filling: {
|
||||||
|
relativePath: 'src/views/business/filling/index.vue',
|
||||||
|
apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'],
|
||||||
|
permissions: ['add', 'list', 'query'],
|
||||||
|
payloads: {
|
||||||
|
addFilling: [
|
||||||
|
'batchNo',
|
||||||
|
'idempotencyKey',
|
||||||
|
'initialPackages',
|
||||||
|
'packageSpec',
|
||||||
|
'qrToken',
|
||||||
|
'strainId',
|
||||||
|
'warehouseId'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shipment: {
|
||||||
|
relativePath: 'src/views/business/shipment/index.vue',
|
||||||
|
apiNames: [
|
||||||
|
'addShipment',
|
||||||
|
'confirmShipment',
|
||||||
|
'delShipment',
|
||||||
|
'getShipment',
|
||||||
|
'getShipmentSiteOptions',
|
||||||
|
'getShipmentWarehouseOptions',
|
||||||
|
'listShipment',
|
||||||
|
'scanShipmentPallet',
|
||||||
|
'updateShipment'
|
||||||
|
],
|
||||||
|
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'remove'],
|
||||||
|
payloads: {
|
||||||
|
addShipment: ['palletIds', 'sourceWarehouseId', 'targetSiteId'],
|
||||||
|
updateShipment: ['palletIds', 'shipmentId', 'sourceWarehouseId', 'targetSiteId'],
|
||||||
|
confirmShipment: ['idempotencyKey']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
relativePath: 'src/views/business/usage/index.vue',
|
||||||
|
apiNames: ['addUsage', 'getUsageLineOptions', 'listUsage', 'scanUsagePallet'],
|
||||||
|
permissions: ['add', 'list', 'query'],
|
||||||
|
payloads: {
|
||||||
|
addUsage: ['idempotencyKey', 'lineId', 'qrToken', 'usedPackages']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
return: {
|
||||||
|
relativePath: 'src/views/business/return/index.vue',
|
||||||
|
apiNames: [
|
||||||
|
'confirmReturn',
|
||||||
|
'getReturn',
|
||||||
|
'getReturnWarehouseOptions',
|
||||||
|
'listReturn',
|
||||||
|
'requestReturn',
|
||||||
|
'scanReturnPallet'
|
||||||
|
],
|
||||||
|
permissions: ['confirm', 'list', 'query', 'request'],
|
||||||
|
payloads: {
|
||||||
|
requestReturn: ['idempotencyKey', 'palletIds', 'sourceSiteId', 'targetWarehouseId'],
|
||||||
|
confirmReturn: ['idempotencyKey', 'scannedPalletIds']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const delimiterPairs = { '(': ')', '[': ']', '{': '}' }
|
const delimiterPairs = { '(': ')', '[': ']', '{': '}' }
|
||||||
|
|
||||||
function sanitizeSource(source) {
|
function sanitizeSource(source, { allowTemplateInterpolation = false } = {}) {
|
||||||
const code = source.split('')
|
const code = source.split('')
|
||||||
const structure = source.split('')
|
const structure = source.split('')
|
||||||
|
|
||||||
@@ -184,7 +266,12 @@ function sanitizeSource(source) {
|
|||||||
while (literalEnd < source.length && source[literalEnd] !== quote) {
|
while (literalEnd < source.length && source[literalEnd] !== quote) {
|
||||||
if (source[literalEnd] === '\\') {
|
if (source[literalEnd] === '\\') {
|
||||||
literalEnd += 1
|
literalEnd += 1
|
||||||
} else if (quote === '`' && source[literalEnd] === '$' && source[literalEnd + 1] === '{') {
|
} else if (
|
||||||
|
!allowTemplateInterpolation &&
|
||||||
|
quote === '`' &&
|
||||||
|
source[literalEnd] === '$' &&
|
||||||
|
source[literalEnd + 1] === '{'
|
||||||
|
) {
|
||||||
throw new Error('契约解析器不支持包含插值的模板字符串')
|
throw new Error('契约解析器不支持包含插值的模板字符串')
|
||||||
}
|
}
|
||||||
literalEnd += 1
|
literalEnd += 1
|
||||||
@@ -370,6 +457,53 @@ function extractCallObjectKeys(source, callName) {
|
|||||||
}).sort()
|
}).sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSecondCallObjectKeys(source, callName) {
|
||||||
|
const escapedCallName = callName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const callMatch = new RegExp(
|
||||||
|
`(?<![\\w$])${escapedCallName}\\s*\\(\\s*[^,]+,\\s*\\{([\\s\\S]*?)\\}\\s*\\)`
|
||||||
|
).exec(source)
|
||||||
|
assert.ok(callMatch, `缺少 ${callName}(id, { ... }) 显式对象调用`)
|
||||||
|
|
||||||
|
const properties = callMatch[1].split(',').map((property) => property.trim()).filter(Boolean)
|
||||||
|
return properties.map((property) => {
|
||||||
|
const keyMatch = /^([A-Za-z_$][\w$]*)\s*:/.exec(property)
|
||||||
|
assert.ok(keyMatch, `${callName} payload 禁止展开、简写或动态属性`)
|
||||||
|
return keyMatch[1]
|
||||||
|
}).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNamedFunction(source, functionName) {
|
||||||
|
const sanitized = sanitizeSource(source, { allowTemplateInterpolation: true })
|
||||||
|
const escapedFunctionName = functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const match = new RegExp(`function\\s+${escapedFunctionName}\\s*\\(`).exec(sanitized.structure)
|
||||||
|
assert.ok(match, `缺少函数 ${functionName}`)
|
||||||
|
const parametersStart = sanitized.structure.indexOf('(', match.index)
|
||||||
|
const parametersEnd = findClosingDelimiter(sanitized.structure, parametersStart)
|
||||||
|
const bodyStart = sanitized.structure.indexOf('{', parametersEnd)
|
||||||
|
assert.ok(bodyStart >= 0, `${functionName} 缺少函数体`)
|
||||||
|
const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart)
|
||||||
|
return source.slice(match.index, bodyEnd + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPromiseCallback(functionSource, methodName, occurrence = 0) {
|
||||||
|
const sanitized = sanitizeSource(functionSource, { allowTemplateInterpolation: true })
|
||||||
|
const marker = `.${methodName}(`
|
||||||
|
let markerIndex = -1
|
||||||
|
for (let index = 0; index <= occurrence; index += 1) {
|
||||||
|
markerIndex = sanitized.structure.indexOf(marker, markerIndex + 1)
|
||||||
|
assert.ok(markerIndex >= 0, `缺少第 ${occurrence + 1} 个 .${methodName}(...)`)
|
||||||
|
}
|
||||||
|
const callStart = markerIndex + marker.length - 1
|
||||||
|
const callEnd = findClosingDelimiter(sanitized.structure, callStart)
|
||||||
|
const arrowIndex = sanitized.structure.indexOf('=>', callStart)
|
||||||
|
assert.ok(arrowIndex > callStart && arrowIndex < callEnd, `.${methodName} 必须传入箭头函数`)
|
||||||
|
const bodyStart = sanitized.structure.indexOf('{', arrowIndex)
|
||||||
|
assert.ok(bodyStart > arrowIndex && bodyStart < callEnd, `.${methodName} 回调必须使用块函数体`)
|
||||||
|
const bodyEnd = findClosingDelimiter(sanitized.structure, bodyStart)
|
||||||
|
assert.ok(bodyEnd < callEnd, `.${methodName} 回调块超出调用范围`)
|
||||||
|
return functionSource.slice(bodyStart + 1, bodyEnd)
|
||||||
|
}
|
||||||
|
|
||||||
function extractButtonBlocks(source, clickHandler) {
|
function extractButtonBlocks(source, clickHandler) {
|
||||||
return [...source.matchAll(/<el-button\b[\s\S]*?<\/el-button>/g)]
|
return [...source.matchAll(/<el-button\b[\s\S]*?<\/el-button>/g)]
|
||||||
.map((match) => match[0])
|
.map((match) => match[0])
|
||||||
@@ -729,6 +863,448 @@ test('六个基础资料页面逐页满足 API、权限、分页和安全删除
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('五个业务页面逐页满足 API、精确权限、列表和交互基础契约', async (t) => {
|
||||||
|
for (const [resource, contract] of Object.entries(businessPageContracts)) {
|
||||||
|
await t.test(contract.relativePath, async () => {
|
||||||
|
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
|
||||||
|
const permissionPrefix = `business:${resource}`
|
||||||
|
const importedBusinessModules = [...source.matchAll(/from\s*['"]@\/api\/business\/([a-z]+)['"]/g)]
|
||||||
|
.map((match) => match[1])
|
||||||
|
|
||||||
|
assert.deepEqual(importedBusinessModules, [resource], `${resource} 页面只能导入本业务 API`)
|
||||||
|
assert.deepEqual(extractImportedNames(source, `@/api/business/${resource}`), contract.apiNames)
|
||||||
|
assert.doesNotMatch(source, /@\/api\/master\//, `${resource} 页面不得借用主数据 API`)
|
||||||
|
assert.doesNotMatch(source, />\s*(?:导入|导出)\s*<\/el-button>/)
|
||||||
|
assert.doesNotMatch(source, /\bhandle(?:Import|Export)\b|proxy\.download\(/)
|
||||||
|
assert.doesNotMatch(source, /BarcodeDetector|getUserMedia|camera/i)
|
||||||
|
assert.doesNotMatch(source, /\.catch\(\(\) => \{\}\)/, `${resource} 页面禁止空 catch`)
|
||||||
|
|
||||||
|
const actualPermissions = [...new Set(
|
||||||
|
[...source.matchAll(new RegExp(`['"](${permissionPrefix}:[a-z]+)['"]`, 'g'))]
|
||||||
|
.map((match) => match[1].slice(permissionPrefix.length + 1))
|
||||||
|
)].sort()
|
||||||
|
assert.deepEqual(actualPermissions, contract.permissions, `${resource} 页面权限集合不精确`)
|
||||||
|
assertButtonsUsePermission(source, 'handleQuery', `${permissionPrefix}:list`)
|
||||||
|
assertButtonsUsePermission(source, 'resetQuery', `${permissionPrefix}:list`)
|
||||||
|
|
||||||
|
assert.match(source, /function handleQuery\(\)\s*{[\s\S]*?queryParams\.value\.pageNum\s*=\s*1[\s\S]*?getList\(\)/)
|
||||||
|
assert.match(source, /function resetQuery\(\)\s*{[\s\S]*?resetForm\(['"]queryRef['"]\)[\s\S]*?handleQuery\(\)/)
|
||||||
|
assert.match(source, /<pagination\b[\s\S]*?v-model:page="queryParams\.pageNum"[\s\S]*?v-model:limit="queryParams\.pageSize"[\s\S]*?@pagination="getList"/)
|
||||||
|
assert.match(source, /<el-table\b[^>]*v-loading="loading"[^>]*empty-text="暂无数据"/)
|
||||||
|
assert.match(source, /<el-alert\b[^>]*v-if="loadError"/)
|
||||||
|
assert.match(source, /\.value\s*=\s*response\.rows/)
|
||||||
|
assert.match(source, /total\.value\s*=\s*response\.total/)
|
||||||
|
|
||||||
|
assert.match(source, /let listGeneration = 0;?/)
|
||||||
|
const listSource = extractNamedFunction(source, 'getList')
|
||||||
|
assert.match(listSource, /const listContext = \{\s*generation: \+\+listGeneration,\s*query: \{ \.\.\.queryParams\.value \}\s*};/)
|
||||||
|
assert.match(listSource, /if \(!isCurrentListRequest\(listContext\)\) \{\s*return;\s*\}/)
|
||||||
|
assert.match(listSource, /\.catch\(\(\) => \{[\s\S]*?loadError\.value\s*=/)
|
||||||
|
assert.match(listSource, /\.finally\(\(\) => \{[\s\S]*?loading\.value = false/)
|
||||||
|
|
||||||
|
assert.match(source, /import PalletScanInput from ['"]@\/components\/PalletScanInput['"]/)
|
||||||
|
const scanTags = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)].map((match) => match[0])
|
||||||
|
assert.ok(scanTags.length > 0, `${resource} 页面必须复用 PalletScanInput`)
|
||||||
|
for (const scanTag of scanTags) {
|
||||||
|
assert.match(scanTag, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用扫码`)
|
||||||
|
assert.match(scanTag, /:loading="[^"]*scanLoading[^"]*"/, `${resource} 扫码请求必须显示 loading`)
|
||||||
|
}
|
||||||
|
assert.match(source, /const operationPending = computed\(\(\) =>/)
|
||||||
|
assert.match(source, /let optionGeneration = 0;?/)
|
||||||
|
assert.match(source, /function invalidateOptionRequest\(\)/)
|
||||||
|
assert.match(source, /function isCurrentOptionRequest\(/)
|
||||||
|
if (resource === 'return') {
|
||||||
|
assert.match(source, /let requestScanGeneration = 0;?/)
|
||||||
|
assert.match(source, /let confirmScanGeneration = 0;?/)
|
||||||
|
assert.match(source, /function invalidateRequestScan\(\)/)
|
||||||
|
assert.match(source, /function invalidateConfirmScan\(\)/)
|
||||||
|
assert.match(source, /function isCurrentRequestScan\(/)
|
||||||
|
assert.match(source, /function isCurrentConfirmScan\(/)
|
||||||
|
} else {
|
||||||
|
assert.match(source, /let scanGeneration = 0;?/)
|
||||||
|
assert.match(source, /function invalidateScanRequest\(\)/)
|
||||||
|
assert.match(source, /function isCurrentScanRequest\(/)
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessControls = [...source.matchAll(/<(?:el-input|el-input-number|el-select)\b[^>]*class="business-control"[^>]*>/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
assert.ok(businessControls.length > 0, `${resource} 页面缺少业务输入控件`)
|
||||||
|
for (const control of businessControls) {
|
||||||
|
assert.match(control, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务输入`)
|
||||||
|
}
|
||||||
|
const businessActions = [...source.matchAll(/<el-button\b[^>]*class="business-action"[^>]*>/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
assert.ok(businessActions.length > 0, `${resource} 页面缺少业务提交按钮`)
|
||||||
|
for (const action of businessActions) {
|
||||||
|
assert.match(action, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务按钮`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [callName, payloadKeys] of Object.entries(contract.payloads)) {
|
||||||
|
const actualKeys = callName.startsWith('confirm')
|
||||||
|
? extractSecondCallObjectKeys(source, callName)
|
||||||
|
: extractCallObjectKeys(source, callName)
|
||||||
|
assert.deepEqual(actualKeys, payloadKeys, `${callName} payload 必须最小且精确`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptTail = source.slice(source.lastIndexOf('getList();'))
|
||||||
|
assert.doesNotMatch(
|
||||||
|
scriptTail,
|
||||||
|
/get(?:InboundWarehouseOptions|FillingStrainOptions|ShipmentWarehouseOptions|ShipmentSiteOptions|UsageLineOptions|ReturnWarehouseOptions)\(/,
|
||||||
|
`${resource} 页面不得在启动阶段无条件请求 query 权限接口`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('入库页面只允许 DRAFT 变更并以 authoritative 详情和独立确认幂等键工作', async () => {
|
||||||
|
const source = await readFile(path.join(frontendRoot, businessPageContracts.inbound.relativePath), 'utf8')
|
||||||
|
|
||||||
|
assertButtonsUsePermission(source, 'handleAdd', 'business:inbound:add')
|
||||||
|
assertButtonsUsePermission(source, 'submitInbound', 'business:inbound:add')
|
||||||
|
assertButtonsUsePermission(source, 'handleUpdate', 'business:inbound:edit')
|
||||||
|
assertButtonsUsePermission(source, 'handleDelete', 'business:inbound:remove')
|
||||||
|
assertButtonsUsePermission(source, 'handleConfirm', 'business:inbound:confirm')
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:add'\]\)/)
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:inbound:query', 'business:inbound:edit'\]\)/)
|
||||||
|
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/)
|
||||||
|
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleDelete\(scope\.row\)/)
|
||||||
|
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/)
|
||||||
|
for (const functionName of ['handleUpdate', 'handleDelete', 'handleConfirm']) {
|
||||||
|
assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(source, /const editDetailReady = ref\(false\)/)
|
||||||
|
const updateSource = extractNamedFunction(source, 'handleUpdate')
|
||||||
|
const detailRequestIndex = updateSource.indexOf('getInbound(')
|
||||||
|
assert.ok(detailRequestIndex >= 0, '编辑入库草稿必须读取 authoritative 详情')
|
||||||
|
for (const [snippet, message] of [
|
||||||
|
['form.value.inboundId = row.inboundId', '读取详情前必须固定入库编辑身份'],
|
||||||
|
['editDetailReady.value = false', '读取详情前必须关闭入库编辑就绪门禁']
|
||||||
|
]) {
|
||||||
|
const index = updateSource.indexOf(snippet)
|
||||||
|
assert.ok(index >= 0 && index < detailRequestIndex, message)
|
||||||
|
}
|
||||||
|
const detailSuccessSource = extractPromiseCallback(updateSource, 'then')
|
||||||
|
assert.ok(
|
||||||
|
detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'),
|
||||||
|
'仅 authoritative DRAFT 入库详情可开启编辑就绪门禁'
|
||||||
|
)
|
||||||
|
const warehouseSelect = source.match(/<el-select\b[\s\S]*?v-model="form\.warehouseId"[\s\S]*?<\/el-select>/)?.[0]
|
||||||
|
assert.ok(warehouseSelect, '缺少入库仓库选择器')
|
||||||
|
assert.match(warehouseSelect, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
const inboundScanTag = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
.find((tag) => tag.includes('@scan="handleInboundScan"'))
|
||||||
|
assert.ok(inboundScanTag, '缺少入库扫码控件')
|
||||||
|
assert.match(inboundScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
const inboundRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0]
|
||||||
|
assert.match(inboundRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
const inboundEditButton = extractButtonBlocks(source, 'saveInboundUpdate')[0]
|
||||||
|
assert.match(inboundEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
const submitSource = extractNamedFunction(source, 'submitInboundForm')
|
||||||
|
assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/)
|
||||||
|
assert.ok(
|
||||||
|
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
|
||||||
|
'入库提交必须在异步校验前加锁'
|
||||||
|
)
|
||||||
|
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
|
||||||
|
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
|
||||||
|
const deleteSource = extractNamedFunction(source, 'handleDelete')
|
||||||
|
assert.ok(
|
||||||
|
deleteSource.indexOf('deleteLoading.value = true') < deleteSource.indexOf('proxy.$modal.confirm('),
|
||||||
|
'入库删除必须在打开二次确认框前加锁'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(extractSecondCallObjectKeys(source, 'scanInboundPallet'), ['warehouseId'])
|
||||||
|
const scanSource = extractNamedFunction(source, 'handleInboundScan')
|
||||||
|
assert.match(scanSource, /pallet\.flowState !== ['"]UNSTOCKED['"]/)
|
||||||
|
assert.match(scanSource, /scannedPallets\.value\.some\(\(item\) => item\.palletId === pallet\.palletId\)/)
|
||||||
|
assert.match(source, /const detail = response\.data;/)
|
||||||
|
assert.match(source, /\(Array\.isArray\(detail\.items\) \? detail\.items : \[\]\)\.map\(\(item\) => \(\{[\s\S]*?palletId: item\.palletId/)
|
||||||
|
assert.match(source, /item\.palletCode \|\| `#\$\{item\.palletId\}`/)
|
||||||
|
|
||||||
|
const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey')
|
||||||
|
assert.match(ensureSource, /createIdempotencyKey\(\)/)
|
||||||
|
assert.match(ensureSource, /confirmIdempotency\.key/)
|
||||||
|
const confirmSource = extractNamedFunction(source, 'handleConfirm')
|
||||||
|
assert.ok(
|
||||||
|
confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('),
|
||||||
|
'入库确认必须在打开二次确认框前加锁'
|
||||||
|
)
|
||||||
|
assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmInbound('))
|
||||||
|
assert.match(confirmSource, /confirmInbound\([^,]+,\s*\{\s*idempotencyKey:/)
|
||||||
|
assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/)
|
||||||
|
const confirmCatch = extractPromiseCallback(confirmSource, 'catch')
|
||||||
|
assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/)
|
||||||
|
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('装填页面锁定扫码上下文、校验正整数并在失败重试时保留幂等键', async () => {
|
||||||
|
const source = await readFile(path.join(frontendRoot, businessPageContracts.filling.relativePath), 'utf8')
|
||||||
|
|
||||||
|
assertButtonsUsePermission(source, 'handleOpenFilling', 'business:filling:add')
|
||||||
|
assertButtonsUsePermission(source, 'submitFilling', 'business:filling:add')
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:filling:query', 'business:filling:add'\]\)/)
|
||||||
|
assert.match(source, /validator:\s*validatePositiveInteger/)
|
||||||
|
assert.match(source, /pallet\.warehouseId/)
|
||||||
|
assert.match(source, /pallet\.palletCode \|\| `#\$\{pallet\.palletId\}`/)
|
||||||
|
assert.match(source, /getFillingStrainOptions\(\)/)
|
||||||
|
|
||||||
|
const scanSource = extractNamedFunction(source, 'handleFillingScan')
|
||||||
|
const scanCallIndex = scanSource.indexOf('scanFillingPallet(')
|
||||||
|
assert.ok(scanCallIndex >= 0, '装填扫码必须调用 scanFillingPallet')
|
||||||
|
for (const [snippet, message] of [
|
||||||
|
['clearFillingIdempotencyKey()', '新装填扫码前必须清除旧幂等键'],
|
||||||
|
['scannedPallet.value = null', '新装填扫码前必须清除旧托盘'],
|
||||||
|
["form.value.qrToken = ''", '新装填扫码前必须清除旧二维码令牌'],
|
||||||
|
['form.value.warehouseId = undefined', '新装填扫码前必须清除旧仓库']
|
||||||
|
]) {
|
||||||
|
const index = scanSource.indexOf(snippet)
|
||||||
|
assert.ok(index >= 0 && index < scanCallIndex, message)
|
||||||
|
}
|
||||||
|
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
|
||||||
|
assert.match(invalidSource, /clearFillingIdempotencyKey\(\)/)
|
||||||
|
assert.match(invalidSource, /scannedPallet\.value = null/)
|
||||||
|
assert.match(invalidSource, /form\.value\.qrToken = ''/)
|
||||||
|
assert.match(invalidSource, /form\.value\.warehouseId = undefined/)
|
||||||
|
|
||||||
|
const ensureSource = extractNamedFunction(source, 'ensureFillingIdempotencyKey')
|
||||||
|
assert.match(ensureSource, /if \(!fillingIdempotencyKey\.value\)/)
|
||||||
|
assert.match(ensureSource, /createIdempotencyKey\(\)/)
|
||||||
|
const submitSource = extractNamedFunction(source, 'submitFilling')
|
||||||
|
assert.ok(
|
||||||
|
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
|
||||||
|
'装填提交必须在异步校验前加锁'
|
||||||
|
)
|
||||||
|
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
|
||||||
|
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
|
||||||
|
assert.match(submitSource, /const idempotencyKey = ensureFillingIdempotencyKey\(\)/)
|
||||||
|
assert.match(extractPromiseCallback(submitSource, 'then'), /clearFillingIdempotencyKey\(\)/)
|
||||||
|
const catchSource = extractPromiseCallback(submitSource, 'catch')
|
||||||
|
assert.match(catchSource, /formError\.value\s*=/)
|
||||||
|
assert.doesNotMatch(catchSource, /clearFillingIdempotencyKey/)
|
||||||
|
assert.match(extractNamedFunction(source, 'cancelFilling'), /clearFillingIdempotencyKey\(\)/)
|
||||||
|
assert.match(source, /watch\([\s\S]*?clearFillingIdempotencyKey\(\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂等键', async () => {
|
||||||
|
const source = await readFile(path.join(frontendRoot, businessPageContracts.shipment.relativePath), 'utf8')
|
||||||
|
|
||||||
|
assertButtonsUsePermission(source, 'handleAdd', 'business:shipment:add')
|
||||||
|
assertButtonsUsePermission(source, 'submitShipment', 'business:shipment:add')
|
||||||
|
assertButtonsUsePermission(source, 'handleUpdate', 'business:shipment:edit')
|
||||||
|
assertButtonsUsePermission(source, 'handleDelete', 'business:shipment:remove')
|
||||||
|
assertButtonsUsePermission(source, 'handleConfirm', 'business:shipment:confirm')
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:add'\]\)/)
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:shipment:query', 'business:shipment:edit'\]\)/)
|
||||||
|
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleUpdate\(scope\.row\)/)
|
||||||
|
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleDelete\(scope\.row\)/)
|
||||||
|
assert.match(source, /scope\.row\.status === ['"]DRAFT['"][\s\S]*?handleConfirm\(scope\.row\)/)
|
||||||
|
for (const functionName of ['handleUpdate', 'handleDelete', 'handleConfirm']) {
|
||||||
|
assert.match(extractNamedFunction(source, functionName), /status !== ['"]DRAFT['"]/)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(source, /const editDetailReady = ref\(false\)/)
|
||||||
|
const updateSource = extractNamedFunction(source, 'handleUpdate')
|
||||||
|
const detailRequestIndex = updateSource.indexOf('getShipment(')
|
||||||
|
assert.ok(detailRequestIndex >= 0, '编辑发货草稿必须读取 authoritative 详情')
|
||||||
|
for (const [snippet, message] of [
|
||||||
|
['form.value.shipmentId = row.shipmentId', '读取详情前必须固定发货编辑身份'],
|
||||||
|
['editDetailReady.value = false', '读取详情前必须关闭发货编辑就绪门禁']
|
||||||
|
]) {
|
||||||
|
const index = updateSource.indexOf(snippet)
|
||||||
|
assert.ok(index >= 0 && index < detailRequestIndex, message)
|
||||||
|
}
|
||||||
|
const detailSuccessSource = extractPromiseCallback(updateSource, 'then')
|
||||||
|
assert.ok(
|
||||||
|
detailSuccessSource.indexOf("detail.status !== 'DRAFT'") < detailSuccessSource.indexOf('editDetailReady.value = true'),
|
||||||
|
'仅 authoritative DRAFT 发货详情可开启编辑就绪门禁'
|
||||||
|
)
|
||||||
|
for (const model of ['sourceWarehouseId', 'targetSiteId']) {
|
||||||
|
const select = source.match(new RegExp(`<el-select\\b[\\s\\S]*?v-model="form\\.${model}"[\\s\\S]*?<\\/el-select>`))?.[0]
|
||||||
|
assert.ok(select, `缺少发货 ${model} 选择器`)
|
||||||
|
assert.match(select, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
}
|
||||||
|
const shipmentScanTag = [...source.matchAll(/<pallet-scan-input\b[^>]*\/>/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
.find((tag) => tag.includes('@scan="handleShipmentScan"'))
|
||||||
|
assert.ok(shipmentScanTag, '缺少发货扫码控件')
|
||||||
|
assert.match(shipmentScanTag, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
const shipmentRemoveButton = extractButtonBlocks(source, 'removeScannedPallet')[0]
|
||||||
|
assert.match(shipmentRemoveButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
const shipmentEditButton = extractButtonBlocks(source, 'saveShipmentUpdate')[0]
|
||||||
|
assert.match(shipmentEditButton, /:disabled="[^"]*!editDetailReady[^"]*"/)
|
||||||
|
const submitSource = extractNamedFunction(source, 'submitShipmentForm')
|
||||||
|
assert.match(submitSource, /editing !== isEdit\.value\s*\|\|\s*\(editing && !editDetailReady\.value\)/)
|
||||||
|
assert.ok(
|
||||||
|
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
|
||||||
|
'发货提交必须在异步校验前加锁'
|
||||||
|
)
|
||||||
|
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
|
||||||
|
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
|
||||||
|
const deleteSource = extractNamedFunction(source, 'handleDelete')
|
||||||
|
assert.ok(
|
||||||
|
deleteSource.indexOf('deleteLoading.value = true') < deleteSource.indexOf('proxy.$modal.confirm('),
|
||||||
|
'发货删除必须在打开二次确认框前加锁'
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.deepEqual(extractSecondCallObjectKeys(source, 'scanShipmentPallet'), ['sourceWarehouseId', 'targetSiteId'])
|
||||||
|
assert.match(source, /label="[^"]*只读[^"]*"[^>]*prop="remainingPackages"|prop="remainingPackages"[\s\S]*?不可修改/)
|
||||||
|
assert.match(source, /const detail = response\.data;/)
|
||||||
|
assert.match(source, /\(Array\.isArray\(detail\.items\) \? detail\.items : \[\]\)\.map\(\(item\) => \(\{[\s\S]*?palletId: item\.palletId/)
|
||||||
|
assert.match(source, /item\.palletCode \|\| `#\$\{item\.palletId\}`/)
|
||||||
|
|
||||||
|
const ensureSource = extractNamedFunction(source, 'ensureConfirmIdempotencyKey')
|
||||||
|
assert.match(ensureSource, /createIdempotencyKey\(\)/)
|
||||||
|
const confirmSource = extractNamedFunction(source, 'handleConfirm')
|
||||||
|
assert.ok(
|
||||||
|
confirmSource.indexOf('confirmLoading.value = true') < confirmSource.indexOf('proxy.$modal.confirm('),
|
||||||
|
'发货确认必须在打开二次确认框前加锁'
|
||||||
|
)
|
||||||
|
assert.ok(confirmSource.indexOf('proxy.$modal.confirm(') < confirmSource.indexOf('confirmShipment('))
|
||||||
|
assert.match(extractPromiseCallback(confirmSource, 'then', 1), /clearConfirmIdempotency\(\)/)
|
||||||
|
const confirmCatch = extractPromiseCallback(confirmSource, 'catch')
|
||||||
|
assert.match(confirmCatch, /error === ['"]cancel['"] \|\| error === ['"]close['"]/)
|
||||||
|
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('使用页面依扫码结果锁定产线并只采用后端返回的余量和状态', async () => {
|
||||||
|
const source = await readFile(path.join(frontendRoot, businessPageContracts.usage.relativePath), 'utf8')
|
||||||
|
|
||||||
|
assertButtonsUsePermission(source, 'submitUsage', 'business:usage:add')
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:usage:query', 'business:usage:add'\]\)/)
|
||||||
|
assert.match(source, /pallet\.currentLineId == null/)
|
||||||
|
assert.match(source, /getUsageLineOptions\(pallet\.siteId\)/)
|
||||||
|
assert.match(source, /:disabled="operationPending \|\| lineLocked/)
|
||||||
|
assert.match(source, /validator:\s*validatePositiveInteger/)
|
||||||
|
|
||||||
|
const scanSource = extractNamedFunction(source, 'handleUsageScan')
|
||||||
|
const scanCallIndex = scanSource.indexOf('scanUsagePallet(')
|
||||||
|
assert.ok(scanCallIndex >= 0, '使用扫码必须调用 scanUsagePallet')
|
||||||
|
for (const [snippet, message] of [
|
||||||
|
['clearUsageIdempotencyKey()', '新使用扫码前必须清除旧幂等键'],
|
||||||
|
['scannedPallet.value = null', '新使用扫码前必须清除旧托盘'],
|
||||||
|
['usageResult.value = null', '新使用扫码前必须清除旧成功结果'],
|
||||||
|
['lineOptions.value = []', '新使用扫码前必须清除旧产线选项'],
|
||||||
|
['lineLocked.value = false', '新使用扫码前必须解除旧产线锁定'],
|
||||||
|
["form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }", '新使用扫码前必须清除旧提交 payload']
|
||||||
|
]) {
|
||||||
|
const index = scanSource.indexOf(snippet)
|
||||||
|
assert.ok(index >= 0 && index < scanCallIndex, message)
|
||||||
|
}
|
||||||
|
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
|
||||||
|
assert.match(invalidSource, /clearUsageIdempotencyKey\(\)/)
|
||||||
|
assert.match(invalidSource, /scannedPallet\.value = null/)
|
||||||
|
assert.match(invalidSource, /usageResult\.value = null/)
|
||||||
|
assert.match(invalidSource, /lineOptions\.value = \[\]/)
|
||||||
|
assert.match(invalidSource, /lineLocked\.value = false/)
|
||||||
|
assert.match(invalidSource, /form\.value = \{ qrToken: '', lineId: undefined, usedPackages: undefined \}/)
|
||||||
|
|
||||||
|
const submitSource = extractNamedFunction(source, 'submitUsage')
|
||||||
|
assert.ok(
|
||||||
|
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
|
||||||
|
'使用提交必须在异步校验前加锁'
|
||||||
|
)
|
||||||
|
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
|
||||||
|
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
|
||||||
|
assert.match(submitSource, /const idempotencyKey = ensureUsageIdempotencyKey\(\)/)
|
||||||
|
const successSource = extractPromiseCallback(submitSource, 'then')
|
||||||
|
assert.match(successSource, /const result = response\.data;/)
|
||||||
|
assert.match(successSource, /remainingPackages:\s*result\.remainingPackages/)
|
||||||
|
assert.match(successSource, /flowState:\s*result\.flowState/)
|
||||||
|
assert.doesNotMatch(source, /remainingPackages\s*[-+]=|remainingPackages\s*[-+]\s*form|Math\.(?:max|min)\([^)]*remainingPackages/)
|
||||||
|
assert.match(successSource, /clearUsageIdempotencyKey\(\)/)
|
||||||
|
const catchSource = extractPromiseCallback(submitSource, 'catch')
|
||||||
|
assert.doesNotMatch(catchSource, /clearUsageIdempotencyKey/)
|
||||||
|
assert.match(extractNamedFunction(source, 'resetUsageOperation'), /clearUsageIdempotencyKey\(\)/)
|
||||||
|
assert.match(source, /watch\([\s\S]*?clearUsageIdempotencyKey\(\)/)
|
||||||
|
assert.match(source, /@media \(max-width: 767px\)[\s\S]*?min-height:\s*(?:48|52|56)px/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('退回页面分离申请与确认幂等状态并在提交前严格复核三组托盘集合', async () => {
|
||||||
|
const source = await readFile(path.join(frontendRoot, businessPageContracts.return.relativePath), 'utf8')
|
||||||
|
|
||||||
|
assertButtonsUsePermission(source, 'submitReturnRequest', 'business:return:request')
|
||||||
|
assertButtonsUsePermission(source, 'submitReturnConfirm', 'business:return:confirm')
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:request'\]\)/)
|
||||||
|
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:return:query', 'business:return:confirm'\]\)/)
|
||||||
|
assert.match(source, /status === ['"]REQUESTED['"]/)
|
||||||
|
assert.doesNotMatch(source, /\b(?:IN_TRANSIT|RECEIVED|REJECTED)\b/)
|
||||||
|
assert.match(source, /pallet\.siteId !== requestForm\.value\.sourceSiteId/)
|
||||||
|
assert.match(source, /mode:\s*['"]request['"]/)
|
||||||
|
assert.match(source, /mode:\s*['"]confirm['"][\s\S]*?returnId:/)
|
||||||
|
|
||||||
|
const invalidateRequestScan = extractNamedFunction(source, 'invalidateRequestScan')
|
||||||
|
assert.match(invalidateRequestScan, /requestScanGeneration \+= 1/)
|
||||||
|
assert.match(invalidateRequestScan, /scanLoading\.request = false/)
|
||||||
|
assert.doesNotMatch(invalidateRequestScan, /confirmScanGeneration|scanLoading\.confirm/)
|
||||||
|
const invalidateConfirmScan = extractNamedFunction(source, 'invalidateConfirmScan')
|
||||||
|
assert.match(invalidateConfirmScan, /confirmScanGeneration \+= 1/)
|
||||||
|
assert.match(invalidateConfirmScan, /scanLoading\.confirm = false/)
|
||||||
|
assert.doesNotMatch(invalidateConfirmScan, /requestScanGeneration|scanLoading\.request/)
|
||||||
|
|
||||||
|
const requestScan = extractNamedFunction(source, 'handleRequestScan')
|
||||||
|
assert.ok(
|
||||||
|
requestScan.indexOf('invalidateRequestScan()') < requestScan.indexOf('scanReturnPallet('),
|
||||||
|
'申请扫码必须先失效本区旧请求'
|
||||||
|
)
|
||||||
|
assert.match(requestScan, /generation: \+\+requestScanGeneration/)
|
||||||
|
assert.match(requestScan, /isCurrentRequestScan\(scanContext\)/)
|
||||||
|
assert.match(extractPromiseCallback(requestScan, 'finally'), /scanLoading\.request = false/)
|
||||||
|
assert.doesNotMatch(requestScan, /invalidateConfirmScan|confirmScanGeneration|scanLoading\.confirm/)
|
||||||
|
|
||||||
|
const confirmScan = extractNamedFunction(source, 'handleConfirmScan')
|
||||||
|
assert.ok(
|
||||||
|
confirmScan.indexOf('invalidateConfirmScan()') < confirmScan.indexOf('scanReturnPallet('),
|
||||||
|
'确认扫码必须先失效本区旧请求'
|
||||||
|
)
|
||||||
|
assert.match(confirmScan, /generation: \+\+confirmScanGeneration/)
|
||||||
|
assert.match(confirmScan, /isCurrentConfirmScan\(scanContext\)/)
|
||||||
|
assert.match(extractPromiseCallback(confirmScan, 'finally'), /scanLoading\.confirm = false/)
|
||||||
|
assert.doesNotMatch(confirmScan, /invalidateRequestScan|requestScanGeneration|scanLoading\.request/)
|
||||||
|
|
||||||
|
const resetRequest = extractNamedFunction(source, 'resetReturnRequest')
|
||||||
|
assert.match(resetRequest, /invalidateRequestScan\(\)/)
|
||||||
|
assert.doesNotMatch(resetRequest, /invalidateConfirmScan/)
|
||||||
|
const resetConfirm = extractNamedFunction(source, 'resetReturnConfirm')
|
||||||
|
assert.match(resetConfirm, /invalidateConfirmScan\(\)/)
|
||||||
|
assert.doesNotMatch(resetConfirm, /invalidateRequestScan/)
|
||||||
|
const confirmSelection = extractNamedFunction(source, 'handleConfirmReturnSelection')
|
||||||
|
assert.match(confirmSelection, /invalidateConfirmScan\(\)/)
|
||||||
|
assert.doesNotMatch(confirmSelection, /invalidateRequestScan/)
|
||||||
|
const warehouseWatch = source.slice(source.lastIndexOf('watch('), source.lastIndexOf('getList();'))
|
||||||
|
assert.match(warehouseWatch, /invalidateRequestScan\(\)/)
|
||||||
|
assert.doesNotMatch(warehouseWatch, /invalidateConfirmScan/)
|
||||||
|
|
||||||
|
const requestEnsure = extractNamedFunction(source, 'ensureRequestIdempotencyKey')
|
||||||
|
const confirmEnsure = extractNamedFunction(source, 'ensureReturnConfirmIdempotencyKey')
|
||||||
|
assert.match(requestEnsure, /createIdempotencyKey\(\)/)
|
||||||
|
assert.match(confirmEnsure, /createIdempotencyKey\(\)/)
|
||||||
|
assert.doesNotMatch(requestEnsure, /confirmIdempotencyKey/)
|
||||||
|
assert.doesNotMatch(confirmEnsure, /requestIdempotencyKey/)
|
||||||
|
|
||||||
|
const requestSubmit = extractNamedFunction(source, 'submitReturnRequest')
|
||||||
|
assert.match(requestSubmit, /const idempotencyKey = ensureRequestIdempotencyKey\(\)/)
|
||||||
|
assert.match(extractPromiseCallback(requestSubmit, 'then'), /clearRequestIdempotencyKey\(\)/)
|
||||||
|
assert.doesNotMatch(extractPromiseCallback(requestSubmit, 'catch'), /clearRequestIdempotencyKey/)
|
||||||
|
|
||||||
|
const confirmSubmit = extractNamedFunction(source, 'submitReturnConfirm')
|
||||||
|
const detailIndex = confirmSubmit.indexOf('getReturn(')
|
||||||
|
const confirmIndex = confirmSubmit.indexOf('confirmReturn(')
|
||||||
|
assert.ok(detailIndex >= 0 && confirmIndex > detailIndex, '确认退回写请求前必须重新读取详情')
|
||||||
|
assert.match(confirmSubmit, /const initialExpectedIds = \[\.\.\.confirmExpectedPalletIds\.value\]/)
|
||||||
|
assert.match(confirmSubmit, /const scannedPalletIds = \[\.\.\.confirmScannedPallets\.value\.map\(\(item\) => item\.palletId\)\]/)
|
||||||
|
assert.match(confirmSubmit, /const latestPalletIds = latestDetail\.items\.map\(\(item\) => item\.palletId\)/)
|
||||||
|
assert.match(confirmSubmit, /!sameIdSet\(latestPalletIds, initialExpectedIds\)\s*\|\|\s*!sameIdSet\(latestPalletIds, scannedPalletIds\)/)
|
||||||
|
assert.match(confirmSubmit, /latestDetail\.status !== ['"]REQUESTED['"]/)
|
||||||
|
assert.match(confirmSubmit, /const idempotencyKey = ensureReturnConfirmIdempotencyKey\(returnId\)/)
|
||||||
|
assert.match(extractPromiseCallback(confirmSubmit, 'then', 1), /clearReturnConfirmIdempotencyKey\(\)/)
|
||||||
|
assert.doesNotMatch(extractPromiseCallback(confirmSubmit, 'catch'), /clearReturnConfirmIdempotencyKey/)
|
||||||
|
|
||||||
|
assert.match(extractNamedFunction(source, 'resetReturnRequest'), /clearRequestIdempotencyKey\(\)/)
|
||||||
|
assert.match(extractNamedFunction(source, 'resetReturnConfirm'), /clearReturnConfirmIdempotencyKey\(\)/)
|
||||||
|
})
|
||||||
|
|
||||||
test('业务用户分配组件使用固定映射并允许空数组完整替换', async () => {
|
test('业务用户分配组件使用固定映射并允许空数组完整替换', async () => {
|
||||||
const relativePath = 'src/components/BusinessUserAssignDialog/index.vue'
|
const relativePath = 'src/components/BusinessUserAssignDialog/index.vue'
|
||||||
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
|
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
|
||||||
|
|||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-form ref="queryRef" :model="queryParams" :inline="true">
|
||||||
|
<el-form-item label="装填单号" prop="fillingNo">
|
||||||
|
<el-input v-model="queryParams.fillingNo" placeholder="请输入装填单号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="托盘编号" prop="palletCode">
|
||||||
|
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="生产批次" prop="batchNo">
|
||||||
|
<el-input v-model="queryParams.batchNo" placeholder="请输入生产批次" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:filling:list']">搜索</el-button>
|
||||||
|
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:filling:list']">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
v-if="canAddFilling"
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="Plus"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="handleOpenFilling"
|
||||||
|
v-hasPermi="['business:filling:add']"
|
||||||
|
>托盘装填</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-table v-loading="loading" empty-text="暂无数据" :data="fillingList">
|
||||||
|
<el-table-column label="装填单号" prop="fillingNo" min-width="170" :show-overflow-tooltip="true" />
|
||||||
|
<el-table-column label="托盘" min-width="120">
|
||||||
|
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="仓库ID" prop="warehouseId" width="110" />
|
||||||
|
<el-table-column label="菌种" min-width="160">
|
||||||
|
<template #default="scope">{{ scope.row.strainNameSnapshot || scope.row.strainCodeSnapshot || `#${scope.row.strainId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="生产批次" prop="batchNo" min-width="130" :show-overflow-tooltip="true" />
|
||||||
|
<el-table-column label="包装规格" prop="packageSpec" min-width="120" :show-overflow-tooltip="true" />
|
||||||
|
<el-table-column label="初始包数" prop="initialPackages" width="100" align="right" />
|
||||||
|
<el-table-column label="操作时间" prop="operatedAt" width="180">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.operatedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="open"
|
||||||
|
title="托盘装填"
|
||||||
|
width="min(680px, 94vw)"
|
||||||
|
append-to-body
|
||||||
|
:close-on-click-modal="!operationPending"
|
||||||
|
:close-on-press-escape="!operationPending"
|
||||||
|
:show-close="!operationPending"
|
||||||
|
:before-close="handleDialogBeforeClose"
|
||||||
|
>
|
||||||
|
<el-alert v-if="optionError" :title="optionError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
|
||||||
|
<el-form ref="fillingRef" :model="form" :rules="rules" label-width="96px">
|
||||||
|
<el-form-item label="扫描托盘">
|
||||||
|
<pallet-scan-input
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleFillingScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-descriptions v-if="scannedPallet" :column="2" border class="scan-summary">
|
||||||
|
<el-descriptions-item label="托盘">{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="仓库">#{{ scannedPallet.warehouseId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="流转状态">{{ scannedPallet.flowState }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="托盘容量">{{ scannedPallet.capacityPackages }} 包</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-empty v-else description="请先扫描待装填托盘" :image-size="72" />
|
||||||
|
|
||||||
|
<el-form-item label="菌种" prop="strainId">
|
||||||
|
<el-select
|
||||||
|
v-model="form.strainId"
|
||||||
|
class="business-control"
|
||||||
|
placeholder="请选择菌种"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="optionLoading"
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in strainOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:label="option.name || `#${option.id}`"
|
||||||
|
:value="option.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="生产批次" prop="batchNo">
|
||||||
|
<el-input
|
||||||
|
v-model="form.batchNo"
|
||||||
|
class="business-control"
|
||||||
|
maxlength="64"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入生产批次"
|
||||||
|
:disabled="operationPending"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="包装规格" prop="packageSpec">
|
||||||
|
<el-input
|
||||||
|
v-model="form.packageSpec"
|
||||||
|
class="business-control"
|
||||||
|
maxlength="64"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入包装规格"
|
||||||
|
:disabled="operationPending"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="初始包数" prop="initialPackages">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.initialPackages"
|
||||||
|
class="business-control"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="operationPending"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="operationPending || scanLoading || optionLoading || !scannedPallet"
|
||||||
|
@click="submitFilling"
|
||||||
|
v-hasPermi="['business:filling:add']"
|
||||||
|
>确认装填</el-button>
|
||||||
|
<el-button class="business-action" :disabled="operationPending" @click="cancelFilling">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="BusinessFilling">
|
||||||
|
import PalletScanInput from '@/components/PalletScanInput'
|
||||||
|
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||||
|
import { addFilling, getFillingStrainOptions, listFilling, scanFillingPallet } from '@/api/business/filling'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
const fillingList = ref([])
|
||||||
|
const strainOptions = ref([])
|
||||||
|
const scannedPallet = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const optionLoading = ref(false)
|
||||||
|
const scanLoading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const optionError = ref('')
|
||||||
|
const scanError = ref('')
|
||||||
|
const formError = ref('')
|
||||||
|
const open = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const fillingIdempotencyKey = ref('')
|
||||||
|
const canAddFilling = computed(() => proxy.$auth.hasPermiAnd(['business:filling:query', 'business:filling:add']))
|
||||||
|
const operationPending = computed(() => submitLoading.value)
|
||||||
|
let listGeneration = 0
|
||||||
|
let optionGeneration = 0
|
||||||
|
let scanGeneration = 0
|
||||||
|
let dialogSession = 0
|
||||||
|
|
||||||
|
function validateRequiredText(rule, value, callback) {
|
||||||
|
const labels = { batchNo: '生产批次', packageSpec: '包装规格' }
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
callback(new Error(`${labels[rule.field]}不能为空`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePositiveInteger(rule, value, callback) {
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
callback(new Error('初始包数必须为正整数'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined },
|
||||||
|
queryParams: { pageNum: 1, pageSize: 10, fillingNo: undefined, palletCode: undefined, batchNo: undefined },
|
||||||
|
rules: {
|
||||||
|
strainId: [{ required: true, message: '请选择菌种', trigger: 'change' }],
|
||||||
|
batchNo: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
|
||||||
|
packageSpec: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
|
||||||
|
initialPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { form, queryParams, rules } = toRefs(data)
|
||||||
|
|
||||||
|
function isCurrentListRequest(listContext) {
|
||||||
|
return listContext.generation === listGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
function getList() {
|
||||||
|
const listContext = {
|
||||||
|
generation: ++listGeneration,
|
||||||
|
query: { ...queryParams.value }
|
||||||
|
};
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
listFilling(listContext.query).then((response) => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fillingList.value = response.rows
|
||||||
|
total.value = response.total
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fillingList.value = []
|
||||||
|
total.value = 0
|
||||||
|
loadError.value = '装填记录加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateOptionRequest() {
|
||||||
|
optionGeneration += 1
|
||||||
|
optionLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentOptionRequest(optionContext) {
|
||||||
|
return optionContext.generation === optionGeneration && optionContext.dialogSession === dialogSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStrainOptions() {
|
||||||
|
const optionContext = { generation: ++optionGeneration, dialogSession }
|
||||||
|
optionLoading.value = true
|
||||||
|
optionError.value = ''
|
||||||
|
getFillingStrainOptions().then((response) => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
strainOptions.value = Array.isArray(response.data) ? response.data : []
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
strainOptions.value = []
|
||||||
|
optionError.value = '菌种选项加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
optionLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateScanRequest() {
|
||||||
|
scanGeneration += 1
|
||||||
|
scanLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentScanRequest(scanContext) {
|
||||||
|
return scanContext.generation === scanGeneration && scanContext.dialogSession === dialogSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFillingIdempotencyKey() {
|
||||||
|
fillingIdempotencyKey.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFillingIdempotencyKey() {
|
||||||
|
if (!fillingIdempotencyKey.value) {
|
||||||
|
fillingIdempotencyKey.value = createIdempotencyKey()
|
||||||
|
}
|
||||||
|
return fillingIdempotencyKey.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFillingOperation() {
|
||||||
|
dialogSession += 1
|
||||||
|
invalidateOptionRequest()
|
||||||
|
invalidateScanRequest()
|
||||||
|
clearFillingIdempotencyKey()
|
||||||
|
scannedPallet.value = null
|
||||||
|
form.value = { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined }
|
||||||
|
optionError.value = ''
|
||||||
|
scanError.value = ''
|
||||||
|
formError.value = ''
|
||||||
|
proxy.resetForm('fillingRef')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenFilling() {
|
||||||
|
if (!canAddFilling.value || operationPending.value) return
|
||||||
|
resetFillingOperation()
|
||||||
|
open.value = true
|
||||||
|
loadStrainOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelFilling() {
|
||||||
|
if (operationPending.value) return
|
||||||
|
clearFillingIdempotencyKey()
|
||||||
|
open.value = false
|
||||||
|
resetFillingOperation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogBeforeClose(done) {
|
||||||
|
if (operationPending.value) return
|
||||||
|
resetFillingOperation()
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQuery() {
|
||||||
|
proxy.resetForm('queryRef')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFillingScan(qrToken) {
|
||||||
|
if (operationPending.value || scanLoading.value) return
|
||||||
|
clearFillingIdempotencyKey()
|
||||||
|
scannedPallet.value = null
|
||||||
|
form.value.qrToken = ''
|
||||||
|
form.value.warehouseId = undefined
|
||||||
|
const scanContext = { generation: ++scanGeneration, dialogSession, qrToken }
|
||||||
|
scanLoading.value = true
|
||||||
|
scanError.value = ''
|
||||||
|
scanFillingPallet(qrToken).then((response) => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
const pallet = response.data
|
||||||
|
if (pallet.flowState !== 'EMPTY_IN_WAREHOUSE') {
|
||||||
|
scanError.value = '该托盘当前不可装填'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scannedPallet.value = {
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
warehouseId: pallet.warehouseId,
|
||||||
|
flowState: pallet.flowState,
|
||||||
|
capacityPackages: pallet.capacityPackages
|
||||||
|
}
|
||||||
|
form.value.qrToken = qrToken
|
||||||
|
form.value.warehouseId = pallet.warehouseId
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
scanError.value = '托盘扫码校验失败,请检查后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
scanLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
clearFillingIdempotencyKey()
|
||||||
|
scannedPallet.value = null
|
||||||
|
form.value.qrToken = ''
|
||||||
|
form.value.warehouseId = undefined
|
||||||
|
scanError.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitFilling() {
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value) return
|
||||||
|
submitLoading.value = true
|
||||||
|
proxy.$refs.fillingRef.validate((valid) => {
|
||||||
|
if (!valid) {
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!scannedPallet.value || !form.value.qrToken || !form.value.warehouseId) {
|
||||||
|
formError.value = '请先扫描待装填托盘'
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idempotencyKey = ensureFillingIdempotencyKey()
|
||||||
|
formError.value = ''
|
||||||
|
addFilling({
|
||||||
|
qrToken: form.value.qrToken,
|
||||||
|
warehouseId: form.value.warehouseId,
|
||||||
|
strainId: form.value.strainId,
|
||||||
|
batchNo: form.value.batchNo.trim(),
|
||||||
|
packageSpec: form.value.packageSpec.trim(),
|
||||||
|
initialPackages: form.value.initialPackages,
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
}).then((response) => {
|
||||||
|
clearFillingIdempotencyKey()
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '托盘装填成功')
|
||||||
|
open.value = false
|
||||||
|
resetFillingOperation()
|
||||||
|
getList()
|
||||||
|
}).catch(() => {
|
||||||
|
formError.value = '装填提交失败;修正业务输入会生成新幂等键,直接重试将复用原键'
|
||||||
|
}).finally(() => {
|
||||||
|
submitLoading.value = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [form.value.qrToken, form.value.warehouseId, form.value.strainId, form.value.batchNo, form.value.packageSpec, form.value.initialPackages],
|
||||||
|
() => {
|
||||||
|
if (!submitLoading.value) clearFillingIdempotencyKey()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.scan-summary {
|
||||||
|
margin: 0 0 18px 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
width: 94vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-summary {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.business-control .el-input__wrapper),
|
||||||
|
:deep(.business-action) {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
float: none;
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__content) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-form ref="queryRef" :model="queryParams" :inline="true">
|
||||||
|
<el-form-item label="入库单号" prop="inboundNo">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.inboundNo"
|
||||||
|
placeholder="请输入入库单号"
|
||||||
|
clearable
|
||||||
|
maxlength="64"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="仓库ID" prop="warehouseId">
|
||||||
|
<el-input-number v-model="queryParams.warehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="单据状态" prop="status">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
|
||||||
|
<el-option label="草稿" value="DRAFT" />
|
||||||
|
<el-option label="已确认" value="CONFIRMED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:inbound:list']">搜索</el-button>
|
||||||
|
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:inbound:list']">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
v-if="canAddInbound"
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="Plus"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="handleAdd"
|
||||||
|
v-hasPermi="['business:inbound:add']"
|
||||||
|
>新增入库草稿</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-table v-loading="loading" empty-text="暂无数据" :data="inboundList">
|
||||||
|
<el-table-column label="入库单号" prop="inboundNo" min-width="170" :show-overflow-tooltip="true" />
|
||||||
|
<el-table-column label="仓库" min-width="120">
|
||||||
|
<template #default="scope">{{ optionLabel(warehouseOptions, scope.row.warehouseId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="110" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.status === 'DRAFT' ? 'warning' : 'success'">
|
||||||
|
{{ scope.row.status === 'DRAFT' ? '草稿' : '已确认' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" prop="createTime" width="180">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="确认时间" prop="confirmedAt" width="180">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.confirmedAt) || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="250" fixed="right" class-name="small-padding fixed-width">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.status === 'DRAFT' && canEditInbound"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
icon="Edit"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="handleUpdate(scope.row)"
|
||||||
|
v-hasPermi="['business:inbound:edit']"
|
||||||
|
>编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.status === 'DRAFT'"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
icon="Delete"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="handleDelete(scope.row)"
|
||||||
|
v-hasPermi="['business:inbound:remove']"
|
||||||
|
>删除</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.status === 'DRAFT' && canConfirmInbound"
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
icon="CircleCheck"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="handleConfirm(scope.row)"
|
||||||
|
v-hasPermi="['business:inbound:confirm']"
|
||||||
|
>确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="open"
|
||||||
|
:title="title"
|
||||||
|
width="min(760px, 94vw)"
|
||||||
|
append-to-body
|
||||||
|
:close-on-click-modal="!operationPending"
|
||||||
|
:close-on-press-escape="!operationPending"
|
||||||
|
:show-close="!operationPending"
|
||||||
|
:before-close="handleDialogBeforeClose"
|
||||||
|
>
|
||||||
|
<el-alert v-if="optionError" :title="optionError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-form ref="inboundRef" :model="form" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="入库仓库" prop="warehouseId">
|
||||||
|
<el-select
|
||||||
|
v-model="form.warehouseId"
|
||||||
|
class="business-control"
|
||||||
|
placeholder="请选择授权仓库"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="optionLoading"
|
||||||
|
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady)"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="option in warehouseOptions"
|
||||||
|
:key="option.id"
|
||||||
|
:label="optionLabel(warehouseOptions, option.id)"
|
||||||
|
:value="option.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="连续扫码">
|
||||||
|
<pallet-scan-input
|
||||||
|
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady) || !form.warehouseId"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleInboundScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">仅接收尚未首次入库(UNSTOCKED)的托盘,同一托盘只保留一条。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-table :data="scannedPallets" empty-text="尚未扫描托盘" size="small" max-height="320">
|
||||||
|
<el-table-column label="托盘" min-width="180">
|
||||||
|
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="托盘ID" prop="palletId" width="110" />
|
||||||
|
<el-table-column label="流转状态" prop="flowState" min-width="140">
|
||||||
|
<template #default="scope">{{ scope.row.flowState || '详情未提供' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="operationPending || detailLoading || (isEdit && !editDetailReady)"
|
||||||
|
@click="removeScannedPallet(scope.row.palletId)"
|
||||||
|
>移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button
|
||||||
|
v-if="!isEdit"
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="operationPending || scanLoading || optionLoading || detailLoading"
|
||||||
|
@click="submitInbound"
|
||||||
|
v-hasPermi="['business:inbound:add']"
|
||||||
|
>保存草稿</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="operationPending || scanLoading || optionLoading || detailLoading || !editDetailReady"
|
||||||
|
@click="saveInboundUpdate"
|
||||||
|
v-hasPermi="['business:inbound:edit']"
|
||||||
|
>保存修改</el-button>
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="cancelInbound"
|
||||||
|
>取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="BusinessInbound">
|
||||||
|
import PalletScanInput from '@/components/PalletScanInput'
|
||||||
|
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||||
|
import {
|
||||||
|
addInbound,
|
||||||
|
confirmInbound,
|
||||||
|
delInbound,
|
||||||
|
getInbound,
|
||||||
|
getInboundWarehouseOptions,
|
||||||
|
listInbound,
|
||||||
|
scanInboundPallet,
|
||||||
|
updateInbound
|
||||||
|
} from '@/api/business/inbound'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
const inboundList = ref([])
|
||||||
|
const warehouseOptions = ref([])
|
||||||
|
const scannedPallets = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const optionLoading = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const scanLoading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const deleteLoading = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const optionError = ref('')
|
||||||
|
const scanError = ref('')
|
||||||
|
const formError = ref('')
|
||||||
|
const open = ref(false)
|
||||||
|
const title = ref('')
|
||||||
|
const total = ref(0)
|
||||||
|
const editDetailReady = ref(false)
|
||||||
|
const isEdit = computed(() => Boolean(form.value.inboundId))
|
||||||
|
const canAddInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:add']))
|
||||||
|
const canEditInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:edit']))
|
||||||
|
const canConfirmInbound = computed(() => proxy.$auth.hasPermiAnd(['business:inbound:query', 'business:inbound:confirm']))
|
||||||
|
const operationPending = computed(() => submitLoading.value || deleteLoading.value || confirmLoading.value)
|
||||||
|
let listGeneration = 0
|
||||||
|
let optionGeneration = 0
|
||||||
|
let detailGeneration = 0
|
||||||
|
let scanGeneration = 0
|
||||||
|
let dialogSession = 0
|
||||||
|
let hydratingForm = false
|
||||||
|
const confirmIdempotency = reactive({ inboundId: undefined, key: '' })
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: { inboundId: undefined, warehouseId: undefined },
|
||||||
|
queryParams: { pageNum: 1, pageSize: 10, inboundNo: undefined, warehouseId: undefined, status: undefined },
|
||||||
|
rules: {
|
||||||
|
warehouseId: [{ required: true, message: '请选择入库仓库', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { form, queryParams, rules } = toRefs(data)
|
||||||
|
|
||||||
|
function optionLabel(options, id) {
|
||||||
|
const option = options.find((item) => item.id === id)
|
||||||
|
return option?.name || (id ? `#${id}` : '—')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentListRequest(listContext) {
|
||||||
|
return listContext.generation === listGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
function getList() {
|
||||||
|
const listContext = {
|
||||||
|
generation: ++listGeneration,
|
||||||
|
query: { ...queryParams.value }
|
||||||
|
};
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
listInbound(listContext.query).then((response) => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inboundList.value = response.rows
|
||||||
|
total.value = response.total
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inboundList.value = []
|
||||||
|
total.value = 0
|
||||||
|
loadError.value = '入库单列表加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateOptionRequest() {
|
||||||
|
optionGeneration += 1
|
||||||
|
optionLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentOptionRequest(optionContext) {
|
||||||
|
return optionContext.generation === optionGeneration && optionContext.dialogSession === dialogSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWarehouseOptions() {
|
||||||
|
const optionContext = { generation: ++optionGeneration, dialogSession }
|
||||||
|
optionLoading.value = true
|
||||||
|
optionError.value = ''
|
||||||
|
getInboundWarehouseOptions().then((response) => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
warehouseOptions.value = Array.isArray(response.data) ? response.data : []
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
warehouseOptions.value = []
|
||||||
|
optionError.value = '授权仓库加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
optionLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDetailRequest() {
|
||||||
|
detailGeneration += 1
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentDetailRequest(detailContext) {
|
||||||
|
return detailContext.generation === detailGeneration && detailContext.dialogSession === dialogSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateScanRequest() {
|
||||||
|
scanGeneration += 1
|
||||||
|
scanLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentScanRequest(scanContext) {
|
||||||
|
return scanContext.generation === scanGeneration &&
|
||||||
|
scanContext.dialogSession === dialogSession &&
|
||||||
|
scanContext.warehouseId === form.value.warehouseId
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetInboundForm() {
|
||||||
|
dialogSession += 1
|
||||||
|
invalidateOptionRequest()
|
||||||
|
invalidateDetailRequest()
|
||||||
|
invalidateScanRequest()
|
||||||
|
formError.value = ''
|
||||||
|
optionError.value = ''
|
||||||
|
scanError.value = ''
|
||||||
|
editDetailReady.value = false
|
||||||
|
form.value = { inboundId: undefined, warehouseId: undefined }
|
||||||
|
scannedPallets.value = []
|
||||||
|
proxy.resetForm('inboundRef')
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelInbound() {
|
||||||
|
if (operationPending.value) return
|
||||||
|
open.value = false
|
||||||
|
resetInboundForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogBeforeClose(done) {
|
||||||
|
if (operationPending.value) return
|
||||||
|
resetInboundForm()
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQuery() {
|
||||||
|
proxy.resetForm('queryRef')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
if (!canAddInbound.value || operationPending.value) return
|
||||||
|
resetInboundForm()
|
||||||
|
title.value = '新增首次入库草稿'
|
||||||
|
open.value = true
|
||||||
|
loadWarehouseOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(row) {
|
||||||
|
if (row.status !== 'DRAFT') {
|
||||||
|
proxy.$modal.msgWarning('只有草稿入库单可以编辑')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canEditInbound.value || operationPending.value) return
|
||||||
|
resetInboundForm()
|
||||||
|
form.value.inboundId = row.inboundId
|
||||||
|
editDetailReady.value = false
|
||||||
|
title.value = '编辑首次入库草稿'
|
||||||
|
open.value = true
|
||||||
|
loadWarehouseOptions()
|
||||||
|
const detailContext = {
|
||||||
|
generation: ++detailGeneration,
|
||||||
|
dialogSession,
|
||||||
|
inboundId: row.inboundId
|
||||||
|
}
|
||||||
|
detailLoading.value = true
|
||||||
|
getInbound(detailContext.inboundId).then((response) => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
const detail = response.data;
|
||||||
|
if (detail.status !== 'DRAFT') {
|
||||||
|
formError.value = '该入库单已不是草稿状态,不能编辑'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hydratingForm = true
|
||||||
|
form.value = { inboundId: detailContext.inboundId, warehouseId: detail.warehouseId }
|
||||||
|
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map((item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
flowState: item.flowState || ''
|
||||||
|
}))
|
||||||
|
hydratingForm = false
|
||||||
|
editDetailReady.value = true
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
formError.value = '入库单详情加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
detailLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInboundScan(qrToken) {
|
||||||
|
if (isEdit.value && !editDetailReady.value) {
|
||||||
|
scanError.value = '入库单详情尚未就绪,不能扫码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value || scanLoading.value || !form.value.warehouseId) {
|
||||||
|
scanError.value = '请先选择入库仓库'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scanContext = {
|
||||||
|
generation: ++scanGeneration,
|
||||||
|
dialogSession,
|
||||||
|
warehouseId: form.value.warehouseId,
|
||||||
|
qrToken
|
||||||
|
}
|
||||||
|
scanLoading.value = true
|
||||||
|
scanError.value = ''
|
||||||
|
scanInboundPallet(qrToken, {
|
||||||
|
warehouseId: scanContext.warehouseId
|
||||||
|
}).then((response) => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
const pallet = response.data
|
||||||
|
if (pallet.flowState !== 'UNSTOCKED') {
|
||||||
|
scanError.value = '该托盘不是未入库状态,不能加入入库草稿'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msgWarning('该托盘已在当前明细中')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
flowState: pallet.flowState
|
||||||
|
})
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
scanError.value = '托盘扫码校验失败,请检查后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
scanLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
scanError.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScannedPallet(palletId) {
|
||||||
|
if (operationPending.value || (isEdit.value && !editDetailReady.value)) return
|
||||||
|
scannedPallets.value = scannedPallets.value.filter((item) => item.palletId !== palletId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInboundDraft() {
|
||||||
|
return addInbound({
|
||||||
|
warehouseId: form.value.warehouseId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInboundDraft() {
|
||||||
|
return updateInbound({
|
||||||
|
inboundId: form.value.inboundId,
|
||||||
|
warehouseId: form.value.warehouseId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitInboundForm(editing) {
|
||||||
|
if (editing !== isEdit.value || (editing && !editDetailReady.value)) {
|
||||||
|
formError.value = '入库单编辑详情尚未就绪,不能提交'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value || detailLoading.value) return
|
||||||
|
submitLoading.value = true
|
||||||
|
proxy.$refs.inboundRef.validate((valid) => {
|
||||||
|
if (!valid) {
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!scannedPallets.value.length) {
|
||||||
|
formError.value = '请至少扫描一个托盘'
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formError.value = ''
|
||||||
|
const submitPromise = editing ? updateInboundDraft() : addInboundDraft()
|
||||||
|
submitPromise.then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '入库草稿保存成功')
|
||||||
|
open.value = false
|
||||||
|
resetInboundForm()
|
||||||
|
getList()
|
||||||
|
}).catch(() => {
|
||||||
|
formError.value = editing ? '入库草稿修改失败,请根据提示修正后重试' : '入库草稿新增失败,请根据提示修正后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
submitLoading.value = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitInbound() {
|
||||||
|
submitInboundForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveInboundUpdate() {
|
||||||
|
submitInboundForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(row) {
|
||||||
|
if (row.status !== 'DRAFT') {
|
||||||
|
proxy.$modal.msgWarning('只有草稿入库单可以删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value) return
|
||||||
|
deleteLoading.value = true
|
||||||
|
proxy.$modal.confirm(`是否确认删除入库单“${row.inboundNo || `#${row.inboundId}`}”?`).then(() => {
|
||||||
|
return delInbound(String(row.inboundId))
|
||||||
|
}).then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '删除成功')
|
||||||
|
getList()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error !== 'cancel' && error !== 'close') {
|
||||||
|
loadError.value = '入库单删除失败,请稍后重试'
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
deleteLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConfirmIdempotency() {
|
||||||
|
confirmIdempotency.inboundId = undefined
|
||||||
|
confirmIdempotency.key = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(inboundId) {
|
||||||
|
if (confirmIdempotency.inboundId !== inboundId) {
|
||||||
|
clearConfirmIdempotency()
|
||||||
|
confirmIdempotency.inboundId = inboundId
|
||||||
|
}
|
||||||
|
if (!confirmIdempotency.key) {
|
||||||
|
confirmIdempotency.key = createIdempotencyKey()
|
||||||
|
}
|
||||||
|
return confirmIdempotency.key
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmCancellation(error) {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
clearConfirmIdempotency()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm(row) {
|
||||||
|
if (row.status !== 'DRAFT') {
|
||||||
|
proxy.$modal.msgWarning('只有草稿入库单可以确认')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value) return
|
||||||
|
confirmLoading.value = true
|
||||||
|
proxy.$modal.confirm(`确认后入库单“${row.inboundNo || `#${row.inboundId}`}”将不可编辑或删除,是否继续?`).then(() => {
|
||||||
|
const idempotencyKey = ensureConfirmIdempotencyKey(row.inboundId)
|
||||||
|
return confirmInbound(row.inboundId, {
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
})
|
||||||
|
}).then((response) => {
|
||||||
|
clearConfirmIdempotency()
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '入库确认成功')
|
||||||
|
getList()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
handleConfirmCancellation(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadError.value = '入库确认失败;再次确认会复用本次幂等键'
|
||||||
|
}).finally(() => {
|
||||||
|
confirmLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => form.value.warehouseId,
|
||||||
|
(warehouseId, previousWarehouseId) => {
|
||||||
|
if (hydratingForm || warehouseId === previousWarehouseId) return
|
||||||
|
invalidateScanRequest()
|
||||||
|
scannedPallets.value = []
|
||||||
|
scanError.value = ''
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
)
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.form-tip {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
width: 94vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
float: none;
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__content) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container return-page">
|
||||||
|
<el-row v-if="canRequestReturn || canConfirmReturn" :gutter="18" class="return-operations">
|
||||||
|
<el-col v-if="canRequestReturn" :xs="24" :lg="12">
|
||||||
|
<el-card shadow="never" class="operation-card">
|
||||||
|
<template #header><strong>客户发起退回</strong></template>
|
||||||
|
<el-alert v-if="requestError" :title="requestError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="目标仓库">
|
||||||
|
<el-select
|
||||||
|
v-model="requestForm.targetWarehouseId"
|
||||||
|
class="business-control"
|
||||||
|
placeholder="请选择退回目标仓库"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="optionLoading"
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
@visible-change="handleWarehouseOptionsVisible"
|
||||||
|
>
|
||||||
|
<el-option v-for="option in warehouseOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="连续扫描同一现场托盘">
|
||||||
|
<pallet-scan-input
|
||||||
|
:disabled="operationPending || optionLoading || !requestForm.targetWarehouseId"
|
||||||
|
:loading="scanLoading.request"
|
||||||
|
@scan="handleRequestScan"
|
||||||
|
@invalid="handleRequestScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-descriptions :column="2" border class="mb8">
|
||||||
|
<el-descriptions-item label="来源现场">{{ requestForm.sourceSiteId ? `#${requestForm.sourceSiteId}` : '等待首个扫码' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="已扫托盘">{{ requestScannedPallets.length }} 个</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-table :data="requestScannedPallets" empty-text="尚未扫描退回托盘" size="small" max-height="250">
|
||||||
|
<el-table-column label="托盘" min-width="150">
|
||||||
|
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="当前余量" prop="remainingPackages" width="100" align="right" />
|
||||||
|
<el-table-column label="操作" width="80" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="removeRequestPallet(scope.row.palletId)"
|
||||||
|
>移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="operation-actions">
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
:loading="requestSubmitLoading"
|
||||||
|
:disabled="operationPending || scanLoading.request || optionLoading || !requestScannedPallets.length"
|
||||||
|
@click="submitReturnRequest"
|
||||||
|
v-hasPermi="['business:return:request']"
|
||||||
|
>提交退回申请</el-button>
|
||||||
|
<el-button class="business-action" :disabled="operationPending" @click="resetReturnRequest">清空</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col v-if="canConfirmReturn" :xs="24" :lg="12">
|
||||||
|
<el-card shadow="never" class="operation-card">
|
||||||
|
<template #header><strong>仓库确认退回</strong></template>
|
||||||
|
<el-alert v-if="confirmError" :title="confirmError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="待确认退回单">
|
||||||
|
<el-select
|
||||||
|
v-model="confirmForm.returnId"
|
||||||
|
class="business-control"
|
||||||
|
placeholder="请选择 REQUESTED 退回单"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@change="handleConfirmReturnSelection"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in requestedReturnOptions"
|
||||||
|
:key="item.returnId"
|
||||||
|
:label="item.returnNo || `#${item.returnId}`"
|
||||||
|
:value="item.returnId"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="逐个扫描到仓托盘">
|
||||||
|
<pallet-scan-input
|
||||||
|
:disabled="operationPending || detailLoading || !confirmForm.returnId || !confirmExpectedPalletIds.length"
|
||||||
|
:loading="scanLoading.confirm"
|
||||||
|
@scan="handleConfirmScan"
|
||||||
|
@invalid="handleConfirmScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-descriptions :column="2" border class="mb8">
|
||||||
|
<el-descriptions-item label="预期托盘">{{ confirmExpectedPalletIds.length }} 个</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="已扫托盘">{{ confirmScannedPallets.length }} 个</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-table :data="confirmExpectedRows" empty-text="请选择待确认退回单" size="small" max-height="250">
|
||||||
|
<el-table-column label="预期托盘" min-width="150">
|
||||||
|
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="扫码状态" width="100" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="isConfirmPalletScanned(scope.row.palletId) ? 'success' : 'info'">
|
||||||
|
{{ isConfirmPalletScanned(scope.row.palletId) ? '已扫码' : '待扫码' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
v-if="isConfirmPalletScanned(scope.row.palletId)"
|
||||||
|
class="business-action"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="removeConfirmPallet(scope.row.palletId)"
|
||||||
|
>移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="operation-actions">
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
type="success"
|
||||||
|
:loading="confirmSubmitLoading"
|
||||||
|
:disabled="operationPending || detailLoading || scanLoading.confirm || !confirmForm.returnId"
|
||||||
|
@click="submitReturnConfirm"
|
||||||
|
v-hasPermi="['business:return:confirm']"
|
||||||
|
>复核并确认退回</el-button>
|
||||||
|
<el-button class="business-action" :disabled="operationPending" @click="resetReturnConfirm">清空</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-divider v-if="canRequestReturn || canConfirmReturn" />
|
||||||
|
|
||||||
|
<el-form ref="queryRef" :model="queryParams" :inline="true">
|
||||||
|
<el-form-item label="退回单号" prop="returnNo">
|
||||||
|
<el-input v-model="queryParams.returnNo" placeholder="请输入退回单号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="来源现场ID" prop="sourceSiteId">
|
||||||
|
<el-input-number v-model="queryParams.sourceSiteId" :min="1" :controls="false" placeholder="请输入现场ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标仓库ID" prop="targetWarehouseId">
|
||||||
|
<el-input-number v-model="queryParams.targetWarehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="退回状态" prop="status">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
|
||||||
|
<el-option label="待确认" value="REQUESTED" />
|
||||||
|
<el-option label="已确认" value="CONFIRMED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:return:list']">搜索</el-button>
|
||||||
|
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:return:list']">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-table v-loading="loading" empty-text="暂无数据" :data="returnList">
|
||||||
|
<el-table-column label="退回单号" prop="returnNo" min-width="170" :show-overflow-tooltip="true" />
|
||||||
|
<el-table-column label="来源现场" min-width="120">
|
||||||
|
<template #default="scope">#{{ scope.row.sourceSiteId }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="目标仓库" min-width="130">
|
||||||
|
<template #default="scope">{{ warehouseLabel(scope.row.targetWarehouseId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="110" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.status === 'REQUESTED' ? 'warning' : 'success'">
|
||||||
|
{{ scope.row.status === 'REQUESTED' ? '待确认' : '已确认' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="申请时间" prop="requestedAt" width="180">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.requestedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.status === 'REQUESTED' && canConfirmReturn"
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="startConfirmFromRow(scope.row)"
|
||||||
|
v-hasPermi="['business:return:confirm']"
|
||||||
|
>确认退回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="BusinessReturn">
|
||||||
|
import PalletScanInput from '@/components/PalletScanInput'
|
||||||
|
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||||
|
import {
|
||||||
|
confirmReturn,
|
||||||
|
getReturn,
|
||||||
|
getReturnWarehouseOptions,
|
||||||
|
listReturn,
|
||||||
|
requestReturn,
|
||||||
|
scanReturnPallet
|
||||||
|
} from '@/api/business/return'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
const returnList = ref([])
|
||||||
|
const warehouseOptions = ref([])
|
||||||
|
const requestScannedPallets = ref([])
|
||||||
|
const confirmExpectedRows = ref([])
|
||||||
|
const confirmExpectedPalletIds = ref([])
|
||||||
|
const confirmScannedPallets = ref([])
|
||||||
|
const requestForm = ref({ sourceSiteId: undefined, targetWarehouseId: undefined })
|
||||||
|
const confirmForm = ref({ returnId: undefined })
|
||||||
|
const loading = ref(false)
|
||||||
|
const optionLoading = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const scanLoading = reactive({ request: false, confirm: false })
|
||||||
|
const requestSubmitLoading = ref(false)
|
||||||
|
const confirmSubmitLoading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const requestError = ref('')
|
||||||
|
const confirmError = ref('')
|
||||||
|
const total = ref(0)
|
||||||
|
const requestIdempotencyKey = ref('')
|
||||||
|
const confirmIdempotency = reactive({ returnId: undefined, key: '' })
|
||||||
|
const canRequestReturn = computed(() => proxy.$auth.hasPermiAnd(['business:return:query', 'business:return:request']))
|
||||||
|
const canConfirmReturn = computed(() => proxy.$auth.hasPermiAnd(['business:return:query', 'business:return:confirm']))
|
||||||
|
const operationPending = computed(() => requestSubmitLoading.value || confirmSubmitLoading.value)
|
||||||
|
const requestedReturnOptions = computed(() => returnList.value.filter((item) => item.status === 'REQUESTED'))
|
||||||
|
let listGeneration = 0
|
||||||
|
let optionGeneration = 0
|
||||||
|
let detailGeneration = 0
|
||||||
|
let requestScanGeneration = 0
|
||||||
|
let confirmScanGeneration = 0
|
||||||
|
let confirmSubmitGeneration = 0
|
||||||
|
let requestSession = 0
|
||||||
|
let confirmSession = 0
|
||||||
|
|
||||||
|
const queryParams = ref({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
returnNo: undefined,
|
||||||
|
sourceSiteId: undefined,
|
||||||
|
targetWarehouseId: undefined,
|
||||||
|
status: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
function warehouseLabel(id) {
|
||||||
|
const option = warehouseOptions.value.find((item) => item.id === id)
|
||||||
|
return option?.name || (id ? `#${id}` : '—')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentListRequest(listContext) {
|
||||||
|
return listContext.generation === listGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
function getList() {
|
||||||
|
const listContext = {
|
||||||
|
generation: ++listGeneration,
|
||||||
|
query: { ...queryParams.value }
|
||||||
|
};
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
listReturn(listContext.query).then((response) => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
returnList.value = response.rows
|
||||||
|
total.value = response.total
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
returnList.value = []
|
||||||
|
total.value = 0
|
||||||
|
loadError.value = '退回单列表加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateOptionRequest() {
|
||||||
|
optionGeneration += 1
|
||||||
|
optionLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentOptionRequest(optionContext) {
|
||||||
|
return optionContext.generation === optionGeneration && optionContext.requestSession === requestSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReturnWarehouseOptions() {
|
||||||
|
const optionContext = { generation: ++optionGeneration, requestSession }
|
||||||
|
optionLoading.value = true
|
||||||
|
requestError.value = ''
|
||||||
|
getReturnWarehouseOptions().then((response) => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
warehouseOptions.value = Array.isArray(response.data) ? response.data : []
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
warehouseOptions.value = []
|
||||||
|
requestError.value = '目标仓库选项加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
optionLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWarehouseOptionsVisible(visible) {
|
||||||
|
if (!visible || !canRequestReturn.value || operationPending.value || warehouseOptions.value.length) return
|
||||||
|
loadReturnWarehouseOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDetailRequest() {
|
||||||
|
detailGeneration += 1
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentDetailRequest(detailContext) {
|
||||||
|
return detailContext.generation === detailGeneration &&
|
||||||
|
detailContext.confirmSession === confirmSession &&
|
||||||
|
detailContext.returnId === confirmForm.value.returnId
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateRequestScan() {
|
||||||
|
requestScanGeneration += 1
|
||||||
|
scanLoading.request = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateConfirmScan() {
|
||||||
|
confirmScanGeneration += 1
|
||||||
|
scanLoading.confirm = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentRequestScan(scanContext) {
|
||||||
|
return scanContext.generation === requestScanGeneration &&
|
||||||
|
scanContext.requestSession === requestSession &&
|
||||||
|
scanContext.targetWarehouseId === requestForm.value.targetWarehouseId
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentConfirmScan(scanContext) {
|
||||||
|
return scanContext.generation === confirmScanGeneration &&
|
||||||
|
scanContext.confirmSession === confirmSession &&
|
||||||
|
scanContext.returnId === confirmForm.value.returnId
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRequestIdempotencyKey() {
|
||||||
|
requestIdempotencyKey.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRequestIdempotencyKey() {
|
||||||
|
if (!requestIdempotencyKey.value) requestIdempotencyKey.value = createIdempotencyKey()
|
||||||
|
return requestIdempotencyKey.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReturnConfirmIdempotencyKey() {
|
||||||
|
confirmIdempotency.returnId = undefined
|
||||||
|
confirmIdempotency.key = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReturnConfirmIdempotencyKey(returnId) {
|
||||||
|
if (confirmIdempotency.returnId !== returnId) {
|
||||||
|
clearReturnConfirmIdempotencyKey()
|
||||||
|
confirmIdempotency.returnId = returnId
|
||||||
|
}
|
||||||
|
if (!confirmIdempotency.key) confirmIdempotency.key = createIdempotencyKey()
|
||||||
|
return confirmIdempotency.key
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetReturnRequest() {
|
||||||
|
if (operationPending.value) return
|
||||||
|
requestSession += 1
|
||||||
|
invalidateOptionRequest()
|
||||||
|
invalidateRequestScan()
|
||||||
|
clearRequestIdempotencyKey()
|
||||||
|
requestForm.value = { sourceSiteId: undefined, targetWarehouseId: undefined }
|
||||||
|
requestScannedPallets.value = []
|
||||||
|
requestError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetReturnConfirm() {
|
||||||
|
if (operationPending.value) return
|
||||||
|
confirmSession += 1
|
||||||
|
confirmSubmitGeneration += 1
|
||||||
|
invalidateDetailRequest()
|
||||||
|
invalidateConfirmScan()
|
||||||
|
clearReturnConfirmIdempotencyKey()
|
||||||
|
confirmForm.value = { returnId: undefined }
|
||||||
|
confirmExpectedRows.value = []
|
||||||
|
confirmExpectedPalletIds.value = []
|
||||||
|
confirmScannedPallets.value = []
|
||||||
|
confirmError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQuery() {
|
||||||
|
proxy.resetForm('queryRef')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestScan(qrToken) {
|
||||||
|
if (operationPending.value || scanLoading.request || !requestForm.value.targetWarehouseId) {
|
||||||
|
requestError.value = '请先选择退回目标仓库'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
invalidateRequestScan()
|
||||||
|
const scanContext = {
|
||||||
|
generation: ++requestScanGeneration,
|
||||||
|
mode: 'request',
|
||||||
|
requestSession,
|
||||||
|
targetWarehouseId: requestForm.value.targetWarehouseId,
|
||||||
|
qrToken
|
||||||
|
}
|
||||||
|
scanLoading.request = true
|
||||||
|
requestError.value = ''
|
||||||
|
scanReturnPallet(qrToken, {
|
||||||
|
mode: 'request'
|
||||||
|
}).then((response) => {
|
||||||
|
if (!isCurrentRequestScan(scanContext)) return
|
||||||
|
const pallet = response.data
|
||||||
|
if (!pallet.siteId) {
|
||||||
|
requestError.value = '该托盘不在客户现场,不能发起退回'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (requestForm.value.sourceSiteId && pallet.siteId !== requestForm.value.sourceSiteId) {
|
||||||
|
requestError.value = '同一退回申请只能包含同一客户现场的托盘'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (requestScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msgWarning('该托盘已在退回申请中')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!requestForm.value.sourceSiteId) requestForm.value.sourceSiteId = pallet.siteId
|
||||||
|
requestScannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
siteId: pallet.siteId,
|
||||||
|
remainingPackages: pallet.remainingPackages
|
||||||
|
})
|
||||||
|
clearRequestIdempotencyKey()
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentRequestScan(scanContext)) return
|
||||||
|
requestError.value = '退回托盘扫码校验失败,请检查后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentRequestScan(scanContext)) return
|
||||||
|
scanLoading.request = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequestScanInvalid(message) {
|
||||||
|
requestError.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRequestPallet(palletId) {
|
||||||
|
if (operationPending.value) return
|
||||||
|
requestScannedPallets.value = requestScannedPallets.value.filter((item) => item.palletId !== palletId)
|
||||||
|
if (!requestScannedPallets.value.length) requestForm.value.sourceSiteId = undefined
|
||||||
|
clearRequestIdempotencyKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReturnRequest() {
|
||||||
|
if (operationPending.value || scanLoading.request || optionLoading.value) return
|
||||||
|
if (!requestForm.value.sourceSiteId || !requestForm.value.targetWarehouseId || !requestScannedPallets.value.length) {
|
||||||
|
requestError.value = '请选择目标仓库并至少扫描一个退回托盘'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idempotencyKey = ensureRequestIdempotencyKey()
|
||||||
|
requestSubmitLoading.value = true
|
||||||
|
requestError.value = ''
|
||||||
|
requestReturn({
|
||||||
|
sourceSiteId: requestForm.value.sourceSiteId,
|
||||||
|
targetWarehouseId: requestForm.value.targetWarehouseId,
|
||||||
|
palletIds: requestScannedPallets.value.map((item) => item.palletId),
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
}).then((response) => {
|
||||||
|
clearRequestIdempotencyKey()
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '退回申请提交成功')
|
||||||
|
requestSubmitLoading.value = false
|
||||||
|
resetReturnRequest()
|
||||||
|
getList()
|
||||||
|
}).catch(() => {
|
||||||
|
requestError.value = '退回申请提交失败;直接重试将复用本次幂等键'
|
||||||
|
}).finally(() => {
|
||||||
|
requestSubmitLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmReturnSelection(returnId) {
|
||||||
|
if (operationPending.value) return
|
||||||
|
confirmSession += 1
|
||||||
|
confirmSubmitGeneration += 1
|
||||||
|
invalidateDetailRequest()
|
||||||
|
invalidateConfirmScan()
|
||||||
|
clearReturnConfirmIdempotencyKey()
|
||||||
|
confirmExpectedRows.value = []
|
||||||
|
confirmExpectedPalletIds.value = []
|
||||||
|
confirmScannedPallets.value = []
|
||||||
|
confirmError.value = ''
|
||||||
|
if (!returnId) return
|
||||||
|
const selected = returnList.value.find((item) => item.returnId === returnId)
|
||||||
|
if (selected?.status !== 'REQUESTED') {
|
||||||
|
confirmError.value = '只能选择 REQUESTED 状态的退回单'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const detailContext = { generation: ++detailGeneration, confirmSession, returnId }
|
||||||
|
detailLoading.value = true
|
||||||
|
getReturn(returnId).then((response) => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
const detail = response.data
|
||||||
|
if (detail.status !== 'REQUESTED') {
|
||||||
|
confirmError.value = '该退回单已不是待确认状态'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmExpectedRows.value = (Array.isArray(detail.items) ? detail.items : []).map((item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`
|
||||||
|
}))
|
||||||
|
confirmExpectedPalletIds.value = confirmExpectedRows.value.map((item) => item.palletId)
|
||||||
|
if (!confirmExpectedPalletIds.value.length) confirmError.value = '该退回单没有可确认的托盘明细'
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
confirmError.value = '退回单详情加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
detailLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function startConfirmFromRow(row) {
|
||||||
|
if (row.status !== 'REQUESTED') {
|
||||||
|
proxy.$modal.msgWarning('只有 REQUESTED 退回单可以确认')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canConfirmReturn.value || operationPending.value) return
|
||||||
|
confirmForm.value.returnId = row.returnId
|
||||||
|
handleConfirmReturnSelection(row.returnId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmScan(qrToken) {
|
||||||
|
if (operationPending.value || scanLoading.confirm || detailLoading.value || !confirmForm.value.returnId) return
|
||||||
|
invalidateConfirmScan()
|
||||||
|
const scanContext = {
|
||||||
|
generation: ++confirmScanGeneration,
|
||||||
|
mode: 'confirm',
|
||||||
|
confirmSession,
|
||||||
|
returnId: confirmForm.value.returnId,
|
||||||
|
qrToken
|
||||||
|
}
|
||||||
|
scanLoading.confirm = true
|
||||||
|
confirmError.value = ''
|
||||||
|
scanReturnPallet(qrToken, {
|
||||||
|
mode: 'confirm',
|
||||||
|
returnId: scanContext.returnId
|
||||||
|
}).then((response) => {
|
||||||
|
if (!isCurrentConfirmScan(scanContext)) return
|
||||||
|
const pallet = response.data
|
||||||
|
if (!confirmExpectedPalletIds.value.includes(pallet.palletId)) {
|
||||||
|
confirmError.value = '该托盘不属于所选退回单'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (confirmScannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msgWarning('该托盘已完成扫码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmScannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`
|
||||||
|
})
|
||||||
|
clearReturnConfirmIdempotencyKey()
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentConfirmScan(scanContext)) return
|
||||||
|
confirmError.value = '到仓托盘扫码校验失败,请检查后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentConfirmScan(scanContext)) return
|
||||||
|
scanLoading.confirm = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmScanInvalid(message) {
|
||||||
|
confirmError.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfirmPalletScanned(palletId) {
|
||||||
|
return confirmScannedPallets.value.some((item) => item.palletId === palletId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConfirmPallet(palletId) {
|
||||||
|
if (operationPending.value) return
|
||||||
|
confirmScannedPallets.value = confirmScannedPallets.value.filter((item) => item.palletId !== palletId)
|
||||||
|
clearReturnConfirmIdempotencyKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdSet(left, right) {
|
||||||
|
if (left.length !== right.length) return false
|
||||||
|
const leftSet = new Set(left)
|
||||||
|
const rightSet = new Set(right)
|
||||||
|
if (leftSet.size !== left.length || rightSet.size !== right.length) return false
|
||||||
|
return right.every((id) => leftSet.has(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentConfirmSubmit(submitContext) {
|
||||||
|
return submitContext.generation === confirmSubmitGeneration &&
|
||||||
|
submitContext.confirmSession === confirmSession &&
|
||||||
|
submitContext.returnId === confirmForm.value.returnId
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReturnConfirm() {
|
||||||
|
if (operationPending.value || detailLoading.value || scanLoading.confirm) return
|
||||||
|
const returnId = confirmForm.value.returnId
|
||||||
|
const initialExpectedIds = [...confirmExpectedPalletIds.value]
|
||||||
|
const scannedPalletIds = [...confirmScannedPallets.value.map((item) => item.palletId)]
|
||||||
|
if (!returnId || !initialExpectedIds.length || !sameIdSet(initialExpectedIds, scannedPalletIds)) {
|
||||||
|
confirmError.value = '必须逐个扫描并完整匹配退回单全部托盘'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const submitContext = { generation: ++confirmSubmitGeneration, confirmSession, returnId }
|
||||||
|
confirmSubmitLoading.value = true
|
||||||
|
confirmError.value = ''
|
||||||
|
getReturn(returnId).then((response) => {
|
||||||
|
if (!isCurrentConfirmSubmit(submitContext)) throw new Error('STALE_CONFIRM_SUBMIT')
|
||||||
|
const latestDetail = response.data
|
||||||
|
if (latestDetail.status !== 'REQUESTED') {
|
||||||
|
confirmError.value = '退回单状态已变化,请重新选择'
|
||||||
|
throw new Error('RETURN_STATUS_CHANGED')
|
||||||
|
}
|
||||||
|
const latestPalletIds = latestDetail.items.map((item) => item.palletId)
|
||||||
|
if (!sameIdSet(latestPalletIds, initialExpectedIds) || !sameIdSet(latestPalletIds, scannedPalletIds)) {
|
||||||
|
confirmError.value = '退回单明细已变化,或实际扫码集合与最新明细不一致;未提交确认'
|
||||||
|
throw new Error('RETURN_SET_CHANGED')
|
||||||
|
}
|
||||||
|
const idempotencyKey = ensureReturnConfirmIdempotencyKey(returnId)
|
||||||
|
return confirmReturn(returnId, {
|
||||||
|
scannedPalletIds: scannedPalletIds,
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
})
|
||||||
|
}).then((response) => {
|
||||||
|
if (!isCurrentConfirmSubmit(submitContext)) return
|
||||||
|
clearReturnConfirmIdempotencyKey()
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '退回确认成功')
|
||||||
|
confirmSubmitLoading.value = false
|
||||||
|
resetReturnConfirm()
|
||||||
|
getList()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error?.message === 'STALE_CONFIRM_SUBMIT') return
|
||||||
|
if (error?.message === 'RETURN_STATUS_CHANGED' || error?.message === 'RETURN_SET_CHANGED') return
|
||||||
|
if (!isCurrentConfirmSubmit(submitContext)) return
|
||||||
|
confirmError.value = '退回确认失败;直接重试将复用本次幂等键'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentConfirmSubmit(submitContext)) return
|
||||||
|
confirmSubmitLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => requestForm.value.targetWarehouseId,
|
||||||
|
() => {
|
||||||
|
if (!requestSubmitLoading.value) {
|
||||||
|
invalidateRequestScan()
|
||||||
|
clearRequestIdempotencyKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.return-operations {
|
||||||
|
row-gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-card {
|
||||||
|
height: 100%;
|
||||||
|
border-color: var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.return-page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.business-control .el-input__wrapper),
|
||||||
|
:deep(.business-action) {
|
||||||
|
min-height: 52px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-form ref="queryRef" :model="queryParams" :inline="true">
|
||||||
|
<el-form-item label="发货单号" prop="shipmentNo">
|
||||||
|
<el-input v-model="queryParams.shipmentNo" placeholder="请输入发货单号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="来源仓库ID" prop="sourceWarehouseId">
|
||||||
|
<el-input-number v-model="queryParams.sourceWarehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标现场ID" prop="targetSiteId">
|
||||||
|
<el-input-number v-model="queryParams.targetSiteId" :min="1" :controls="false" placeholder="请输入现场ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="单据状态" prop="status">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 150px">
|
||||||
|
<el-option label="草稿" value="DRAFT" />
|
||||||
|
<el-option label="已确认" value="CONFIRMED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:shipment:list']">搜索</el-button>
|
||||||
|
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:shipment:list']">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
v-if="canAddShipment"
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="Plus"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="handleAdd"
|
||||||
|
v-hasPermi="['business:shipment:add']"
|
||||||
|
>新增发货草稿</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-table v-loading="loading" empty-text="暂无数据" :data="shipmentList">
|
||||||
|
<el-table-column label="发货单号" prop="shipmentNo" min-width="170" :show-overflow-tooltip="true" />
|
||||||
|
<el-table-column label="来源仓库" min-width="130">
|
||||||
|
<template #default="scope">{{ optionLabel(warehouseOptions, scope.row.sourceWarehouseId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="目标现场" min-width="130">
|
||||||
|
<template #default="scope">{{ optionLabel(siteOptions, scope.row.targetSiteId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="110" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.status === 'DRAFT' ? 'warning' : 'success'">
|
||||||
|
{{ scope.row.status === 'DRAFT' ? '草稿' : '已确认' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" prop="createTime" width="180">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.createTime) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="250" fixed="right" class-name="small-padding fixed-width">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.status === 'DRAFT' && canEditShipment"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
icon="Edit"
|
||||||
|
:disabled="operationPending || detailLoading"
|
||||||
|
@click="handleUpdate(scope.row)"
|
||||||
|
v-hasPermi="['business:shipment:edit']"
|
||||||
|
>编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.status === 'DRAFT'"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
icon="Delete"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="handleDelete(scope.row)"
|
||||||
|
v-hasPermi="['business:shipment:remove']"
|
||||||
|
>删除</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="scope.row.status === 'DRAFT' && canConfirmShipment"
|
||||||
|
link
|
||||||
|
type="success"
|
||||||
|
icon="CircleCheck"
|
||||||
|
:disabled="operationPending"
|
||||||
|
@click="handleConfirm(scope.row)"
|
||||||
|
v-hasPermi="['business:shipment:confirm']"
|
||||||
|
>确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="open"
|
||||||
|
:title="title"
|
||||||
|
width="min(820px, 95vw)"
|
||||||
|
append-to-body
|
||||||
|
:close-on-click-modal="!operationPending"
|
||||||
|
:close-on-press-escape="!operationPending"
|
||||||
|
:show-close="!operationPending"
|
||||||
|
:before-close="handleDialogBeforeClose"
|
||||||
|
>
|
||||||
|
<el-alert v-if="optionError" :title="optionError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-form ref="shipmentRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="来源仓库" prop="sourceWarehouseId">
|
||||||
|
<el-select
|
||||||
|
v-model="form.sourceWarehouseId"
|
||||||
|
class="business-control"
|
||||||
|
placeholder="请选择授权仓库"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="optionLoading"
|
||||||
|
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady)"
|
||||||
|
>
|
||||||
|
<el-option v-for="option in warehouseOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="目标现场" prop="targetSiteId">
|
||||||
|
<el-select
|
||||||
|
v-model="form.targetSiteId"
|
||||||
|
class="business-control"
|
||||||
|
placeholder="请选择授权现场"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="optionLoading"
|
||||||
|
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady)"
|
||||||
|
>
|
||||||
|
<el-option v-for="option in siteOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<el-form-item label="连续扫码">
|
||||||
|
<pallet-scan-input
|
||||||
|
:disabled="operationPending || optionLoading || detailLoading || (isEdit && !editDetailReady) || !form.sourceWarehouseId || !form.targetSiteId"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleShipmentScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">仅接收来源仓库内有料托盘;剩余包数由后端返回,只读不可修改。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert v-if="scanError" :title="scanError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-table :data="scannedPallets" empty-text="尚未扫描托盘" size="small" max-height="340">
|
||||||
|
<el-table-column label="托盘" min-width="180">
|
||||||
|
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="托盘ID" prop="palletId" width="100" />
|
||||||
|
<el-table-column label="菌种" min-width="150">
|
||||||
|
<template #default="scope">{{ scope.row.strainName || scope.row.strainCode || (scope.row.strainId ? `#${scope.row.strainId}` : '—') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="批次" prop="batchNo" min-width="120" />
|
||||||
|
<el-table-column label="剩余包数(只读)" prop="remainingPackages" width="140" align="right">
|
||||||
|
<template #default="scope">{{ scope.row.remainingPackages ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="operationPending || detailLoading || (isEdit && !editDetailReady)"
|
||||||
|
@click="removeScannedPallet(scope.row.palletId)"
|
||||||
|
>移除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button
|
||||||
|
v-if="!isEdit"
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="operationPending || scanLoading || optionLoading || detailLoading"
|
||||||
|
@click="submitShipment"
|
||||||
|
v-hasPermi="['business:shipment:add']"
|
||||||
|
>保存草稿</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="operationPending || scanLoading || optionLoading || detailLoading || !editDetailReady"
|
||||||
|
@click="saveShipmentUpdate"
|
||||||
|
v-hasPermi="['business:shipment:edit']"
|
||||||
|
>保存修改</el-button>
|
||||||
|
<el-button class="business-action" :disabled="operationPending" @click="cancelShipment">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="BusinessShipment">
|
||||||
|
import PalletScanInput from '@/components/PalletScanInput'
|
||||||
|
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||||
|
import {
|
||||||
|
addShipment,
|
||||||
|
confirmShipment,
|
||||||
|
delShipment,
|
||||||
|
getShipment,
|
||||||
|
getShipmentSiteOptions,
|
||||||
|
getShipmentWarehouseOptions,
|
||||||
|
listShipment,
|
||||||
|
scanShipmentPallet,
|
||||||
|
updateShipment
|
||||||
|
} from '@/api/business/shipment'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
const shipmentList = ref([])
|
||||||
|
const warehouseOptions = ref([])
|
||||||
|
const siteOptions = ref([])
|
||||||
|
const scannedPallets = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const optionLoading = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const scanLoading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const deleteLoading = ref(false)
|
||||||
|
const confirmLoading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const optionError = ref('')
|
||||||
|
const scanError = ref('')
|
||||||
|
const formError = ref('')
|
||||||
|
const open = ref(false)
|
||||||
|
const title = ref('')
|
||||||
|
const total = ref(0)
|
||||||
|
const editDetailReady = ref(false)
|
||||||
|
const isEdit = computed(() => Boolean(form.value.shipmentId))
|
||||||
|
const canAddShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:add']))
|
||||||
|
const canEditShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:edit']))
|
||||||
|
const canConfirmShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:query', 'business:shipment:confirm']))
|
||||||
|
const operationPending = computed(() => submitLoading.value || deleteLoading.value || confirmLoading.value)
|
||||||
|
let listGeneration = 0
|
||||||
|
let optionGeneration = 0
|
||||||
|
let detailGeneration = 0
|
||||||
|
let scanGeneration = 0
|
||||||
|
let dialogSession = 0
|
||||||
|
let hydratingForm = false
|
||||||
|
const confirmIdempotency = reactive({ shipmentId: undefined, key: '' })
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: { shipmentId: undefined, sourceWarehouseId: undefined, targetSiteId: undefined },
|
||||||
|
queryParams: { pageNum: 1, pageSize: 10, shipmentNo: undefined, sourceWarehouseId: undefined, targetSiteId: undefined, status: undefined },
|
||||||
|
rules: {
|
||||||
|
sourceWarehouseId: [{ required: true, message: '请选择来源仓库', trigger: 'change' }],
|
||||||
|
targetSiteId: [{ required: true, message: '请选择目标现场', trigger: 'change' }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { form, queryParams, rules } = toRefs(data)
|
||||||
|
|
||||||
|
function optionLabel(options, id) {
|
||||||
|
const option = options.find((item) => item.id === id)
|
||||||
|
return option?.name || (id ? `#${id}` : '—')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentListRequest(listContext) {
|
||||||
|
return listContext.generation === listGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
function getList() {
|
||||||
|
const listContext = {
|
||||||
|
generation: ++listGeneration,
|
||||||
|
query: { ...queryParams.value }
|
||||||
|
};
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
listShipment(listContext.query).then((response) => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shipmentList.value = response.rows
|
||||||
|
total.value = response.total
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shipmentList.value = []
|
||||||
|
total.value = 0
|
||||||
|
loadError.value = '发货单列表加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateOptionRequest() {
|
||||||
|
optionGeneration += 1
|
||||||
|
optionLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentOptionRequest(optionContext) {
|
||||||
|
return optionContext.generation === optionGeneration && optionContext.dialogSession === dialogSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShipmentOptions() {
|
||||||
|
const optionContext = { generation: ++optionGeneration, dialogSession }
|
||||||
|
optionLoading.value = true
|
||||||
|
optionError.value = ''
|
||||||
|
Promise.all([getShipmentWarehouseOptions(), getShipmentSiteOptions()]).then(([warehouseResponse, siteResponse]) => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
warehouseOptions.value = Array.isArray(warehouseResponse.data) ? warehouseResponse.data : []
|
||||||
|
siteOptions.value = Array.isArray(siteResponse.data) ? siteResponse.data : []
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
warehouseOptions.value = []
|
||||||
|
siteOptions.value = []
|
||||||
|
optionError.value = '仓库或现场选项加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
optionLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDetailRequest() {
|
||||||
|
detailGeneration += 1
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentDetailRequest(detailContext) {
|
||||||
|
return detailContext.generation === detailGeneration && detailContext.dialogSession === dialogSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateScanRequest() {
|
||||||
|
scanGeneration += 1
|
||||||
|
scanLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentScanRequest(scanContext) {
|
||||||
|
return scanContext.generation === scanGeneration &&
|
||||||
|
scanContext.dialogSession === dialogSession &&
|
||||||
|
scanContext.sourceWarehouseId === form.value.sourceWarehouseId &&
|
||||||
|
scanContext.targetSiteId === form.value.targetSiteId
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetShipmentForm() {
|
||||||
|
dialogSession += 1
|
||||||
|
invalidateOptionRequest()
|
||||||
|
invalidateDetailRequest()
|
||||||
|
invalidateScanRequest()
|
||||||
|
form.value = { shipmentId: undefined, sourceWarehouseId: undefined, targetSiteId: undefined }
|
||||||
|
scannedPallets.value = []
|
||||||
|
editDetailReady.value = false
|
||||||
|
optionError.value = ''
|
||||||
|
scanError.value = ''
|
||||||
|
formError.value = ''
|
||||||
|
proxy.resetForm('shipmentRef')
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelShipment() {
|
||||||
|
if (operationPending.value) return
|
||||||
|
open.value = false
|
||||||
|
resetShipmentForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogBeforeClose(done) {
|
||||||
|
if (operationPending.value) return
|
||||||
|
resetShipmentForm()
|
||||||
|
done()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQuery() {
|
||||||
|
proxy.resetForm('queryRef')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
if (!canAddShipment.value || operationPending.value) return
|
||||||
|
resetShipmentForm()
|
||||||
|
title.value = '新增整托发货草稿'
|
||||||
|
open.value = true
|
||||||
|
loadShipmentOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(row) {
|
||||||
|
if (row.status !== 'DRAFT') {
|
||||||
|
proxy.$modal.msgWarning('只有草稿发货单可以编辑')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!canEditShipment.value || operationPending.value) return
|
||||||
|
resetShipmentForm()
|
||||||
|
form.value.shipmentId = row.shipmentId
|
||||||
|
editDetailReady.value = false
|
||||||
|
title.value = '编辑整托发货草稿'
|
||||||
|
open.value = true
|
||||||
|
loadShipmentOptions()
|
||||||
|
const detailContext = { generation: ++detailGeneration, dialogSession, shipmentId: row.shipmentId }
|
||||||
|
detailLoading.value = true
|
||||||
|
getShipment(detailContext.shipmentId).then((response) => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
const detail = response.data;
|
||||||
|
if (detail.status !== 'DRAFT') {
|
||||||
|
formError.value = '该发货单已不是草稿状态,不能编辑'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hydratingForm = true
|
||||||
|
form.value = {
|
||||||
|
shipmentId: detailContext.shipmentId,
|
||||||
|
sourceWarehouseId: detail.sourceWarehouseId,
|
||||||
|
targetSiteId: detail.targetSiteId
|
||||||
|
}
|
||||||
|
scannedPallets.value = (Array.isArray(detail.items) ? detail.items : []).map((item) => ({
|
||||||
|
palletId: item.palletId,
|
||||||
|
palletCode: item.palletCode || `#${item.palletId}`,
|
||||||
|
strainId: item.strainId,
|
||||||
|
strainCode: item.strainCode,
|
||||||
|
strainName: item.strainName,
|
||||||
|
batchNo: item.batchNo,
|
||||||
|
remainingPackages: item.remainingPackages ?? item.packageQty ?? null
|
||||||
|
}))
|
||||||
|
hydratingForm = false
|
||||||
|
editDetailReady.value = true
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
formError.value = '发货单详情加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) return
|
||||||
|
detailLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShipmentScan(qrToken) {
|
||||||
|
if (isEdit.value && !editDetailReady.value) {
|
||||||
|
scanError.value = '发货单详情尚未就绪,不能扫码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value || scanLoading.value || !form.value.sourceWarehouseId || !form.value.targetSiteId) {
|
||||||
|
scanError.value = '请先选择来源仓库和目标现场'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const scanContext = {
|
||||||
|
generation: ++scanGeneration,
|
||||||
|
dialogSession,
|
||||||
|
sourceWarehouseId: form.value.sourceWarehouseId,
|
||||||
|
targetSiteId: form.value.targetSiteId,
|
||||||
|
qrToken
|
||||||
|
}
|
||||||
|
scanLoading.value = true
|
||||||
|
scanError.value = ''
|
||||||
|
scanShipmentPallet(qrToken, {
|
||||||
|
sourceWarehouseId: scanContext.sourceWarehouseId,
|
||||||
|
targetSiteId: scanContext.targetSiteId
|
||||||
|
}).then((response) => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
const pallet = response.data
|
||||||
|
if (pallet.flowState !== 'FILLED_IN_WAREHOUSE' || pallet.remainingPackages <= 0) {
|
||||||
|
scanError.value = '该托盘不是来源仓库内的有料托盘'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (scannedPallets.value.some((item) => item.palletId === pallet.palletId)) {
|
||||||
|
proxy.$modal.msgWarning('该托盘已在当前明细中')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scannedPallets.value.push({
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
strainId: pallet.strainId,
|
||||||
|
strainCode: pallet.strainCode,
|
||||||
|
strainName: pallet.strainName,
|
||||||
|
batchNo: pallet.batchNo,
|
||||||
|
remainingPackages: pallet.remainingPackages
|
||||||
|
})
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
scanError.value = '托盘扫码校验失败,请检查后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
scanLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
scanError.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScannedPallet(palletId) {
|
||||||
|
if (operationPending.value || (isEdit.value && !editDetailReady.value)) return
|
||||||
|
scannedPallets.value = scannedPallets.value.filter((item) => item.palletId !== palletId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addShipmentDraft() {
|
||||||
|
return addShipment({
|
||||||
|
sourceWarehouseId: form.value.sourceWarehouseId,
|
||||||
|
targetSiteId: form.value.targetSiteId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShipmentDraft() {
|
||||||
|
return updateShipment({
|
||||||
|
shipmentId: form.value.shipmentId,
|
||||||
|
sourceWarehouseId: form.value.sourceWarehouseId,
|
||||||
|
targetSiteId: form.value.targetSiteId,
|
||||||
|
palletIds: scannedPallets.value.map((item) => item.palletId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitShipmentForm(editing) {
|
||||||
|
if (editing !== isEdit.value || (editing && !editDetailReady.value)) {
|
||||||
|
formError.value = '发货单编辑详情尚未就绪,不能提交'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value || detailLoading.value) return
|
||||||
|
submitLoading.value = true
|
||||||
|
proxy.$refs.shipmentRef.validate((valid) => {
|
||||||
|
if (!valid) {
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!scannedPallets.value.length) {
|
||||||
|
formError.value = '请至少扫描一个有料托盘'
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formError.value = ''
|
||||||
|
const submitPromise = editing ? updateShipmentDraft() : addShipmentDraft()
|
||||||
|
submitPromise.then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '发货草稿保存成功')
|
||||||
|
open.value = false
|
||||||
|
resetShipmentForm()
|
||||||
|
getList()
|
||||||
|
}).catch(() => {
|
||||||
|
formError.value = editing ? '发货草稿修改失败,请根据提示修正后重试' : '发货草稿新增失败,请根据提示修正后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
submitLoading.value = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitShipment() {
|
||||||
|
submitShipmentForm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveShipmentUpdate() {
|
||||||
|
submitShipmentForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(row) {
|
||||||
|
if (row.status !== 'DRAFT') {
|
||||||
|
proxy.$modal.msgWarning('只有草稿发货单可以删除')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value) return
|
||||||
|
deleteLoading.value = true
|
||||||
|
proxy.$modal.confirm(`是否确认删除发货单“${row.shipmentNo || `#${row.shipmentId}`}”?`).then(() => {
|
||||||
|
return delShipment(String(row.shipmentId))
|
||||||
|
}).then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '删除成功')
|
||||||
|
getList()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error !== 'cancel' && error !== 'close') loadError.value = '发货单删除失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
deleteLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConfirmIdempotency() {
|
||||||
|
confirmIdempotency.shipmentId = undefined
|
||||||
|
confirmIdempotency.key = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfirmIdempotencyKey(shipmentId) {
|
||||||
|
if (confirmIdempotency.shipmentId !== shipmentId) {
|
||||||
|
clearConfirmIdempotency()
|
||||||
|
confirmIdempotency.shipmentId = shipmentId
|
||||||
|
}
|
||||||
|
if (!confirmIdempotency.key) confirmIdempotency.key = createIdempotencyKey()
|
||||||
|
return confirmIdempotency.key
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirmCancellation(error) {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
clearConfirmIdempotency()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm(row) {
|
||||||
|
if (row.status !== 'DRAFT') {
|
||||||
|
proxy.$modal.msgWarning('只有草稿发货单可以确认')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (operationPending.value) return
|
||||||
|
confirmLoading.value = true
|
||||||
|
proxy.$modal.confirm(`确认后发货单“${row.shipmentNo || `#${row.shipmentId}`}”将不可编辑或删除,是否继续?`).then(() => {
|
||||||
|
const idempotencyKey = ensureConfirmIdempotencyKey(row.shipmentId)
|
||||||
|
return confirmShipment(row.shipmentId, {
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
})
|
||||||
|
}).then((response) => {
|
||||||
|
clearConfirmIdempotency()
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '发货确认成功')
|
||||||
|
getList()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error === 'cancel' || error === 'close') {
|
||||||
|
handleConfirmCancellation(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadError.value = '发货确认失败;再次确认会复用本次幂等键'
|
||||||
|
}).finally(() => {
|
||||||
|
confirmLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [form.value.sourceWarehouseId, form.value.targetSiteId],
|
||||||
|
([sourceWarehouseId, targetSiteId], [previousWarehouseId, previousSiteId]) => {
|
||||||
|
if (hydratingForm || (sourceWarehouseId === previousWarehouseId && targetSiteId === previousSiteId)) return
|
||||||
|
invalidateScanRequest()
|
||||||
|
scannedPallets.value = []
|
||||||
|
scanError.value = ''
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
)
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.form-tip {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
:deep(.el-dialog) {
|
||||||
|
width: 95vw !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
float: none;
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__content) {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container usage-page">
|
||||||
|
<el-card v-if="canAddUsage" class="usage-operation" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="usage-operation__header">
|
||||||
|
<span>现场使用 PDA</span>
|
||||||
|
<el-tag type="info">扫码枪 / 手工输入</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert v-if="operationError" :title="operationError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-alert
|
||||||
|
v-if="usageResult"
|
||||||
|
:title="`使用成功:后端返回余量 ${usageResult.remainingPackages} 包,状态 ${usageResult.flowState}`"
|
||||||
|
type="success"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="mb8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="usage-grid">
|
||||||
|
<section class="usage-panel">
|
||||||
|
<div class="usage-label">扫描托盘</div>
|
||||||
|
<pallet-scan-input
|
||||||
|
:disabled="operationPending || optionLoading"
|
||||||
|
:loading="scanLoading"
|
||||||
|
@scan="handleUsageScan"
|
||||||
|
@invalid="handleScanInvalid"
|
||||||
|
@duplicate="handleScanDuplicate"
|
||||||
|
/>
|
||||||
|
<div class="usage-tip">支持 Android 浏览器连接扫码枪后直接回车提交,不调用摄像头。</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="usage-panel usage-panel--summary">
|
||||||
|
<template v-if="scannedPallet">
|
||||||
|
<div class="summary-item"><span>托盘</span><strong>{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}</strong></div>
|
||||||
|
<div class="summary-item"><span>菌种</span><strong>{{ scannedPallet.strainName || scannedPallet.strainCode || (scannedPallet.strainId ? `#${scannedPallet.strainId}` : '—') }}</strong></div>
|
||||||
|
<div class="summary-item"><span>批次</span><strong>{{ scannedPallet.batchNo || '—' }}</strong></div>
|
||||||
|
<div class="summary-item"><span>当前余量</span><strong>{{ scannedPallet.remainingPackages }} 包</strong></div>
|
||||||
|
</template>
|
||||||
|
<el-empty v-else description="等待扫描托盘" :image-size="72" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="usageRef" :model="form" :rules="rules" label-position="top" class="usage-form">
|
||||||
|
<el-row :gutter="18">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="使用产线" prop="lineId">
|
||||||
|
<el-select
|
||||||
|
v-model="form.lineId"
|
||||||
|
class="business-control"
|
||||||
|
placeholder="请先扫描托盘"
|
||||||
|
filterable
|
||||||
|
style="width: 100%"
|
||||||
|
:loading="optionLoading"
|
||||||
|
:disabled="operationPending || lineLocked || optionLoading || !scannedPallet"
|
||||||
|
>
|
||||||
|
<el-option v-for="option in lineOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
|
||||||
|
</el-select>
|
||||||
|
<div v-if="lineLocked" class="usage-tip">托盘已绑定产线,当前操作不可切换。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="本次使用包数" prop="usedPackages">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.usedPackages"
|
||||||
|
class="business-control"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
:disabled="operationPending || !scannedPallet"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="usage-actions">
|
||||||
|
<el-button
|
||||||
|
class="business-action"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="operationPending || scanLoading || optionLoading || !scannedPallet || Boolean(usageResult)"
|
||||||
|
@click="submitUsage"
|
||||||
|
v-hasPermi="['business:usage:add']"
|
||||||
|
>提交使用</el-button>
|
||||||
|
<el-button class="business-action" size="large" :disabled="operationPending" @click="resetUsageOperation">清空重扫</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-divider v-if="canAddUsage" />
|
||||||
|
|
||||||
|
<el-form ref="queryRef" :model="queryParams" :inline="true">
|
||||||
|
<el-form-item label="使用记录号" prop="usageNo">
|
||||||
|
<el-input v-model="queryParams.usageNo" placeholder="请输入使用记录号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="托盘编号" prop="palletCode">
|
||||||
|
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="现场ID" prop="siteId">
|
||||||
|
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="产线ID" prop="lineId">
|
||||||
|
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="请输入产线ID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="Search" :disabled="operationPending" @click="handleQuery" v-hasPermi="['business:usage:list']">搜索</el-button>
|
||||||
|
<el-button icon="Refresh" :disabled="operationPending" @click="resetQuery" v-hasPermi="['business:usage:list']">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
<el-table v-loading="loading" empty-text="暂无数据" :data="usageList">
|
||||||
|
<el-table-column label="使用记录号" prop="usageNo" min-width="170" :show-overflow-tooltip="true" />
|
||||||
|
<el-table-column label="托盘" min-width="120">
|
||||||
|
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="现场ID" prop="siteId" width="100" />
|
||||||
|
<el-table-column label="产线ID" prop="lineId" width="100" />
|
||||||
|
<el-table-column label="使用包数" prop="usedPackages" width="105" align="right" />
|
||||||
|
<el-table-column label="使用后余量" prop="remainingPackagesAfter" width="115" align="right" />
|
||||||
|
<el-table-column label="发生时间" prop="occurredAt" width="180">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="BusinessUsage">
|
||||||
|
import PalletScanInput from '@/components/PalletScanInput'
|
||||||
|
import { createIdempotencyKey } from '@/utils/palletScan'
|
||||||
|
import { addUsage, getUsageLineOptions, listUsage, scanUsagePallet } from '@/api/business/usage'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
const usageList = ref([])
|
||||||
|
const lineOptions = ref([])
|
||||||
|
const scannedPallet = ref(null)
|
||||||
|
const usageResult = ref(null)
|
||||||
|
const lineLocked = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const optionLoading = ref(false)
|
||||||
|
const scanLoading = ref(false)
|
||||||
|
const submitLoading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
const operationError = ref('')
|
||||||
|
const total = ref(0)
|
||||||
|
const usageIdempotencyKey = ref('')
|
||||||
|
const canAddUsage = computed(() => proxy.$auth.hasPermiAnd(['business:usage:query', 'business:usage:add']))
|
||||||
|
const operationPending = computed(() => submitLoading.value)
|
||||||
|
let listGeneration = 0
|
||||||
|
let optionGeneration = 0
|
||||||
|
let scanGeneration = 0
|
||||||
|
let operationSession = 0
|
||||||
|
|
||||||
|
function validatePositiveInteger(rule, value, callback) {
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
callback(new Error('使用包数必须为正整数'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: { qrToken: '', lineId: undefined, usedPackages: undefined },
|
||||||
|
queryParams: { pageNum: 1, pageSize: 10, usageNo: undefined, palletCode: undefined, siteId: undefined, lineId: undefined },
|
||||||
|
rules: {
|
||||||
|
lineId: [{ required: true, message: '请选择使用产线', trigger: 'change' }],
|
||||||
|
usedPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const { form, queryParams, rules } = toRefs(data)
|
||||||
|
|
||||||
|
function isCurrentListRequest(listContext) {
|
||||||
|
return listContext.generation === listGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
function getList() {
|
||||||
|
const listContext = {
|
||||||
|
generation: ++listGeneration,
|
||||||
|
query: { ...queryParams.value }
|
||||||
|
};
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
listUsage(listContext.query).then((response) => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usageList.value = response.rows
|
||||||
|
total.value = response.total
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usageList.value = []
|
||||||
|
total.value = 0
|
||||||
|
loadError.value = '使用记录加载失败,请稍后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateOptionRequest() {
|
||||||
|
optionGeneration += 1
|
||||||
|
optionLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentOptionRequest(optionContext) {
|
||||||
|
return optionContext.generation === optionGeneration &&
|
||||||
|
optionContext.operationSession === operationSession &&
|
||||||
|
optionContext.scanGeneration === scanGeneration &&
|
||||||
|
optionContext.palletId === scannedPallet.value?.palletId
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateScanRequest() {
|
||||||
|
scanGeneration += 1
|
||||||
|
scanLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentScanRequest(scanContext) {
|
||||||
|
return scanContext.generation === scanGeneration && scanContext.operationSession === operationSession
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLineOptions(pallet, scanContext) {
|
||||||
|
const optionContext = {
|
||||||
|
generation: ++optionGeneration,
|
||||||
|
operationSession,
|
||||||
|
scanGeneration: scanContext.generation,
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
siteId: pallet.siteId
|
||||||
|
}
|
||||||
|
optionLoading.value = true
|
||||||
|
getUsageLineOptions(pallet.siteId).then((response) => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
lineOptions.value = Array.isArray(response.data) ? response.data : []
|
||||||
|
if (!lineOptions.value.length) operationError.value = '该现场暂无可用产线'
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
lineOptions.value = []
|
||||||
|
operationError.value = '产线选项加载失败,请稍后重新扫码'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentOptionRequest(optionContext)) return
|
||||||
|
optionLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUsageIdempotencyKey() {
|
||||||
|
usageIdempotencyKey.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUsageIdempotencyKey() {
|
||||||
|
if (!usageIdempotencyKey.value) usageIdempotencyKey.value = createIdempotencyKey()
|
||||||
|
return usageIdempotencyKey.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUsageOperation() {
|
||||||
|
if (operationPending.value) return
|
||||||
|
operationSession += 1
|
||||||
|
invalidateOptionRequest()
|
||||||
|
invalidateScanRequest()
|
||||||
|
clearUsageIdempotencyKey()
|
||||||
|
scannedPallet.value = null
|
||||||
|
usageResult.value = null
|
||||||
|
lineOptions.value = []
|
||||||
|
lineLocked.value = false
|
||||||
|
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
|
||||||
|
operationError.value = ''
|
||||||
|
proxy.resetForm('usageRef')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetQuery() {
|
||||||
|
proxy.resetForm('queryRef')
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUsageScan(qrToken) {
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value) return
|
||||||
|
invalidateOptionRequest()
|
||||||
|
clearUsageIdempotencyKey()
|
||||||
|
scannedPallet.value = null
|
||||||
|
usageResult.value = null
|
||||||
|
lineOptions.value = []
|
||||||
|
lineLocked.value = false
|
||||||
|
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
|
||||||
|
const scanContext = { generation: ++scanGeneration, operationSession, qrToken }
|
||||||
|
scanLoading.value = true
|
||||||
|
operationError.value = ''
|
||||||
|
scanUsagePallet(qrToken).then((response) => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
const pallet = response.data
|
||||||
|
if (!['AT_SITE_UNUSED', 'AT_SITE_PARTIAL'].includes(pallet.flowState) || pallet.remainingPackages <= 0) {
|
||||||
|
operationError.value = '该托盘当前不可记录使用'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scannedPallet.value = {
|
||||||
|
palletId: pallet.palletId,
|
||||||
|
palletCode: pallet.palletCode || `#${pallet.palletId}`,
|
||||||
|
siteId: pallet.siteId,
|
||||||
|
currentLineId: pallet.currentLineId,
|
||||||
|
strainId: pallet.strainId,
|
||||||
|
strainCode: pallet.strainCode,
|
||||||
|
strainName: pallet.strainName,
|
||||||
|
batchNo: pallet.batchNo,
|
||||||
|
remainingPackages: pallet.remainingPackages,
|
||||||
|
flowState: pallet.flowState
|
||||||
|
}
|
||||||
|
form.value.qrToken = qrToken
|
||||||
|
form.value.usedPackages = undefined
|
||||||
|
if (pallet.currentLineId == null) {
|
||||||
|
lineLocked.value = false
|
||||||
|
form.value.lineId = undefined
|
||||||
|
lineOptions.value = []
|
||||||
|
loadLineOptions(pallet, scanContext)
|
||||||
|
} else {
|
||||||
|
lineLocked.value = true
|
||||||
|
form.value.lineId = pallet.currentLineId
|
||||||
|
lineOptions.value = [{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `#${pallet.currentLineId}` }]
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
operationError.value = '托盘扫码校验失败,请检查后重试'
|
||||||
|
}).finally(() => {
|
||||||
|
if (!isCurrentScanRequest(scanContext)) return
|
||||||
|
scanLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanInvalid(message) {
|
||||||
|
invalidateOptionRequest()
|
||||||
|
clearUsageIdempotencyKey()
|
||||||
|
scannedPallet.value = null
|
||||||
|
usageResult.value = null
|
||||||
|
lineOptions.value = []
|
||||||
|
lineLocked.value = false
|
||||||
|
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
|
||||||
|
operationError.value = message
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScanDuplicate() {
|
||||||
|
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUsage() {
|
||||||
|
if (operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return
|
||||||
|
submitLoading.value = true
|
||||||
|
proxy.$refs.usageRef.validate((valid) => {
|
||||||
|
if (!valid) {
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!scannedPallet.value || !form.value.qrToken) {
|
||||||
|
operationError.value = '请先扫描待使用托盘'
|
||||||
|
submitLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const idempotencyKey = ensureUsageIdempotencyKey()
|
||||||
|
operationError.value = ''
|
||||||
|
addUsage({
|
||||||
|
qrToken: form.value.qrToken,
|
||||||
|
lineId: form.value.lineId,
|
||||||
|
usedPackages: form.value.usedPackages,
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
}).then((response) => {
|
||||||
|
const result = response.data;
|
||||||
|
usageResult.value = {
|
||||||
|
remainingPackages: result.remainingPackages,
|
||||||
|
flowState: result.flowState
|
||||||
|
}
|
||||||
|
clearUsageIdempotencyKey()
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '使用记录提交成功')
|
||||||
|
getList()
|
||||||
|
}).catch(() => {
|
||||||
|
operationError.value = '使用提交失败;直接重试将复用本次幂等键'
|
||||||
|
}).finally(() => {
|
||||||
|
submitLoading.value = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [form.value.qrToken, form.value.lineId, form.value.usedPackages],
|
||||||
|
() => {
|
||||||
|
if (!submitLoading.value) clearUsageIdempotencyKey()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.usage-operation {
|
||||||
|
border-color: var(--el-color-primary-light-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-operation__header,
|
||||||
|
.usage-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-operation__header {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-panel {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-label {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tip {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item span {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.usage-page {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-panel {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.business-control .el-input__wrapper),
|
||||||
|
:deep(.business-action) {
|
||||||
|
min-height: 56px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user