Files
KCGL/inventory-web/src/views/outbound/index.vue
yueli 511e822d12 feat(ui): 按钮级权限守卫与数量显示格式化
按钮级权限(与后端 @permission_required 用同一批权限码):
- 出库记录页「退回」按钮   -> v-permission="'outbound_return'"
- 不良品看板「修复回库」   -> v-permission="'defective_restock'"
- 不良品看板「报废销毁」   -> v-permission="'defective_scrap'"

  两个处置按钮是**独立权限**,可能有人只有其一,故分别判定、分别隐藏;
  两者都无时显示「无处置权限」。均保留响应式的 v-if 兜底——v-permission
  只在 mounted 执行一次,而 Element Plus 表格行会重渲染,两者用同一权限码
  判定一致。hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的
  超管旁路对齐。

  另外去掉了出库记录页原本套用的 username === 'IRIS' 特例放行:退回是实物
  交接的 SOP 动作,后端不认这个特例,前端也不该认,否则会出现「按钮可见但
  接口 403」的错位。

数量显示格式化(新增 utils/format.ts,该文件原本是 0 字节空文件):
- formatQty()    显示用:1.0000 -> '1',2.5000 -> '2.5'
- normalizeQty() 数值用:同上但返回 number,供 el-input-number 的
                 v-model / :max —— 喂字符串会让步进与边界比较退化

  应用于 不良品看板(退回总数、剩余待处理、两个弹窗的在管数量与输入框)、
  出库记录页(已退 N 提示、退回弹窗三项数字)、退回记录页(退回数量列)。

  同时移除三个 el-input-number 上的 :precision="4" —— 它会把 1 强制渲染成
  1.0000,是截图上多余小数点的直接来源。数量本身仍支持小数(后端按
  numeric(19,4) 收),只是不再强制补零。

  表格列原本不受影响(接口返回 JSON 数字,1.0 经 JS 解析即 1),但后端有一批
  运算是 float 累加,可能吐出 0.30000000000000004 这类尾巴,格式化按
  numeric(19,4) 量纲先收敛到 4 位再做归一。
2026-09-16 16:46:20 +08:00

631 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container">
<!-- ★ 高级搜索:与借还记录 / 报废记录统一版式 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item>
<CompanySelector v-model="listQuery.company" @change="fetchData" />
</el-form-item>
<el-form-item label="搜索">
<el-input
v-model="listQuery.keyword"
placeholder="输入关键词"
style="width: 240px;"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="listQuery.search_type" placeholder="搜索类型" style="width: 110px" @change="handleSearchImmediate">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="姓名" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="出库日期">
<el-date-picker
v-model="listQuery.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="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual"
>
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
高级筛选
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
</el-button>
</template>
<div class="advanced-filter">
<div
v-for="(condition, index) in advancedConditions"
:key="index"
class="condition-row"
>
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
</div>
<div style="margin-top:12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="success" @click="$router.push('/outbound/create')">新建出库</el-button>
</el-form-item>
</el-form>
<el-table
:data="list"
v-loading="loading"
border
style="width: 100%; margin-top: 20px;"
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
>
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: #fafafa;">
<h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4>
<el-table :data="props.row.items" border size="small">
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="150" />
<el-table-column v-if="hasColumnPermission('name')" prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('material_type')" prop="material_type" label="类型" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('category')" prop="category" label="类别" width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('spec_model')" prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="batch_sn" label="批号/SN" min-width="120" />
<el-table-column prop="warehouse_location" label="库位" min-width="120">
<template #default="{row}">
<span v-if="row.warehouse_location">{{ row.warehouse_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('quantity')" prop="quantity" label="数量" width="100" />
<el-table-column v-if="hasColumnPermission('unit_price')" prop="unit_price" label="单价" width="120">
<template #default="{row}">¥{{ row.unit_price }}</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('subtotal')" prop="subtotal" label="小计">
<template #default="{row}">
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
</template>
</el-table-column>
<!-- ★ 原单退回:良品加回库存 / 不良品转入异常待处理台账。
已退满的行(returnable_quantity <= 0)按钮置灰不可点。
★ 权限双重保障,两者用同一个权限码 outbound_return,判定一致:
· v-if="canReturn" 响应式判定。v-permission 指令只在 mounted
执行一次,而表格行会重渲染,用响应式判定兜底更稳。
· v-permission 本系统规范的按钮级指令,声明式表达权限要求,
无权限时直接把元素移出 DOM(普通领料员工完全看不到)。 -->
<el-table-column label="退回" width="150" align="center" fixed="right">
<template #default="{ row }">
<template v-if="canReturn">
<el-button
v-permission="'outbound_return'"
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">
已退 {{ formatQty(row.returned_quantity) }}
</span>
</template>
<span v-else class="returned-hint">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('outbound_no')" prop="outbound_no" label="出库单号" min-width="200" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('outbound_time')" prop="outbound_time" label="出库时间" width="170" align="center">
<template #default="{ row }">
<span>{{ row.outbound_time ? row.outbound_time.substring(0, 16) : '' }}</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('outbound_type')" prop="outbound_type" label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTagType(row.outbound_type)">{{ formatType(row.outbound_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('total_amount')" prop="total_amount" label="总金额" width="120" align="right">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('consumer_name')" prop="consumer_name" label="领用/客户" min-width="120" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('operator_name')" prop="operator_name" label="操作员" min-width="100" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('remark')" prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('signature_path')" label="签名" width="120" align="center">
<template #default="{ row }">
<div v-if="row.signature_path" class="signature-cell">
<el-image
style="width: 80px; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6;"
:src="row.signature_path"
:preview-src-list="[row.signature_path]"
preview-teleported
fit="contain"
hide-on-click-modal
>
<template #error>
<div class="image-slot">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<span v-else style="color: #909399; font-size: 12px;">未签名</span>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px; text-align: right;">
<el-pagination
background
layout="prev, pager, next"
:total="total"
:page-size="listQuery.limit"
@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">{{ formatQty(returnDialog.row?.quantity) }}</span>
</el-form-item>
<el-form-item label="已退回数量">
<span class="dialog-text">{{ formatQty(returnDialog.row?.returned_quantity) }}</span>
</el-form-item>
<el-form-item label="本次可退最大">
<span class="dialog-text dialog-strong">
{{ formatQty(returnDialog.row?.returnable_quantity) }}
</span>
</el-form-item>
<el-form-item label="本次退回数量" required>
<!-- 不再设 :precision="4" —— 那会把 1 显示成 1.0000。
数量本身仍支持小数(后端按 numeric(19,4) 收)。 -->
<el-input-number
v-model="returnDialog.form.return_qty"
:min="0"
:max="normalizeQty(returnDialog.row?.returnable_quantity)"
: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, computed, onMounted, reactive, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { getOutboundList } from '@/api/outbound'
import { returnFromOutbound } from '@/api/inbound/return'
import { formatQty, normalizeQty } from '@/utils/format'
import { Picture } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import CompanySelector from '@/components/CompanySelector.vue'
const userStore = useUserStore()
// 退回按钮的可见性。
//
// ★ 权限码必须与后端 @permission_required('outbound_return') 逐字一致,
// 且刻意**不**套用本文件 hasColumnPermission() 里 `username === 'IRIS'`
// 那类特例放行 —— 退回是实物交接的 SOP 动作,后端不认特例,前端也不该认,
// 否则会出现「按钮可见但接口 403」的错位。
// hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的超管旁路对齐。
// 权限码来源见 db_migrations/add_outbound_return_perm.sql
const canReturn = computed(() => userStore.hasPermission('outbound_return'))
// 防抖定时器
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// 防抖搜索函数
const debouncedSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
listQuery.page = 1
fetchData()
}, 500)
}
// 清空搜索时立即触发查询
const handleClearSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
listQuery.page = 1
fetchData()
}
// 列与权限Code的映射关系(数据库中的code)
const permissionMap: Record<string, string | null> = {
// 主表基础列 — 始终可见
outbound_no: null,
outbound_time: null,
outbound_type: null,
consumer_name: null,
operator_name: null,
remark: null,
signature_path: null,
// 金额列 — 需权限
total_amount: 'outbound_list:total_amount',
// 明细列 — 始终可见
sku: null,
name: null,
material_type: null,
category: null,
spec_model: null,
quantity: null,
batch_sn: null,
// 明细金额 — 需权限
unit_price: 'outbound_list:unit_price',
subtotal: 'outbound_list:subtotal',
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
if (!(prop in permissionMap)) {
return false // Default Deny
}
const code = permissionMap[prop]
if (code === null || code === undefined) {
return true // 基础字段,无需权限
}
return userStore.hasPermission(code)
}
const list = ref([])
const total = ref(0)
const loading = ref(false)
const listQuery = reactive({
page: 1,
limit: 10,
keyword: '',
search_type: 'all',
dateRange: [],
company: '' as string,
advancedFilters: [] as any[],
})
// --- ★ 高级筛选 ---
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const appliedConditions = ref<any[]>([])
const fieldOptions = [
{ value: 'no', label: '单号' },
{ value: 'sku', label: 'SKU' },
{ value: 'material_name', label: '物料名称' },
{ value: 'operator', label: '操作人' },
]
const operatorOptions = [
{ value: 'contains', label: '包含' },
{ value: 'eq', label: '等于' },
{ value: 'not_contains', label: '不包含' },
{ value: 'ne', label: '不等于' },
]
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1)
}
const applyAdvancedFilter = () => {
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
listQuery.advancedFilters = valid
appliedConditions.value = valid
advancedFilterVisible.value = false
listQuery.page = 1
fetchData()
}
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }]
listQuery.advancedFilters = []
appliedConditions.value = []
advancedFilterVisible.value = false
listQuery.page = 1
fetchData()
}
const fetchData = async () => {
loading.value = true
try {
const params = {
...listQuery,
start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null,
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
}
const res = await getOutboundList(params)
list.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handlePageChange = (val: number) => {
listQuery.page = val
fetchData()
}
// 立即查询(搜索类型切换用,取消防抖)
const handleSearchImmediate = () => {
if (debounceTimer) clearTimeout(debounceTimer)
listQuery.page = 1
fetchData()
}
const resetFilter = () => {
if (debounceTimer) clearTimeout(debounceTimer)
listQuery.keyword = ''
listQuery.search_type = 'all'
listQuery.dateRange = []
listQuery.advancedFilters = []
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
listQuery.page = 1
fetchData()
}
const formatType = (type: string) => {
const map: any = {
'SALES': '销售出库',
'USE': '内部领用',
'PRODUCTION': '生产出库',
'SCRAP': '报废',
'LOSS': '盘亏出库', // ★ 补充映射:扫码出库下拉中存在「盘亏出库 LOSS」,避免出库记录显示英文
'REPAIR': '维修出库'
}
return map[type] || type
}
const getTagType = (type: string) => {
const map: any = {
'SALES': 'success',
'USE': 'warning',
'PRODUCTION': 'info',
'SCRAP': 'danger',
'LOSS': 'warning', // ★ 补充:盘亏出库标签样式
'REPAIR': 'info'
}
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
// 默认带入「本次可退最大」,业务上整行退回最常见;用户可再调小做部分退回
// 用 normalizeQty 抹掉后端 float 累加留下的尾数,避免输入框显示一长串小数
returnDialog.form.return_qty = normalizeQty(row?.returnable_quantity)
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 = normalizeQty(returnDialog.row?.returnable_quantity)
const qty = normalizeQty(returnDialog.form.return_qty)
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()
})
// 组件销毁前清理定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</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;
}
/* 高级筛选条件行 */
.condition-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.signature-cell {
display: flex;
justify-content: center;
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;
align-items: center;
width: 100%;
height: 100%;
background: #f5f7fa;
color: #909399;
}
</style>