feat(return): 前端退回入口与不良品在管台账看板

出库记录页(views/outbound/index.vue):
- 明细行新增「退回」列,returnable_quantity <= 0 时按钮置灰,行内显示「已退 N」
- 对话框展示 原出库数量 / 已退回数量 / 本次可退最大,默认带入可退最大值
- 退回类型用下拉单选:良品(加回库存)/ 不良品(转入异常待处理)
- 退回原因必填,提交前三重校验;按钮 :loading + 函数内 submitting 双保险防抖
- 成功后刷新列表。错误提示不重复弹——request 拦截器对 HTTP 400 已取
  data.msg 展示,对话框 catch 只收尾

配套后端(services/outbound_service.py):
- get_grouped_list 的出库明细补 id / returned_quantity / returnable_quantity。
  原先明细不带 id,退回接口无从指定 outbound_id

新增页面(views/stock/defective/index.vue,路由 /inventory/defective):
- 展示 物料名称/规格/SKU/退回时间/操作人/退回总数/剩余待处理/状态
- 顶部 alert 提示在管总量,并明确「这批实物不在库存表中,盘点请以本台账
  为准」——在管坏件对盘点不可见是本方案的固有盲区,必须在页面上主动提醒
- 仅对 remaining_qty > 0 的行提供「修复回库」「报废销毁」,终态行显示已结案
- 两个弹窗均带数量上限约束与 loading 防抖,成功后刷新

新增 api/inbound/return.ts 承载四个逆向物流接口。

侧边栏由 router/index.ts 驱动,加路由即入菜单;注意侧边栏只过滤
meta.hidden、不看 meta.permission,故菜单对所有角色可见,访问控制由后端
接口负责(与既有「维修管理」一致)。
This commit is contained in:
yueli
2026-09-16 15:45:40 +08:00
parent dda6e4c787
commit fd99d33a0d
4 changed files with 730 additions and 1 deletions

View File

@ -120,6 +120,28 @@
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
</template>
</el-table-column>
<!-- ★ 原单退回:良品加回库存 / 不良品转入异常待处理台账。
已退满的行(returnable_quantity <= 0)按钮置灰不可点。 -->
<el-table-column label="退回" width="150" align="center" fixed="right">
<template #default="{ row }">
<template v-if="canReturn">
<el-button
type="warning"
link
size="small"
:disabled="!row.returnable_quantity || row.returnable_quantity <= 0"
@click="openReturnDialog(row)"
>
退回
</el-button>
<span v-if="row.returned_quantity > 0" class="returned-hint">
已退 {{ row.returned_quantity }}
</span>
</template>
<span v-else class="returned-hint">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
@ -183,18 +205,94 @@
@current-change="handlePageChange"
/>
</div>
<!-- ★ 原单退回对话框 -->
<el-dialog
v-model="returnDialog.visible"
title="原单退回"
width="540px"
:close-on-click-modal="false"
@closed="resetReturnDialog"
>
<el-form :model="returnDialog.form" label-width="120px">
<el-form-item label="物料">
<span class="dialog-text">
{{ returnDialog.row?.name || '-' }}
<span class="dialog-sub">/ {{ returnDialog.row?.sku || '-' }}</span>
</span>
</el-form-item>
<el-form-item label="原出库数量">
<span class="dialog-text">{{ returnDialog.row?.quantity ?? 0 }}</span>
</el-form-item>
<el-form-item label="已退回数量">
<span class="dialog-text">{{ returnDialog.row?.returned_quantity ?? 0 }}</span>
</el-form-item>
<el-form-item label="本次可退最大">
<span class="dialog-text dialog-strong">
{{ returnDialog.row?.returnable_quantity ?? 0 }}
</span>
</el-form-item>
<el-form-item label="本次退回数量" required>
<el-input-number
v-model="returnDialog.form.return_qty"
:min="0"
:max="returnDialog.row?.returnable_quantity || 0"
:precision="4"
:step="1"
style="width: 200px"
/>
</el-form-item>
<el-form-item label="退回类型" required>
<el-select v-model="returnDialog.form.is_defective" style="width: 260px">
<el-option :value="false" label="良品(加回库存)" />
<el-option :value="true" label="不良品(转入异常待处理)" />
</el-select>
</el-form-item>
<el-form-item label="退回原因" required>
<el-input
v-model="returnDialog.form.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请填写退回原因(必填)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="returnDialog.visible = false">取消</el-button>
<!-- loading 兼作前端防抖:提交期间按钮不可再点 -->
<el-button type="primary" :loading="returnDialog.submitting" @click="submitReturn">
确定提交
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, reactive, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { getOutboundList } from '@/api/outbound'
import { returnFromOutbound } from '@/api/inbound/return'
import { Picture } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import CompanySelector from '@/components/CompanySelector.vue'
const userStore = useUserStore()
// 退回按钮的可见性。后端同样会校验权限(inventory_stocktake:operation),
// 前端只是不给无权限用户显示一个必然失败的按钮。
const canReturn = computed(() =>
userStore.role === 'SUPER_ADMIN'
|| userStore.username === 'IRIS'
|| userStore.hasPermission('inventory_stocktake:operation')
)
// 防抖定时器
let debounceTimer: ReturnType<typeof setTimeout> | null = null
@ -381,6 +479,77 @@ const getTagType = (type: string) => {
return map[type] || ''
}
// ============================================================
// ★ 原单退回
// ============================================================
const returnDialog = reactive({
visible: false,
submitting: false,
row: null as any,
form: {
return_qty: 1,
is_defective: false,
reason: '',
},
})
const openReturnDialog = (row: any) => {
returnDialog.row = row
// 默认带入「本次可退最大」,业务上整行退回最常见;用户可再调小做部分退回
returnDialog.form.return_qty = Number(row?.returnable_quantity || 0)
returnDialog.form.is_defective = false
returnDialog.form.reason = ''
returnDialog.visible = true
}
const resetReturnDialog = () => {
returnDialog.row = null
returnDialog.submitting = false
returnDialog.form.return_qty = 1
returnDialog.form.is_defective = false
returnDialog.form.reason = ''
}
const submitReturn = async () => {
if (returnDialog.submitting) return // 双保险:即便按钮 loading 被绕过也不重复提交
const maxQty = Number(returnDialog.row?.returnable_quantity || 0)
const qty = Number(returnDialog.form.return_qty || 0)
const reason = (returnDialog.form.reason || '').trim()
if (!qty || qty <= 0) {
ElMessage.warning('请填写本次退回数量')
return
}
if (qty > maxQty) {
ElMessage.warning(`本次退回数量不能超过可退最大数量 ${maxQty}`)
return
}
if (!reason) {
ElMessage.warning('请填写退回原因')
return
}
returnDialog.submitting = true
try {
const res = await returnFromOutbound({
outbound_id: returnDialog.row.id,
return_qty: qty,
is_defective: returnDialog.form.is_defective,
reason,
})
ElMessage.success(res?.msg || '退回成功')
returnDialog.visible = false
// 刷新列表:退回额度与库存状态都已变化
await fetchData()
} catch (e) {
// 错误提示由 request 拦截器统一弹出(400 会展示后端业务文案),此处只收尾
console.error('[退回] 失败', e)
} finally {
returnDialog.submitting = false
}
}
onMounted(() => {
fetchData()
})
@ -419,6 +588,24 @@ onBeforeUnmount(() => {
align-items: center;
padding: 2px 0;
}
/* ★ 原单退回:明细行的「已退 N」提示与对话框文案样式 */
.returned-hint {
color: #E6A23C;
font-size: 12px;
margin-left: 6px;
}
.dialog-text {
color: #303133;
}
.dialog-sub {
color: #909399;
font-size: 12px;
}
.dialog-strong {
color: #409EFF;
font-weight: bold;
}
.image-slot {
display: flex;
justify-content: center;