feat: 报废前提示并支持快捷入库

This commit is contained in:
WuYongJie 2026-08-11 21:00:36 +08:00
parent 3e7486e760
commit 02b637064c
6 changed files with 802 additions and 22 deletions

View File

@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import prisma from '../../utils/prisma'
import { returnEquipment } from '../borrowController'
import { returnEquipment, returnEquipmentBeforeDisposal } from '../borrowController'
const now = new Date('2026-07-14T06:00:00.000Z')
const root = {
@ -188,3 +188,316 @@ test('单笔归还接口拒绝 keep、set 或单独 department_id,且不访问
;(prisma.equipment.findUnique as any) = originalEquipmentFindUnique
}
})
test('行政可在报废前将唯一未归还资产快捷入库,并保留正式流转记录', async () => {
const originalTransaction = prisma.$transaction
const borrowedRoot = {
...root,
equipment_name: '待报废显示器',
status: '已借出',
total_quantity: 1,
remaining_quantity: 0
}
const borrowRecord = {
id: 601,
equipment_id: borrowedRoot.id,
person_id: 15,
status: '已借出',
children_snapshot: null,
updated_at: now
}
const rootUpdates: any[] = []
const movementReasons: string[] = []
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.includes('FROM borrow_record')) return [borrowRecord]
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [borrowedRoot]
if (sql.includes('WHERE equipment.parent_id IN')) return []
return []
},
equipment: {
findUnique: async () => borrowedRoot,
findMany: async () => [],
updateMany: async (args: any) => {
rootUpdates.push(args)
return { count: 1 }
},
update: async () => borrowedRoot
},
borrowRecord: {
updateMany: async (args: any) => {
if (args.where.id !== borrowRecord.id || borrowRecord.status !== args.where.status) {
return { count: 0 }
}
borrowRecord.status = args.data.status
return { count: 1 }
}
},
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async (args: any) => {
movementReasons.push(args.data.reason)
return { id: 2, items: [] }
}
}
})
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative', name: '行政人员' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await returnEquipmentBeforeDisposal(
{
params: { equipment_id: String(borrowedRoot.id) },
body: { return_condition: '损坏' }
} as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(state.body.message, '资产已入库,可以继续申请报废')
assert.equal(state.body.data.return_condition, '损坏')
assert.equal(borrowRecord.status, '已归还')
assert.equal(rootUpdates.at(-1)?.data.status, '可用')
assert.equal(rootUpdates.at(-1)?.data.department_id, null)
assert.deepEqual(movementReasons, ['报废前快捷入库'])
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('报废前快捷入库遇到多条未归还记录时拒绝自动处理', async () => {
const originalTransaction = prisma.$transaction
const borrowedRoot = { ...root, status: '已借出', total_quantity: 1, remaining_quantity: 0 }
let transactionCount = 0
let businessWriteCount = 0
const activeRecords = [
{ id: 701, equipment_id: root.id, person_id: 1, status: '已借出', children_snapshot: null, updated_at: now },
{ id: 702, equipment_id: root.id, person_id: 2, status: '已借出', children_snapshot: null, updated_at: now }
]
;(prisma.$transaction as any) = async (callback: any) => {
transactionCount += 1
return callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.includes('FROM borrow_record')) return activeRecords
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [borrowedRoot]
if (sql.includes('WHERE equipment.parent_id IN')) return []
return []
},
equipment: {
findUnique: async () => borrowedRoot,
findMany: async () => [],
updateMany: async () => { businessWriteCount += 1; return { count: 1 } },
update: async () => { businessWriteCount += 1; return borrowedRoot }
},
borrowRecord: {
updateMany: async () => { businessWriteCount += 1; return { count: 1 } }
},
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async () => { businessWriteCount += 1; return { id: 2, items: [] } }
}
})
}
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: {} } as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /多条未归还记录/)
assert.equal(transactionCount, 1)
assert.equal(businessWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('报废前快捷入库拒绝未收回或多数量资产,且不会产生业务写入', async () => {
const originalTransaction = prisma.$transaction
let transactionCount = 0
let businessWriteCount = 0
const multiQuantityRoot = {
...root,
status: '已借出',
total_quantity: 2,
remaining_quantity: 1
}
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
;(prisma.$transaction as any) = async (callback: any) => {
transactionCount += 1
return callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [multiQuantityRoot]
if (sql.includes('WHERE equipment.parent_id IN')) return []
return []
},
equipment: {
findUnique: async () => multiQuantityRoot,
findMany: async () => [],
updateMany: async () => { businessWriteCount += 1; return { count: 1 } },
update: async () => { businessWriteCount += 1; return multiQuantityRoot }
},
borrowRecord: { updateMany: async () => { businessWriteCount += 1; return { count: 1 } } },
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async () => { businessWriteCount += 1; return { id: 2, items: [] } }
}
})
}
try {
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: { return_condition: '丢失' } } as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /正常或损坏资产/)
state.statusCode = 200
state.body = null
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: { return_condition: '正常' } } as any,
res as any
)
assert.equal(state.statusCode, 422)
assert.match(state.body.message, /仅支持单件资产/)
assert.equal(transactionCount, 1)
assert.equal(businessWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})
test('借出时无配件但当前新增配件时进入版本确认,配件再变后旧确认不会入库', async () => {
const originalTransaction = prisma.$transaction
const borrowedRoot = { ...root, status: '已借出', total_quantity: 1, remaining_quantity: 0 }
let currentChild = { ...child, id: 802, equipment_code: 'JS-RAM-0802', parent_id: borrowedRoot.id }
const borrowRecord = {
id: 803,
equipment_id: borrowedRoot.id,
person_id: 15,
children_snapshot: null,
updated_at: now
}
let businessWriteCount = 0
;(prisma.$transaction as any) = async (callback: any) => callback({
$queryRaw: async (query: any) => {
const sql = Array.isArray(query.sql) ? query.sql.join(' ') : String(query.sql)
if (sql.includes('FROM borrow_record')) return [borrowRecord]
if (sql.startsWith('SELECT id FROM equipment')) return []
if (sql.includes('WHERE equipment.id =')) return [borrowedRoot]
if (sql.includes('WHERE equipment.parent_id IN')) {
return query.values.includes(borrowedRoot.id) ? [currentChild] : []
}
return []
},
equipment: {
findUnique: async () => borrowedRoot,
findMany: async (args: any) => {
const parentIds = args.where.parent_id.in as number[]
return parentIds.includes(borrowedRoot.id) ? [currentChild] : []
},
updateMany: async () => { businessWriteCount += 1; return { count: 1 } },
update: async () => { businessWriteCount += 1; return currentChild }
},
borrowRecord: { updateMany: async () => { businessWriteCount += 1; return { count: 1 } } },
department: { findUnique: async () => ({ name: '领用部门' }) },
assetTransferRecord: {
create: async () => { businessWriteCount += 1; return { id: 2, items: [] } }
}
})
const state = { statusCode: 200, body: null as any }
const res = {
locals: { user: { id: 8, role: 'administrative' } },
status(code: number) {
state.statusCode = code
return this
},
json(payload: any) {
state.body = payload
return this
}
}
try {
await returnEquipmentBeforeDisposal(
{ params: { equipment_id: String(root.id) }, body: { return_condition: '正常' } } as any,
res as any
)
assert.equal(state.statusCode, 200)
assert.equal(state.body.data.need_confirm, true)
const firstToken = state.body.data.confirmation_token
assert.match(firstToken, /^[a-f0-9]{64}$/)
assert.equal(businessWriteCount, 0)
currentChild = {
...currentChild,
id: 804,
equipment_code: 'JS-RAM-0804',
updated_at: new Date(now.getTime() + 1000)
}
state.body = null
await returnEquipmentBeforeDisposal(
{
params: { equipment_id: String(root.id) },
body: { return_condition: '正常', confirmation_token: firstToken }
} as any,
res as any
)
assert.equal(state.body.data.need_confirm, true)
assert.notEqual(state.body.data.confirmation_token, firstToken)
assert.equal(businessWriteCount, 0)
} finally {
;(prisma.$transaction as any) = originalTransaction
}
})

View File

@ -1,5 +1,6 @@
import { Request, Response } from 'express'
import { Prisma } from '@prisma/client'
import { createHash } from 'crypto'
import prisma from '../utils/prisma'
import { successResponse, errorResponse } from '../utils/response'
import {
@ -15,8 +16,10 @@ import { logOperation } from './operationLogController'
import {
buildRecursiveBorrowSnapshot,
borrowAssetTree,
createAssetMovementRecord,
finalizeBorrowAnomalyReturnState,
finalizeNormalReturnState,
loadAndLockAssetTree,
loadAssetTree,
registerBorrowAnomalyAssetTree,
recordReturnToStorage
@ -338,6 +341,165 @@ const returnEquipmentInTransaction = async (
})
}
type DisposalReturnBorrowRecord = {
id: number
equipment_id: number
person_id: number
children_snapshot: string | null
updated_at: Date
}
const getLockedDisposalChildrenDiff = (
items: Awaited<ReturnType<typeof loadAndLockAssetTree>>,
borrowRecord: DisposalReturnBorrowRecord
) => {
const currentChildren = buildRecursiveBorrowSnapshot(items)
let borrowedChildren: Array<{
id: number
parent_id?: number | null
parent_equipment_id?: number | null
equipment_code?: string
equipment_name?: string
}> = []
if (borrowRecord.children_snapshot) {
try {
const parsed = JSON.parse(borrowRecord.children_snapshot)
if (!Array.isArray(parsed)) throw new Error('配件快照格式错误')
borrowedChildren = parsed
} catch {
throw new Error('借用记录的配件快照无效,无法安全快捷入库,请先使用正式归还流程')
}
}
const borrowedById = new Map(borrowedChildren.map((child) => [Number(child.id), child]))
const currentById = new Map(currentChildren.map((child) => [child.id, child]))
const getParentId = (child: { parent_id?: number | null; parent_equipment_id?: number | null }) =>
child.parent_id ?? child.parent_equipment_id ?? null
const samePlacement = (
left: { parent_id?: number | null; parent_equipment_id?: number | null },
right: { parent_id?: number | null; parent_equipment_id?: number | null }
) => getParentId(left) === getParentId(right)
const removed = borrowedChildren.filter((child) => {
const current = currentById.get(Number(child.id))
return !current || !samePlacement(child, current)
})
const added = currentChildren.filter((child) => {
const borrowed = borrowedById.get(child.id)
return !borrowed || !samePlacement(borrowed, child)
})
return removed.length === 0 && added.length === 0 ? null : { removed, added }
}
const buildDisposalReturnConfirmationToken = (
items: Awaited<ReturnType<typeof loadAndLockAssetTree>>,
borrowRecord: DisposalReturnBorrowRecord
) => createHash('sha256').update(JSON.stringify({
borrow_record: {
id: borrowRecord.id,
equipment_id: borrowRecord.equipment_id,
updated_at: borrowRecord.updated_at.getTime(),
children_snapshot: borrowRecord.children_snapshot
},
asset_tree: items.map((item) => ({
id: item.id,
parent_id: item.parent_id,
department_id: item.department_id,
status: item.status,
mount_status: item.mount_status,
total_quantity: item.total_quantity,
remaining_quantity: item.remaining_quantity,
unit_price: item.unit_price.toString(),
updated_at: item.updated_at.getTime()
}))
})).digest('hex')
const returnEquipmentBeforeDisposalInTransaction = async (input: {
equipmentId: number
returnCondition: string
confirmationToken?: string
operatedBy: number
}) => prisma.$transaction(async (tx) => {
const items = await loadAndLockAssetTree(tx, input.equipmentId)
const root = items[0]
if (root.parent_id !== null) {
throw new Error('挂载中的子资产不能单独入库或申请报废,请先卸载')
}
if (root.status !== '已借出') {
throw new Error(`资产当前为${root.status},无需执行报废前入库`)
}
if (root.total_quantity !== 1 || root.remaining_quantity !== 0) {
throw new Error('快捷入库仅支持单件资产;该资产存在多数量库存,请先逐笔归还后再申请报废')
}
const activeBorrowRecords = await tx.$queryRaw<DisposalReturnBorrowRecord[]>(Prisma.sql`
SELECT id, equipment_id, person_id, children_snapshot, updated_at
FROM borrow_record
WHERE equipment_id = ${root.id} AND status = ${'已借出'}
ORDER BY id
FOR UPDATE
`)
if (activeBorrowRecords.length === 0) {
throw new Error('资产状态为已借出,但没有对应的未归还记录,请先处理借用异常')
}
if (activeBorrowRecords.length > 1) {
throw new Error('该资产存在多条未归还记录,请先逐笔归还后再申请报废')
}
const borrowRecord = activeBorrowRecords[0]
const childrenDiff = getLockedDisposalChildrenDiff(items, borrowRecord)
const confirmationToken = buildDisposalReturnConfirmationToken(items, borrowRecord)
if (childrenDiff && input.confirmationToken !== confirmationToken) {
return {
need_confirm: true as const,
confirmation_token: confirmationToken,
children_diff: childrenDiff
}
}
const notes = childrenDiff
? `报废前快捷入库;配件变更:移除${childrenDiff.removed.length}个,新增${childrenDiff.added.length}个`
: '报废前快捷入库'
await createAssetMovementRecord(tx, {
items,
actionType: 'return',
toDepartmentId: null,
operatedBy: input.operatedBy,
reason: notes
})
const returnTime = new Date()
const updatedBorrowRecord = await tx.borrowRecord.updateMany({
where: {
id: borrowRecord.id,
equipment_id: root.id,
status: '已借出'
},
data: {
return_time: returnTime,
status: '已归还',
return_condition: input.returnCondition,
notes
}
})
if (updatedBorrowRecord.count !== 1) {
throw new Error('该借用记录已归还,请刷新后重试')
}
const returnState = await finalizeNormalReturnState(tx, root)
return {
need_confirm: false as const,
equipment_id: root.id,
equipment_code: root.equipment_code,
borrow_record_id: borrowRecord.id,
return_time: returnTime,
return_condition: input.returnCondition,
status: returnState.status,
children_diff: childrenDiff
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any, operatedBy: number) => {
return prisma.$transaction(async (tx) => {
const { items } = await recordReturnToStorage(tx, {
@ -707,6 +869,53 @@ export const returnEquipment = async (req: Request, res: Response) => {
}
}
// 报废前快捷入库:仅供拥有报废申请权限的流程入口调用。
// 不开放通用借出/归还权限,仍完整复用正式归还事务与资产流转记录。
export const returnEquipmentBeforeDisposal = async (req: Request, res: Response) => {
try {
const equipmentId = parseNumber(req.params.equipment_id)
const returnCondition = String(req.body?.return_condition || '正常').trim()
const confirmationToken = typeof req.body?.confirmation_token === 'string'
? req.body.confirmation_token.trim()
: ''
const userId = Number(res.locals.user?.id)
if (!equipmentId || equipmentId <= 0) {
return errorResponse(res, '资产编号无效', 422)
}
if (!userId) {
return errorResponse(res, '用户未登录', 401)
}
if (!['正常', '损坏'].includes(returnCondition)) {
return errorResponse(res, '报废前快捷入库仅支持已收回的正常或损坏资产', 422)
}
if (confirmationToken && !/^[a-f0-9]{64}$/.test(confirmationToken)) {
return errorResponse(res, '配件确认凭据无效,请重新确认', 422)
}
const result = await returnEquipmentBeforeDisposalInTransaction({
equipmentId,
returnCondition,
confirmationToken,
operatedBy: userId
})
if (result.need_confirm) {
return successResponse(res, {
need_confirm: true,
confirmation_token: result.confirmation_token,
children_diff: result.children_diff,
message: '当前挂载配件与借出时不一致,请确认后再入库'
}, '配件差异提醒')
}
successResponse(res, result, '资产已入库,可以继续申请报废')
} catch (error: any) {
console.error('[报废前快捷入库失败]', error?.message || error)
const status = error.status || (error?.message === '资产不存在' ? 404 : 422)
errorResponse(res, error.message || '报废前快捷入库失败', status)
}
}
// 扫码入库
export const scanReturn = async (req: Request, res: Response) => {
try {

View File

@ -7,6 +7,7 @@ import {
completeDisposal,
getEquipmentDisposalHistory
} from '../controllers/disposalController'
import { returnEquipmentBeforeDisposal } from '../controllers/borrowController'
import { protect, requirePermission } from '../middleware/auth'
import { PERMISSIONS } from '../utils/permissions'
@ -15,6 +16,11 @@ const router = Router()
router.use(protect())
router.post('/', requirePermission(PERMISSIONS.DISPOSALS_APPLY), createDisposal)
router.get('/list', requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW), getDisposalList)
router.post(
'/equipment/:equipment_id/return-to-storage',
requirePermission(PERMISSIONS.DISPOSALS_APPLY),
returnEquipmentBeforeDisposal
)
router.get('/equipment/:equipment_id/history', requirePermission(PERMISSIONS.ASSETS_VIEW_ALL), getEquipmentDisposalHistory)
router.get('/:id', requirePermission(PERMISSIONS.NOTIFICATIONS_VIEW), getDisposalDetail)
router.put('/:id/approve', requirePermission(PERMISSIONS.DISPOSALS_APPROVE), approveDisposal)

View File

@ -75,6 +75,24 @@ export interface CreateDisposalParams {
notes?: string
}
export interface DisposalReturnChildrenDiff {
removed: Array<{ id?: number; equipment_code?: string; equipment_name?: string }>
added: Array<{ id?: number; equipment_code?: string; equipment_name?: string }>
}
export interface DisposalReturnPreparationResult {
need_confirm?: boolean
message?: string
confirmation_token?: string
children_diff?: DisposalReturnChildrenDiff | null
equipment_id?: number
equipment_code?: string
borrow_record_id?: number
return_time?: string
return_condition?: string
status?: string
}
export interface DisposalSummary {
total: number
pending: number
@ -93,6 +111,16 @@ export interface DisposalListData {
export const createDisposal = (data: CreateDisposalParams) => request.post('/disposal', data)
export const returnEquipmentBeforeDisposal = (equipmentId: number, data: {
return_condition: '正常' | '损坏'
confirmation_token?: string
}) => request
.post<ApiResponse<DisposalReturnPreparationResult>>(
`/disposal/equipment/${equipmentId}/return-to-storage`,
data
)
.then((response) => response.data)
export const getDisposalList = (params: {
page?: number
pageSize?: number

View File

@ -300,7 +300,7 @@
<section v-if="isEdit && canViewDisposals" id="disposal-section" class="card">
<div class="list-header">
<h3 class="section-title">报废记录</h3>
<button v-if="canStartDisposal" type="button" class="btn-secondary text-sm text-status-error" @click="openDisposalForm">申请报废</button>
<button v-if="canOpenDisposalAction" type="button" class="btn-secondary text-sm text-status-error" @click="openDisposalForm">申请报废</button>
</div>
<div v-if="disposalRecords.length === 0" class="empty-state">暂无报废记录</div>
<div v-else class="space-y-3">
@ -365,10 +365,89 @@
</div>
</div>
<!-- 报废前置处理提示 -->
<div
v-if="showDisposalPrerequisite && canApplyDisposal"
class="sheet-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="disposal-prerequisite-title"
>
<div class="sheet-panel max-w-lg">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 id="disposal-prerequisite-title" class="text-xl font-semibold text-text-primary">{{ disposalPrerequisiteTitle }}</h3>
<p class="mt-2 text-sm text-text-secondary">{{ disposalBlockReason }}</p>
</div>
<span class="badge badge-warning">当前状态:{{ currentEquipment?.status }}</span>
</div>
<dl v-if="currentEquipment" class="mt-5 grid gap-4 border-y border-border py-4 sm:grid-cols-2">
<div>
<dt class="text-xs text-text-tertiary">资产</dt>
<dd class="mt-1 text-sm font-medium text-text-primary">{{ currentEquipment.equipment_name }}</dd>
<dd class="mt-1 font-mono text-xs text-text-tertiary">{{ currentEquipment.equipment_code }}</dd>
</div>
<div v-if="activeBorrowRecord">
<dt class="text-xs text-text-tertiary">当前使用者</dt>
<dd class="mt-1 text-sm font-medium text-text-primary">{{ currentBorrowerLabel }}</dd>
<dd class="mt-1 text-xs text-text-tertiary">借出时间:{{ formatDateTime(activeBorrowRecord.borrow_time) }}</dd>
</div>
</dl>
<div v-if="canQuickReturnBeforeDisposal" class="mt-5 space-y-4">
<div class="rounded-lg border border-status-warning/30 bg-status-warning/10 p-3">
<p class="text-sm font-medium text-status-warning">确认实物已经收回后再继续</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">系统会结束当前未归还记录并完成入库,然后自动打开该资产的报废申请表。</p>
</div>
<div class="field-stack">
<label class="field-label" for="disposal-return-condition">入库状况</label>
<select id="disposal-return-condition" v-model="disposalReturnCondition" class="select">
<option value="正常">正常</option>
<option value="损坏">损坏</option>
</select>
</div>
<div v-if="disposalReturnChildrenDiff" class="rounded-lg border border-status-warning/30 bg-bg-elevated p-3" role="alert">
<p class="text-sm font-medium text-text-primary">挂载配件与借出时不一致</p>
<p class="mt-1 text-xs leading-5 text-text-secondary">
移除 {{ disposalReturnChildrenDiff.removed.length }} 个,新增 {{ disposalReturnChildrenDiff.added.length }} 个。再次确认后,将按当前挂载情况入库。
</p>
</div>
</div>
<p v-if="disposalReturnError" class="mt-4 text-sm text-status-error" role="alert">{{ disposalReturnError }}</p>
<div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button type="button" class="btn-secondary w-full sm:w-auto" :disabled="disposalReturnLoading" @click="closeDisposalPrerequisite">暂不处理</button>
<button
v-if="canQuickReturnBeforeDisposal"
type="button"
class="btn-primary w-full sm:w-auto"
:disabled="disposalReturnLoading"
:aria-busy="disposalReturnLoading"
@click="submitReturnBeforeDisposal"
>
{{ disposalReturnLoading ? '正在入库...' : disposalReturnChildrenDiff ? '确认配件变化并入库' : '确认入库,继续报废' }}
</button>
</div>
</div>
</div>
<!-- 报废申请弹窗 -->
<div v-if="showDisposalForm && canApplyDisposal" class="sheet-backdrop">
<div
v-if="showDisposalForm && canApplyDisposal"
class="sheet-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="disposal-form-title"
>
<div class="sheet-panel max-w-2xl">
<h3 class="text-xl font-semibold text-text-primary mb-4">申请报废</h3>
<h3 id="disposal-form-title" class="mb-4 text-xl font-semibold text-text-primary">申请报废</h3>
<div v-if="disposalPreparationNotice" class="mb-4 rounded-lg border border-status-success/30 bg-status-success/10 p-3" role="status">
<p class="text-sm font-medium text-status-success">{{ disposalPreparationNotice }}</p>
</div>
<div class="space-y-4">
<div class="field-stack">
<label class="field-label">本次报废资产 *</label>
@ -418,9 +497,12 @@
<textarea v-model="disposalForm.notes" class="input" rows="2" placeholder="其他说明"></textarea>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button class="btn-secondary" @click="showDisposalForm = false">取消</button>
<button class="btn-danger" @click="submitDisposal" :disabled="!canSubmitDisposal">提交申请</button>
<p v-if="disposalSubmitError" class="mt-4 text-sm text-status-error" role="alert">{{ disposalSubmitError }}</p>
<div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button type="button" class="btn-secondary w-full sm:w-auto" :disabled="disposalSubmitting" @click="showDisposalForm = false">取消</button>
<button type="button" class="btn-danger w-full sm:w-auto" @click="submitDisposal" :disabled="!canSubmitDisposal || disposalSubmitting" :aria-busy="disposalSubmitting">
{{ disposalSubmitting ? '正在提交...' : '提交申请' }}
</button>
</div>
</div>
</div>
@ -438,8 +520,21 @@ import { getAssetTypeOptions } from '@/api/assetType'
import { getDepartmentOptions } from '@/api/department'
import { batchTransfer } from '@/api/batch'
import { getMaintenanceList, createMaintenance } from '@/api/maintenance'
import { getEquipmentDisposalHistory, createDisposal } from '@/api/disposal'
import type { Equipment, StorageLocation, AssetType, Department, CodeGenerationMode, EquipmentStatus } from '@/types'
import {
getEquipmentDisposalHistory,
createDisposal,
returnEquipmentBeforeDisposal,
type DisposalReturnChildrenDiff
} from '@/api/disposal'
import type {
Equipment,
EquipmentCurrentBorrow,
StorageLocation,
AssetType,
Department,
CodeGenerationMode,
EquipmentStatus
} from '@/types'
import { formatCurrency, formatDateTime } from '@/utils/format'
import CameraScannerModal from '@/components/CameraScannerModal.vue'
import InfoHint from '@/components/InfoHint.vue'
@ -509,11 +604,21 @@ let bundleChildSeed = 0
const maintenanceRecords = ref<any[]>([])
const disposalRecords = ref<any[]>([])
const showMaintenanceForm = ref(false)
const showDisposalPrerequisite = ref(false)
const showDisposalForm = ref(false)
const showBarcodePrint = ref(false)
const currentEquipment = ref<Equipment | null>(null)
type EquipmentDetail = Equipment & { borrow_records?: EquipmentCurrentBorrow[] }
const currentEquipment = ref<EquipmentDetail | null>(null)
const selectedDisposalItemIds = ref<number[]>([])
const departmentTransferLoading = ref(false)
const disposalReturnLoading = ref(false)
const disposalReturnError = ref('')
const disposalReturnCondition = ref<'正常' | '损坏'>('正常')
const disposalReturnChildrenDiff = ref<DisposalReturnChildrenDiff | null>(null)
const disposalReturnConfirmationToken = ref('')
const disposalPreparationNotice = ref('')
const disposalSubmitting = ref(false)
const disposalSubmitError = ref('')
interface DisposalTreeItem {
id: number
@ -539,6 +644,53 @@ const canPrintLabels = computed(() => authStore.can(PERMISSIONS.ASSETS_PRINT_LAB
const canOverrideCorrectionRestrictions = computed(() => authStore.can(PERMISSIONS.SYSTEM_SETTINGS))
const hasActiveMaintenance = computed(() => maintenanceRecords.value.some((record) => record.status === 'in_progress'))
const hasActiveDisposal = computed(() => disposalRecords.value.some((record) => record.status === 'pending' || record.status === 'approved'))
const activeBorrowRecord = computed<EquipmentCurrentBorrow | null>(() => {
const equipment = currentEquipment.value
if (!equipment) return null
return equipment.current_borrow || equipment.borrow_records?.[0] || null
})
const currentBorrowerLabel = computed(() => {
const record = activeBorrowRecord.value
if (!record) return '未识别'
const nickname = record.person?.nickname || record.person_nickname
const realName = record.person?.real_name || record.person_name
if (nickname && realName && nickname !== realName) return `${nickname}(${realName})`
return nickname || realName || `员工 #${record.person_id}`
})
const disposalBlockReason = computed(() => {
const equipment = currentEquipment.value
if (!equipment) return '资产信息尚未加载完成,请稍后重试。'
if (equipment.parent_id) return '该资产仍挂载在主资产下,不能单独申请报废。请先卸载后再处理。'
if (equipment.status === '已借出') {
if (equipment.total_quantity !== 1 || equipment.remaining_quantity !== 0) {
return '该资产是多数量库存,不能自动判断要归还哪一件。请先逐笔归还,再申请报废。'
}
if (!activeBorrowRecord.value) {
return '资产显示为已借出,但系统没有找到唯一的未归还记录,无法自动入库。请先处理借用异常。'
}
return `该资产当前仍由“${currentBorrowerLabel.value}”领用。报废前必须先确认实物已收回并完成入库。`
}
if (equipment.status === '维护中' || hasActiveMaintenance.value) {
return '该资产正在维修中,请先完成或取消维修,再申请报废。'
}
if (equipment.status === '待报废' || hasActiveDisposal.value) {
return '该资产已有进行中的报废申请,请在下方报废记录中查看当前进度。'
}
if (equipment.status === '已报废') return '该资产已经完成报废,不能重复发起申请。'
return ''
})
const disposalPrerequisiteTitle = computed(() =>
currentEquipment.value?.status === '已借出' ? '报废前需先入库' : '暂时不能申请报废'
)
const canQuickReturnBeforeDisposal = computed(() =>
currentEquipment.value?.status === '已借出' &&
currentEquipment.value.total_quantity === 1 &&
currentEquipment.value.remaining_quantity === 0 &&
Boolean(activeBorrowRecord.value)
)
const canOpenDisposalAction = computed(() =>
Boolean(canApplyDisposal.value && currentEquipment.value && !currentEquipment.value.parent_id && currentEquipment.value.status !== '已报废')
)
const canEditBasic = computed(() => {
if (!isEdit.value) return canCreate.value
if (!canCorrect.value) return false
@ -561,8 +713,8 @@ const canStartMaintenance = computed(() => {
return !['已借出', '维护中', '待报废', '已报废'].includes(currentEquipment.value.status)
})
const canStartDisposal = computed(() => {
if (!canApplyDisposal.value || !currentEquipment.value || currentEquipment.value.parent_id) return false
return !['已借出', '维护中', '待报废', '已报废'].includes(currentEquipment.value.status)
if (!canApplyDisposal.value || !currentEquipment.value) return false
return !disposalBlockReason.value
})
const maintenanceForm = ref({ issue_description: '', repair_vendor: '', estimated_cost: 0, expected_end_date: '' })
@ -822,7 +974,7 @@ const updateCanBeParent = () => {
canBeParent.value = assetType?.can_be_parent ?? false
}
const fetchEquipmentData = async () => {
const fetchEquipmentData = async (throwOnError = false) => {
if (!isEdit.value) return
try {
const response = await getEquipmentDetail(Number(route.params.id))
@ -852,7 +1004,10 @@ const fetchEquipmentData = async () => {
lastGeneratedCode.value = response.data.code_generation_mode === 'auto' ? response.data.equipment_code : ''
updateCanBeParent()
}
} catch (error) { console.error('获取资产详情失败:', error) }
} catch (error) {
console.error('获取资产详情失败:', error)
if (throwOnError) throw error
}
}
const fetchMaintenanceRecords = async () => {
@ -924,12 +1079,68 @@ const submitMaintenance = async () => {
} catch (error: any) { alert(error?.message || '添加维修记录失败') }
}
const openDisposalForm = () => {
if (!canStartDisposal.value || !currentEquipment.value) return
const beginDisposalForm = () => {
if (!currentEquipment.value) return
selectedDisposalItemIds.value = [currentEquipment.value.id]
disposalSubmitError.value = ''
showDisposalForm.value = true
}
const resetDisposalPrerequisite = () => {
disposalReturnError.value = ''
disposalReturnCondition.value = '正常'
disposalReturnChildrenDiff.value = null
disposalReturnConfirmationToken.value = ''
}
const closeDisposalPrerequisite = () => {
if (disposalReturnLoading.value) return
showDisposalPrerequisite.value = false
resetDisposalPrerequisite()
}
const openDisposalForm = () => {
if (!canApplyDisposal.value || !currentEquipment.value) return
disposalPreparationNotice.value = ''
if (!canStartDisposal.value) {
resetDisposalPrerequisite()
showDisposalPrerequisite.value = true
return
}
beginDisposalForm()
}
const submitReturnBeforeDisposal = async () => {
if (!currentEquipment.value || !canQuickReturnBeforeDisposal.value || disposalReturnLoading.value) return
disposalReturnLoading.value = true
disposalReturnError.value = ''
try {
const response = await returnEquipmentBeforeDisposal(currentEquipment.value.id, {
return_condition: disposalReturnCondition.value,
confirmation_token: disposalReturnConfirmationToken.value || undefined
})
if (response.data?.need_confirm) {
disposalReturnChildrenDiff.value = response.data.children_diff || { removed: [], added: [] }
disposalReturnConfirmationToken.value = response.data.confirmation_token || ''
return
}
await fetchEquipmentData(true)
if (currentEquipment.value?.status !== '可用') {
throw new Error('资产已完成入库,但当前状态尚未恢复为可用,请刷新后重试。')
}
showDisposalPrerequisite.value = false
disposalReturnChildrenDiff.value = null
disposalReturnConfirmationToken.value = ''
disposalPreparationNotice.value = '资产已入库,可以继续填写并提交报废申请。'
beginDisposalForm()
} catch (error: any) {
disposalReturnError.value = error?.message || '资产入库失败,请核对当前借用记录后重试。'
} finally {
disposalReturnLoading.value = false
}
}
const toggleDisposalItem = (id: number) => {
if (currentEquipment.value?.id === id) return
selectedDisposalItemIds.value = selectedDisposalItemIds.value.includes(id)
@ -938,7 +1149,9 @@ const toggleDisposalItem = (id: number) => {
}
const submitDisposal = async () => {
if (!canSubmitDisposal.value) return
if (!canSubmitDisposal.value || disposalSubmitting.value) return
disposalSubmitting.value = true
disposalSubmitError.value = ''
try {
const response = await createDisposal({
equipment_id: Number(route.params.id),
@ -953,7 +1166,11 @@ const submitDisposal = async () => {
alert('报废申请已提交')
const disposalId = response.data?.data?.id
if (disposalId) await router.push(`/workflows/disposals/${disposalId}`)
} catch (error: any) { alert(error?.message || '提交报废申请失败') }
} catch (error: any) {
disposalSubmitError.value = error?.message || '提交报废申请失败,请检查填写内容后重试。'
} finally {
disposalSubmitting.value = false
}
}
const getDisposalStatusClass = (status: string) => {
@ -1104,7 +1321,7 @@ onMounted(async () => {
if (route.query.action === 'maintenance' && canStartMaintenance.value) {
showMaintenanceForm.value = true
}
if (route.query.action === 'disposal' && canStartDisposal.value) {
if (route.query.action === 'disposal' && canOpenDisposalAction.value) {
openDisposalForm()
}
})

View File

@ -242,7 +242,7 @@
</button>
<button v-if="canHandleBorrowAnomaly(equipment)" @click="openBorrowAnomalyHandlerForAsset(equipment)" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">异常处理</button>
<button v-if="canMaintainAsset(equipment)" @click="openEquipmentAction(equipment.id, 'maintenance')" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">发起维修</button>
<button v-if="canDisposeAsset(equipment)" @click="openEquipmentAction(equipment.id, 'disposal')" class="rounded-lg px-3 py-2 text-left text-xs text-status-error hover:bg-bg-hover">申请报废</button>
<button v-if="canDisposeAsset(equipment)" @click="openEquipmentAction(equipment.id, 'disposal')" class="rounded-lg px-3 py-2 text-left text-xs text-status-error hover:bg-bg-hover">{{ getDisposalActionLabel(equipment, 'long') }}</button>
<button @click="handleEdit(equipment.id)" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">{{ canCorrect ? '纠错资料' : '查看详情' }}</button>
<button v-if="canPrintLabels" @click="handlePrintBarcode(equipment.id)" class="rounded-lg px-3 py-2 text-left text-xs text-text-secondary hover:bg-bg-hover">打印标签</button>
<button v-if="canDelete" @click="handleDelete(equipment.id)" class="rounded-lg px-3 py-2 text-left text-xs text-status-error hover:bg-bg-hover">删除</button>
@ -344,7 +344,7 @@
归还
</button>
<button v-if="canMaintainAsset(equipment)" @click.stop="openEquipmentAction(equipment.id, 'maintenance')" class="table-action">维修</button>
<button v-if="canDisposeAsset(equipment)" @click.stop="openEquipmentAction(equipment.id, 'disposal')" class="table-action table-action--danger">报废</button>
<button v-if="canDisposeAsset(equipment)" @click.stop="openEquipmentAction(equipment.id, 'disposal')" class="table-action table-action--danger">{{ getDisposalActionLabel(equipment, 'short') }}</button>
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">{{ canCorrect ? '纠错' : '详情' }}</button>
<button v-if="canPrintLabels" @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
<button v-if="canDelete" @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
@ -1867,7 +1867,14 @@ const canHandleBorrowAnomaly = (equipment: Equipment) => canBorrow.value && hasB
const canMaintainAsset = (equipment: Equipment) => canMaintain.value && !equipment.parent_id && !['已借出', '维护中', '待报废', '已报废'].includes(equipment.status)
const canDisposeAsset = (equipment: Equipment) => canApplyDisposal.value && !equipment.parent_id && !['已借出', '维护中', '待报废', '已报废'].includes(equipment.status)
const canDisposeAsset = (equipment: Equipment) =>
canApplyDisposal.value && !equipment.parent_id && equipment.status !== '已报废'
const getDisposalActionLabel = (equipment: Equipment, variant: 'short' | 'long') => {
if (equipment.status === '已借出') return variant === 'short' ? '入库后报废' : '入库后申请报废'
if (['维护中', '待报废'].includes(equipment.status)) return variant === 'short' ? '报废限制' : '查看报废限制'
return variant === 'short' ? '报废' : '申请报废'
}
const openSingleBorrowModal = (equipment: Equipment) => {
startSingleAction('borrow', equipment)