From ece5dd8d727f306464c0914b7d1e54f140f78800 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 09:18:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(borrow):=20=E5=89=8D=E7=AB=AF=E8=BD=AC?= =?UTF-8?q?=E4=BA=A4/=E6=B5=81=E8=BD=AC=E6=98=8E=E7=BB=86=20UI=20=E4=B8=8E?= =?UTF-8?q?=E5=80=9F=E7=94=A8=E4=BA=BA=E9=80=89=E4=BA=BA=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 转交 UI(借还记录页) ---- · 操作列新增【转交】,v-permission="'borrow_transfer'" + 按钮 loading 防抖 · 弹窗明示「仅支持整单全部转交」与「转交后须由接收人本人归还」,并在确认框 复述接收人与转交数量 · 列表是 borrow_no 主子表结构,trans_borrow.id 在**明细行**上,而一张单的明细 可各有不同持有人(转交是逐条明细进行的),故弹窗先让用户选定明细;单条明细 时自动选中(实测 54/60 的单号只有 1 条,等于零额外点击)。 该「单号按钮 → 弹窗选明细」范式沿用本页既有的「申请报废」。 流转明细时间线 ---- · 操作列新增【流转记录】,无特殊权限要求(页面本身已由 op_records 把守) · Drawer + el-timeline 倒序展示 借出 → 转交(可多次) → 归还 → 报废, 每节点显示时间、动作、当事人(借用人 / 转出→接收 / 实际归还人)与经手库管, 转交备注一并展示 · 主列表新增「当前持有人」列:转交后可能与「借用人」不是同一人;一张单的明细 持有人可能不同,去重后逐个展示,已全部回库显示「已回库」 身份锚点适配 ---- · 借出页:借用人由「自由输入姓名」改为「用户下拉」,提交 borrower_id (姓名无法唯一锚定一个人,重名即责任链断裂) · 归还页:新增「实际归还人」下拉,默认取首件物品的当前持有人,提交 returner_id; 主表新增「当前持有人」列提示库管该由谁来还 · 错误提示不再本地重复弹出 —— request 拦截器已按后端 msg 全局 toast, 再弹一次会双份 验证:vite build 通过;接口契约经后端回归用例校验。 --- inventory-web/src/api/transaction.ts | 71 ++++- .../src/views/transaction/borrow.vue | 51 +++- .../src/views/transaction/records.vue | 278 +++++++++++++++++- .../src/views/transaction/return.vue | 66 ++++- 4 files changed, 452 insertions(+), 14 deletions(-) diff --git a/inventory-web/src/api/transaction.ts b/inventory-web/src/api/transaction.ts index 1d4d277..274e7ee 100644 --- a/inventory-web/src/api/transaction.ts +++ b/inventory-web/src/api/transaction.ts @@ -215,12 +215,18 @@ export function getBorrowStockList(params: { page?: number; pageSize?: number; k /** * 执行借库扣减(审批通过后调用) - * @param data approval_id + 扫码选中的物品 + 借用人信息 + 签名 + * + * ★ borrower_id 为**必填**:借用人身份从「姓名」升级为「ID 锚点」后, + * 后端以 borrower_id 反查 sys_user 生成姓名快照;缺失会被直接拒绝。 + * borrower_name 仅作兼容保留,后端不采信。 + * + * @param data approval_id + 扫码选中的物品 + 借用人ID + 签名 */ export function dispatchBorrow(data: { approval_id: number items: Array - borrower_name: string + borrower_id: number + borrower_name?: string signature_path: string remark?: string expected_return_time?: string | null @@ -232,6 +238,67 @@ export function dispatchBorrow(data: { }) } +/** + * 借库责任链人员名单(借用人 / 转交接收人 / 实际归还人共用) + * @returns [{ id, name }] + */ +export function getBorrowUsers() { + return request({ + url: '/v1/transactions/borrow/users', + method: 'get' + }) +} + +/** + * 借库转交(一期:仅支持整单全量转交) + * + * ★ transfer_qty 必须严格等于待还量,部分转交会被后端拒绝 —— + * 单行模型无法同时追踪两个持有人。 + * ★ 转交是纯持有权变更,不触碰任何库存字段。 + * + * @param borrowId trans_borrow.id + * @param data transfer_qty + to_user_id + remark + */ +export function transferBorrow(borrowId: number, data: { + transfer_qty: number + to_user_id: number + to_user_name?: string + remark?: string +}) { + return request({ + url: `/v1/transactions/borrow/${borrowId}/transfer`, + method: 'post', + data + }) +} + +/** + * 借出单的流转历史(**单品**维度):转交链 + 逐次归还明细 + * @returns { record, transfers[], returns[] } + */ +export function getBorrowHistory(borrowId: number) { + return request({ + url: `/v1/transactions/borrow/${borrowId}/history`, + method: 'get' + }) +} + +/** + * 整单流转时间线(**borrow_no** 维度):借出 → 转交(可多次) → 归还 → 报废 + * + * ★ 与 getBorrowHistory 的分工:列表页是 borrow_no 主子表结构(单张单最多 + * 21 条明细),逐条明细调用单品接口会产生 21 个请求,且各条时间线无法全局 + * 排序。本接口一次返回合并后的事件流,按时间**倒序**。 + * + * @returns { borrow_no, events[], records[] } + */ +export function getBorrowSlipHistory(borrowNo: string) { + return request({ + url: `/v1/transactions/borrow/slip/${encodeURIComponent(borrowNo)}/history`, + method: 'get' + }) +} + /** * 提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。 * diff --git a/inventory-web/src/views/transaction/borrow.vue b/inventory-web/src/views/transaction/borrow.vue index 2bf76dc..1791641 100644 --- a/inventory-web/src/views/transaction/borrow.vue +++ b/inventory-web/src/views/transaction/borrow.vue @@ -207,8 +207,25 @@ - - + + + + + @@ -360,7 +377,7 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue' import QrScanner from '@/components/QrScanner/index.vue' import { getStockByBarcode, getStockAlternatives, getScanDraft, saveScanDraft, clearScanDraft } from '@/api/outbound' -import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction' +import { dispatchBorrow, getBorrowApprovalList, getBorrowUsers } from '@/api/transaction' import { uploadFile } from '@/api/common/upload' import { useUserStore } from '@/stores/user' @@ -709,13 +726,31 @@ const lastX = ref(0) const lastY = ref(0) const form = reactive({ - borrower_name: '', + borrower_id: null as number | null, // ★ 唯一身份锚点,提交给后端 + borrower_name: '', // 由所选用户派生,仅用于本地展示 expected_return_time: '', remark: '' }) +// ★ 借用人名单:与转交接收人、归还人共用同一数据源,按公司隔离 +const borrowerOptions = ref>([]) + +const loadBorrowerOptions = async () => { + try { + const res: any = await getBorrowUsers() + borrowerOptions.value = res?.data || [] + } catch { + borrowerOptions.value = [] + } +} + +const handleBorrowerChange = (id: number) => { + const hit = borrowerOptions.value.find(u => u.id === id) + form.borrower_name = hit ? hit.name : '' +} + const rules = computed(() => ({ - borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }], + borrower_id: [{ required: true, message: '请选择借用人', trigger: 'change' }], expected_return_time: [ { required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' } ] @@ -870,6 +905,7 @@ const clearAll = () => { ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' }) .then(() => { cartItems.value = [] + form.borrower_id = null form.borrower_name = '' form.remark = '' form.expected_return_time = '' @@ -933,7 +969,8 @@ const submitForm = async () => { await dispatchBorrow({ approval_id: selectedApprovalId.value, items: itemsPayload, - borrower_name: form.borrower_name, + borrower_id: form.borrower_id as number, // ★ 唯一身份锚点 + borrower_name: form.borrower_name, // 仅作兼容,后端不采信 signature_path: signatureUrl, remark: form.remark, expected_return_time: isIndefinite.value ? null : form.expected_return_time @@ -947,6 +984,7 @@ const submitForm = async () => { } cartItems.value = [] + form.borrower_id = null form.borrower_name = '' form.expected_return_time = '' form.remark = '' @@ -1043,6 +1081,7 @@ const handleSignCancel = () => { showSignatureDialog.value = false } import { onMounted } from 'vue' onMounted(() => { loadApprovalRequests() + loadBorrowerOptions() }) onUnmounted(() => { diff --git a/inventory-web/src/views/transaction/records.vue b/inventory-web/src/views/transaction/records.vue index 0f906fc..da0652d 100644 --- a/inventory-web/src/views/transaction/records.vue +++ b/inventory-web/src/views/transaction/records.vue @@ -137,6 +137,23 @@ + + + + - - + + @@ -256,6 +287,115 @@ 提交报废申请 + + + + +
+
单号:{{ transferBorrowNo }}
+
请选择要转交的明细(转交后该明细的持有人整体变更为接收人):
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
单号:{{ historyBorrowNo }}
+
+ 按时间倒序,最新动作在最上方 +
+ + + +
+ {{ eventMeta(e.type).label }} + {{ e.material_name || e.sku }} + × {{ e.quantity }} +
+
+ + + + +
+
+ 备注:{{ e.remark }} +
+
+
+
+
@@ -268,7 +408,12 @@ import 'dayjs/locale/zh-cn' dayjs.locale('zh-cn') import { useUserStore } from '@/stores/user' import { getApproversList } from '@/api/auth' -import { submitBorrowScrapRequest } from '@/api/transaction' +import { + submitBorrowScrapRequest, + transferBorrow, + getBorrowUsers, + getBorrowSlipHistory, +} from '@/api/transaction' const userStore = useUserStore() @@ -450,6 +595,131 @@ const confirmScrap = async () => { } } +// ========================================================================== +// ★ 借库转交(一期:仅支持整单全部转交) +// ========================================================================== +// 未归还判定:主表状态下 borrowed / partial_returned 都还有实物未回库 +const isUnreturned = (row: any) => + row.status === 'borrowed' || row.status === 'partial_returned' + +// 当前持有人:整单下各明细可能持有人不同(转交是逐条明细进行的),去重后展示 +const currentHolders = (row: any): string[] => { + const names = (row.children || []) + .map((c: any) => c.current_holder_name) + .filter((n: any) => !!n) + return Array.from(new Set(names)) as string[] +} + +const transferDialogVisible = ref(false) +const transferSubmitting = ref(false) +const transferBorrowNo = ref('') +const transferCandidates = ref([]) +const transferTargetId = ref(null) +const transferToUserId = ref(null) +const transferRemark = ref('') +const transferUsers = ref>([]) + +const loadTransferUsers = async () => { + if (transferUsers.value.length) return // 名单会话内复用,避免每次开弹窗都请求 + try { + const res: any = await getBorrowUsers() + transferUsers.value = res?.data || [] + } catch { + transferUsers.value = [] + } +} + +const openTransferDialog = (row: any) => { + // 仅未还清的明细可转交(与后端的状态准入一致) + const candidates = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0) + if (!candidates.length) { + ElMessage.warning('该单号下没有未归还的明细,无法转交') + return + } + transferBorrowNo.value = row.borrow_no + transferCandidates.value = candidates + // 实测 54/60 的单号只有 1 条明细 —— 单条时直接选中,省掉一次点击 + transferTargetId.value = candidates.length === 1 ? candidates[0].id : null + transferToUserId.value = null + transferRemark.value = '' + transferDialogVisible.value = true + loadTransferUsers() +} + +const confirmTransfer = async () => { + if (!transferTargetId.value) return ElMessage.warning('请选择要转交的明细') + if (!transferToUserId.value) return ElMessage.warning('请选择接收人') + + const target = transferCandidates.value.find((c: any) => c.id === transferTargetId.value) + const receiver = transferUsers.value.find(u => u.id === transferToUserId.value) + + // 提前拦截「转给自己」,省一次无效往返(后端也会拒) + if (target && receiver && target.current_holder_id === receiver.id) { + return ElMessage.warning(`接收人与当前持有人同为【${receiver.name}】,无需转交`) + } + + const qty = target?.pending_quantity ?? 0 + try { + await ElMessageBox.confirm( + `将【${target?.material_name || target?.sku || ''}】的持有权整单转交给【${receiver?.name || ''}】?\n\n` + + `转交数量:${qty}(整单全量,不支持部分转交)\n` + + `转交后,该物品须由接收人本人办理归还。`, + '转交确认', + { confirmButtonText: '确认转交', cancelButtonText: '取消', type: 'warning' } + ) + } catch { + return // 用户取消 + } + + transferSubmitting.value = true // ★ 按钮 loading 防抖,避免重复提交 + try { + const res: any = await transferBorrow(transferTargetId.value, { + transfer_qty: qty, // 一期必须等于待还量,后端会二次校验 + to_user_id: transferToUserId.value, + to_user_name: receiver?.name, + remark: transferRemark.value, + }) + ElMessage.success(res?.msg || '转交成功') + transferDialogVisible.value = false + fetchData() // 刷新列表:持有人/操作列状态已变化 + } catch { + // ★ 不重复弹错:request 拦截器已按后端 msg 全局 toast,此处再弹会双份 + } finally { + transferSubmitting.value = false + } +} + +// ========================================================================== +// ★ 流转明细时间线(借出 → 转交(可多次) → 归还 → 报废) +// ========================================================================== +const historyDrawerVisible = ref(false) +const historyLoading = ref(false) +const historyBorrowNo = ref('') +const historyEvents = ref([]) + +const EVENT_META: Record = { + borrow: { label: '借出', type: 'primary' }, + transfer: { label: '转交', type: 'warning' }, + return: { label: '归还', type: 'success' }, + scrap: { label: '报废', type: 'danger' }, +} +const eventMeta = (t: string) => EVENT_META[t] || { label: t, type: 'info' } + +const openHistoryDrawer = async (row: any) => { + historyBorrowNo.value = row.borrow_no + historyEvents.value = [] + historyDrawerVisible.value = true + historyLoading.value = true + try { + const res: any = await getBorrowSlipHistory(row.borrow_no) + historyEvents.value = res?.data?.events || [] + } catch { + // 同上:错误提示由 request 拦截器统一处理 + } finally { + historyLoading.value = false + } +} + const fetchData = async () => { loading.value = true try { diff --git a/inventory-web/src/views/transaction/return.vue b/inventory-web/src/views/transaction/return.vue index cb767f3..5fe78f8 100644 --- a/inventory-web/src/views/transaction/return.vue +++ b/inventory-web/src/views/transaction/return.vue @@ -46,6 +46,13 @@
+ + + +