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

@ -0,0 +1,86 @@
import request from '@/utils/request'
// ============================================================================
// 逆向物流(原单退回 + 不良品在管台账)
//
// 后端路由见 inventory-backend/app/api/v1/inbound/stock.py
// 设计要点:
// · 良品退回 → 直接加回原库存行
// · 不良品退回 → 不进库存表,转入 trans_defective_goods 在管台账,
// 修好后回库 / 无法维修则报废销毁
// ============================================================================
/**
* 原单退回。
*
* @param data {
* outbound_id: number 必填trans_outbound.id出库**明细行**主键,非单号)
* return_qty: number 必填,本次退回数量(>0
* is_defective: boolean 必填true=不良品转在管台账false=良品加回库存
* reason: string 可选,退回原因
* }
*/
export function returnFromOutbound(data: {
outbound_id: number
return_qty: number
is_defective: boolean
reason?: string
}) {
return request({
url: '/inbound/stock/return-from-outbound',
method: 'post',
data
})
}
/**
* 不良品在管台账分页查询。
*
* @param params { page, page_size, status?, keyword?, start_date?, end_date? }
* status 传 '全部' 或留空表示不过滤
*/
export function getDefectiveList(params: {
page?: number
page_size?: number
status?: string
keyword?: string
start_date?: string
end_date?: string
company_name?: string
}) {
return request({
url: '/inbound/stock/defective',
method: 'get',
params
})
}
/**
* 修复回库:把修好的数量加回原库存行。
*
* @param id 在管台账 id
* @param data { restock_qty?: number, remark?: string }
* restock_qty 缺省 = 全部剩余在管量
*/
export function restockDefective(id: number, data: { restock_qty?: number; remark?: string }) {
return request({
url: `/inbound/stock/defective/${id}/restock`,
method: 'post',
data
})
}
/**
* 报废销毁:在管坏件确认无法维修时销毁。
*
* @param id 在管台账 id
* @param data { scrap_qty?: number, reason: string }
* reason 必填scrap_qty 缺省 = 全部剩余在管量
*/
export function scrapDefective(id: number, data: { scrap_qty?: number; reason: string }) {
return request({
url: `/inbound/stock/defective/${id}/scrap`,
method: 'post',
data
})
}

View File

@ -106,6 +106,14 @@ const routes: Array<RouteRecordRaw> = [
name: 'RepairManagement',
component: () => import('@/views/stock/inbound/repair.vue'),
meta: { title: '维修管理', permission: 'inbound_repair' }
},
// ★ 不良品在管台账(逆向物流):退回的坏件不入库存表,
// 实物在仓但不在库存行中,故单独成页承载其去向与处置。
{
path: 'defective',
name: 'DefectiveGoods',
component: () => import('@/views/stock/defective/index.vue'),
meta: { title: '不良品在管台账', permission: 'inventory_stocktake' }
}
]
},

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;

View File

@ -0,0 +1,448 @@
<template>
<div class="app-container">
<!-- 顶部汇总在管坏件是不在库存表里的实物用这个数字提醒别漏盘 -->
<el-alert
v-if="pendingTotal > 0"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 12px;"
>
当前在管不良品剩余待处理<b>{{ pendingTotal }}</b>
这批实物不在库存表中盘点时请以本台账为准
</el-alert>
<!-- 筛选区 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item>
<CompanySelector v-model="query.company_name" @change="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" style="width: 150px" clearable>
<el-option label="全部" value="全部" />
<el-option v-for="s in statusOptions" :key="s" :label="s" :value="s" />
</el-select>
</el-form-item>
<el-form-item label="搜索">
<el-input
v-model="query.keyword"
placeholder="物料名称 / SKU / 规格"
style="width: 240px;"
clearable
@keyup.enter="handleSearch"
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="退回时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
<el-table
:data="list"
v-loading="loading"
border
style="width: 100%; margin-top: 16px;"
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
>
<el-table-column prop="material_name" label="物料名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.material_name">{{ row.material_name }}</span>
<span v-else style="color:#c0c4cc">原库存行已删除</span>
</template>
</el-table-column>
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.spec_model">{{ row.spec_model }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="sku" label="SKU" width="150" show-overflow-tooltip />
<el-table-column label="退回时间" width="170" align="center">
<template #default="{ row }">{{ row.created_at || '-' }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="110" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.operator">{{ row.operator }}</span>
<span v-else style="color:#c0c4cc">-</span>
</template>
</el-table-column>
<el-table-column prop="quantity" label="退回总数" width="100" align="right" />
<el-table-column label="剩余待处理" width="110" align="right">
<template #default="{ row }">
<span :style="{ color: row.remaining_qty > 0 ? '#E6A23C' : '#909399', fontWeight: 'bold' }">
{{ row.remaining_qty }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<!-- 仅在还有在管量时提供处置入口终态已回库/已报废/已闭环
remaining_qty 已归零再操作只会拿到后端 400 -->
<template v-if="row.remaining_qty > 0">
<el-button type="success" link size="small" @click="openRestockDialog(row)">
修复回库
</el-button>
<el-button type="danger" link size="small" @click="openScrapDialog(row)">
报废销毁
</el-button>
</template>
<span v-else style="color: #c0c4cc; font-size: 12px;">已结案</span>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px; text-align: right;">
<el-pagination
background
layout="total, prev, pager, next"
:total="total"
:page-size="query.page_size"
:current-page="query.page"
@current-change="handlePageChange"
/>
</div>
<!-- ============ 修复回库 ============ -->
<el-dialog
v-model="restockDialog.visible"
title="修复回库"
width="460px"
:close-on-click-modal="false"
@closed="resetRestockDialog"
>
<el-form label-width="110px">
<el-form-item label="物料">
<span class="dlg-text">{{ restockDialog.row?.material_name || '-' }}</span>
</el-form-item>
<el-form-item label="在管数量">
<span class="dlg-text dlg-strong">{{ restockDialog.row?.remaining_qty ?? 0 }}</span>
</el-form-item>
<el-form-item label="本次回库数量" required>
<el-input-number
v-model="restockDialog.form.restock_qty"
:min="0"
:max="restockDialog.row?.remaining_qty || 0"
:precision="4"
:step="1"
style="width: 180px"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="restockDialog.form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="如:已更换主板(可选)"
/>
</el-form-item>
</el-form>
<div class="dlg-tip">回库后数量将加回原库存行的实物数与可用数状态变为在库</div>
<template #footer>
<el-button @click="restockDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="restockDialog.submitting" @click="submitRestock">
确定回库
</el-button>
</template>
</el-dialog>
<!-- ============ 报废销毁 ============ -->
<el-dialog
v-model="scrapDialog.visible"
title="报废销毁"
width="460px"
:close-on-click-modal="false"
@closed="resetScrapDialog"
>
<el-form label-width="110px">
<el-form-item label="物料">
<span class="dlg-text">{{ scrapDialog.row?.material_name || '-' }}</span>
</el-form-item>
<el-form-item label="在管数量">
<span class="dlg-text dlg-strong">{{ scrapDialog.row?.remaining_qty ?? 0 }}</span>
</el-form-item>
<el-form-item label="本次报废数量" required>
<el-input-number
v-model="scrapDialog.form.scrap_qty"
:min="0"
:max="scrapDialog.row?.remaining_qty || 0"
:precision="4"
:step="1"
style="width: 180px"
/>
</el-form-item>
<el-form-item label="报废原因" required>
<el-input
v-model="scrapDialog.form.reason"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请填写报废原因(必填)"
/>
</el-form-item>
</el-form>
<div class="dlg-tip dlg-danger">
报废后实物视为已销毁不可撤销同时会写入报废台账
</div>
<template #footer>
<el-button @click="scrapDialog.visible = false">取消</el-button>
<el-button type="danger" :loading="scrapDialog.submitting" @click="submitScrap">
确定报废
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getDefectiveList, restockDefective, scrapDefective } from '@/api/inbound/return'
import CompanySelector from '@/components/CompanySelector.vue'
// 与后端 app/models/transaction.py 的 VALID_DEFECTIVE_STATUSES 保持一致
const statusOptions = ['待处理', '处理中', '已回库', '已报废', '已闭环']
const statusTagType = (status: string) => {
const map: Record<string, string> = {
'待处理': 'warning',
'处理中': 'primary',
'已回库': 'success',
'已报废': 'danger',
'已闭环': 'info',
}
return map[status] || 'info'
}
const list = ref<any[]>([])
const total = ref(0)
const pendingTotal = ref(0)
const loading = ref(false)
const dateRange = ref<string[]>([])
const query = reactive({
page: 1,
page_size: 20,
status: '全部',
keyword: '',
company_name: '' as string,
})
const fetchData = async () => {
loading.value = true
try {
const res = await getDefectiveList({
page: query.page,
page_size: query.page_size,
status: query.status,
keyword: query.keyword,
start_date: dateRange.value?.[0] || undefined,
end_date: dateRange.value?.[1] || undefined,
company_name: query.company_name || undefined,
})
list.value = res.data.list || []
total.value = res.data.total || 0
pendingTotal.value = res.data.pending_total || 0
} catch (e) {
console.error('[不良品台账] 查询失败', e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
query.page = 1
fetchData()
}
const handlePageChange = (val: number) => {
query.page = val
fetchData()
}
const resetFilter = () => {
query.page = 1
query.status = '全部'
query.keyword = ''
query.company_name = ''
dateRange.value = []
fetchData()
}
// ============================================================
// ★ 修复回库
// ============================================================
const restockDialog = reactive({
visible: false,
submitting: false,
row: null as any,
form: { restock_qty: 0, remark: '' },
})
const openRestockDialog = (row: any) => {
restockDialog.row = row
restockDialog.form.restock_qty = Number(row?.remaining_qty || 0)
restockDialog.form.remark = ''
restockDialog.visible = true
}
const resetRestockDialog = () => {
restockDialog.row = null
restockDialog.submitting = false
restockDialog.form.restock_qty = 0
restockDialog.form.remark = ''
}
const submitRestock = async () => {
if (restockDialog.submitting) return // 双保险,防手抖连击
const max = Number(restockDialog.row?.remaining_qty || 0)
const qty = Number(restockDialog.form.restock_qty || 0)
if (!qty || qty <= 0) {
ElMessage.warning('请填写本次回库数量')
return
}
if (qty > max) {
ElMessage.warning(`回库数量不能超过在管数量 ${max}`)
return
}
restockDialog.submitting = true
try {
const res = await restockDefective(restockDialog.row.id, {
restock_qty: qty,
remark: restockDialog.form.remark || undefined,
})
ElMessage.success(res?.msg || '回库成功')
restockDialog.visible = false
await fetchData()
} catch (e) {
console.error('[修复回库] 失败', e)
} finally {
restockDialog.submitting = false
}
}
// ============================================================
// ★ 报废销毁
// ============================================================
const scrapDialog = reactive({
visible: false,
submitting: false,
row: null as any,
form: { scrap_qty: 0, reason: '' },
})
const openScrapDialog = (row: any) => {
scrapDialog.row = row
scrapDialog.form.scrap_qty = Number(row?.remaining_qty || 0)
scrapDialog.form.reason = ''
scrapDialog.visible = true
}
const resetScrapDialog = () => {
scrapDialog.row = null
scrapDialog.submitting = false
scrapDialog.form.scrap_qty = 0
scrapDialog.form.reason = ''
}
const submitScrap = async () => {
if (scrapDialog.submitting) return
const max = Number(scrapDialog.row?.remaining_qty || 0)
const qty = Number(scrapDialog.form.scrap_qty || 0)
const reason = (scrapDialog.form.reason || '').trim()
if (!qty || qty <= 0) {
ElMessage.warning('请填写本次报废数量')
return
}
if (qty > max) {
ElMessage.warning(`报废数量不能超过在管数量 ${max}`)
return
}
if (!reason) {
ElMessage.warning('请填写报废原因')
return
}
scrapDialog.submitting = true
try {
const res = await scrapDefective(scrapDialog.row.id, {
scrap_qty: qty,
reason,
})
ElMessage.success(res?.msg || '报废成功')
scrapDialog.visible = false
await fetchData()
} catch (e) {
console.error('[报废销毁] 失败', e)
} finally {
scrapDialog.submitting = false
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.dlg-text {
color: #303133;
}
.dlg-strong {
color: #E6A23C;
font-weight: bold;
}
.dlg-tip {
font-size: 12px;
color: #909399;
padding-left: 10px;
}
.dlg-danger {
color: #F56C6C;
}
</style>