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 位再做归一。
This commit is contained in:
@ -0,0 +1,36 @@
|
||||
// ============================================================================
|
||||
// 通用格式化工具
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 数量显示格式化 —— 整数不显示小数点,小数去除末尾多余的 0。
|
||||
*
|
||||
* 1.0000 -> '1'
|
||||
* 2.5000 -> '2.5'
|
||||
* 0.30000000000000004 -> '0.3' (后端 float 累加产生的尾巴)
|
||||
* null / undefined / '' -> fallback
|
||||
*
|
||||
* ★ 为什么先 toFixed(4) 再 Number:
|
||||
* 本系统数量列在库中一律为 numeric(19,4),但后端有一批运算写成
|
||||
* `float(a) + float(b)`,累加后可能带出 0.30000000000000004 这类二进制
|
||||
* 浮点尾数。先按量纲精度收敛到 4 位,再交给 Number 归一 —— Number('0.3000')
|
||||
* 即 0.3,String() 后自然就是 '0.3'。
|
||||
*/
|
||||
export function formatQty(value: any, fallback = '0'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
return String(Number(n.toFixed(4)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 数量归一化为 number —— 供 el-input-number 等**需要数值而非字符串**的
|
||||
* 组件使用。作用同 formatQty,只是保留 number 类型。
|
||||
*
|
||||
* ★ 不要把 formatQty 的返回值喂给 el-input-number:传字符串会让 v-model
|
||||
* 失去数值语义,组件内部的步进/边界比较也会退化。
|
||||
*/
|
||||
export function normalizeQty(value: any, fallback = 0): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? Number(n.toFixed(4)) : fallback
|
||||
}
|
||||
|
||||
@ -122,11 +122,18 @@
|
||||
</el-table-column>
|
||||
|
||||
<!-- ★ 原单退回:良品加回库存 / 不良品转入异常待处理台账。
|
||||
已退满的行(returnable_quantity <= 0)按钮置灰不可点。 -->
|
||||
已退满的行(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"
|
||||
@ -136,7 +143,7 @@
|
||||
退回
|
||||
</el-button>
|
||||
<span v-if="row.returned_quantity > 0" class="returned-hint">
|
||||
已退 {{ row.returned_quantity }}
|
||||
已退 {{ formatQty(row.returned_quantity) }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="returned-hint">-</span>
|
||||
@ -222,23 +229,24 @@
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="原出库数量">
|
||||
<span class="dialog-text">{{ returnDialog.row?.quantity ?? 0 }}</span>
|
||||
<span class="dialog-text">{{ formatQty(returnDialog.row?.quantity) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="已退回数量">
|
||||
<span class="dialog-text">{{ returnDialog.row?.returned_quantity ?? 0 }}</span>
|
||||
<span class="dialog-text">{{ formatQty(returnDialog.row?.returned_quantity) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="本次可退最大">
|
||||
<span class="dialog-text dialog-strong">
|
||||
{{ returnDialog.row?.returnable_quantity ?? 0 }}
|
||||
{{ 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="returnDialog.row?.returnable_quantity || 0"
|
||||
:precision="4"
|
||||
:max="normalizeQty(returnDialog.row?.returnable_quantity)"
|
||||
:step="1"
|
||||
style="width: 200px"
|
||||
/>
|
||||
@ -279,19 +287,22 @@ 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()
|
||||
|
||||
// 退回按钮的可见性。后端同样会校验权限(inventory_stocktake:operation),
|
||||
// 前端只是不给无权限用户显示一个必然失败的按钮。
|
||||
const canReturn = computed(() =>
|
||||
userStore.role === 'SUPER_ADMIN'
|
||||
|| userStore.username === 'IRIS'
|
||||
|| userStore.hasPermission('inventory_stocktake:operation')
|
||||
)
|
||||
// 退回按钮的可见性。
|
||||
//
|
||||
// ★ 权限码必须与后端 @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
|
||||
@ -496,7 +507,8 @@ const returnDialog = reactive({
|
||||
const openReturnDialog = (row: any) => {
|
||||
returnDialog.row = row
|
||||
// 默认带入「本次可退最大」,业务上整行退回最常见;用户可再调小做部分退回
|
||||
returnDialog.form.return_qty = Number(row?.returnable_quantity || 0)
|
||||
// 用 normalizeQty 抹掉后端 float 累加留下的尾数,避免输入框显示一长串小数
|
||||
returnDialog.form.return_qty = normalizeQty(row?.returnable_quantity)
|
||||
returnDialog.form.is_defective = false
|
||||
returnDialog.form.reason = ''
|
||||
returnDialog.visible = true
|
||||
@ -513,8 +525,8 @@ const resetReturnDialog = () => {
|
||||
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 maxQty = normalizeQty(returnDialog.row?.returnable_quantity)
|
||||
const qty = normalizeQty(returnDialog.form.return_qty)
|
||||
const reason = (returnDialog.form.reason || '').trim()
|
||||
|
||||
if (!qty || qty <= 0) {
|
||||
|
||||
@ -88,12 +88,14 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="quantity" label="退回总数" width="100" align="right" />
|
||||
<el-table-column label="退回总数" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatQty(row.quantity) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<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 }}
|
||||
{{ formatQty(row.remaining_qty) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -104,19 +106,37 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||
<el-table-column label="操作" width="190" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<!-- ★ 仅在还有在管量时提供处置入口:终态(已回库/已报废/已闭环)
|
||||
的 remaining_qty 已归零,再操作只会拿到后端 400 -->
|
||||
的 remaining_qty 已归零,再操作只会拿到后端 400。
|
||||
|
||||
★ 两个动作是**独立权限**(defective_restock / defective_scrap),
|
||||
可能有人只有其中一个,故分别判定、分别隐藏。
|
||||
v-if 是响应式判定(表格行会重渲染),v-permission 是本系统规范的
|
||||
按钮级指令(无权限直接移出 DOM),两者用同一权限码,判定一致。
|
||||
后端 @permission_required 用的是同样的码,见
|
||||
db_migrations/add_defective_operation_perms.sql -->
|
||||
<template v-if="row.remaining_qty > 0">
|
||||
<el-button type="success" link size="small" @click="openRestockDialog(row)">
|
||||
<el-button
|
||||
v-if="canRestock"
|
||||
v-permission="'defective_restock'"
|
||||
type="success" link size="small"
|
||||
@click="openRestockDialog(row)"
|
||||
>
|
||||
修复回库
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="openScrapDialog(row)">
|
||||
<el-button
|
||||
v-if="canScrap"
|
||||
v-permission="'defective_scrap'"
|
||||
type="danger" link size="small"
|
||||
@click="openScrapDialog(row)"
|
||||
>
|
||||
报废销毁
|
||||
</el-button>
|
||||
<span v-if="!canRestock && !canScrap" class="no-perm">无处置权限</span>
|
||||
</template>
|
||||
<span v-else style="color: #c0c4cc; font-size: 12px;">已结案</span>
|
||||
<span v-else class="no-perm">已结案</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -145,14 +165,15 @@
|
||||
<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>
|
||||
<span class="dlg-text dlg-strong">{{ formatQty(restockDialog.row?.remaining_qty) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="本次回库数量" required>
|
||||
<!-- 不再设 :precision="4" —— 那会把 1 显示成 1.0000。
|
||||
数量本身仍支持小数(后端按 numeric(19,4) 收),只是不强制补零。 -->
|
||||
<el-input-number
|
||||
v-model="restockDialog.form.restock_qty"
|
||||
:min="0"
|
||||
:max="restockDialog.row?.remaining_qty || 0"
|
||||
:precision="4"
|
||||
:max="normalizeQty(restockDialog.row?.remaining_qty)"
|
||||
:step="1"
|
||||
style="width: 180px"
|
||||
/>
|
||||
@ -190,14 +211,14 @@
|
||||
<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>
|
||||
<span class="dlg-text dlg-strong">{{ formatQty(scrapDialog.row?.remaining_qty) }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="本次报废数量" required>
|
||||
<!-- 同「修复回库」:去掉 :precision 强制补零 -->
|
||||
<el-input-number
|
||||
v-model="scrapDialog.form.scrap_qty"
|
||||
:min="0"
|
||||
:max="scrapDialog.row?.remaining_qty || 0"
|
||||
:precision="4"
|
||||
:max="normalizeQty(scrapDialog.row?.remaining_qty)"
|
||||
:step="1"
|
||||
style="width: 180px"
|
||||
/>
|
||||
@ -227,14 +248,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getDefectiveList, restockDefective, scrapDefective } from '@/api/inbound/return'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { formatQty, normalizeQty } from '@/utils/format'
|
||||
import CompanySelector from '@/components/CompanySelector.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 与后端 app/models/transaction.py 的 VALID_DEFECTIVE_STATUSES 保持一致
|
||||
const statusOptions = ['待处理', '处理中', '已回库', '已报废', '已闭环']
|
||||
|
||||
// ★ 处置权限判定。权限码必须与后端 @permission_required 逐字一致。
|
||||
// hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的超管旁路对齐。
|
||||
const canRestock = computed(() => userStore.hasPermission('defective_restock'))
|
||||
const canScrap = computed(() => userStore.hasPermission('defective_scrap'))
|
||||
|
||||
const statusTagType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'待处理': 'warning',
|
||||
@ -313,7 +343,9 @@ const restockDialog = reactive({
|
||||
|
||||
const openRestockDialog = (row: any) => {
|
||||
restockDialog.row = row
|
||||
restockDialog.form.restock_qty = Number(row?.remaining_qty || 0)
|
||||
// 用 normalizeQty 而非裸 Number:抹掉后端 float 累加留下的
|
||||
// 0.30000000000000004 这类尾巴,否则输入框会显示一长串小数
|
||||
restockDialog.form.restock_qty = normalizeQty(row?.remaining_qty)
|
||||
restockDialog.form.remark = ''
|
||||
restockDialog.visible = true
|
||||
}
|
||||
@ -328,8 +360,8 @@ const resetRestockDialog = () => {
|
||||
const submitRestock = async () => {
|
||||
if (restockDialog.submitting) return // 双保险,防手抖连击
|
||||
|
||||
const max = Number(restockDialog.row?.remaining_qty || 0)
|
||||
const qty = Number(restockDialog.form.restock_qty || 0)
|
||||
const max = normalizeQty(restockDialog.row?.remaining_qty)
|
||||
const qty = normalizeQty(restockDialog.form.restock_qty)
|
||||
if (!qty || qty <= 0) {
|
||||
ElMessage.warning('请填写本次回库数量')
|
||||
return
|
||||
@ -367,7 +399,7 @@ const scrapDialog = reactive({
|
||||
|
||||
const openScrapDialog = (row: any) => {
|
||||
scrapDialog.row = row
|
||||
scrapDialog.form.scrap_qty = Number(row?.remaining_qty || 0)
|
||||
scrapDialog.form.scrap_qty = normalizeQty(row?.remaining_qty)
|
||||
scrapDialog.form.reason = ''
|
||||
scrapDialog.visible = true
|
||||
}
|
||||
@ -382,8 +414,8 @@ const resetScrapDialog = () => {
|
||||
const submitScrap = async () => {
|
||||
if (scrapDialog.submitting) return
|
||||
|
||||
const max = Number(scrapDialog.row?.remaining_qty || 0)
|
||||
const qty = Number(scrapDialog.form.scrap_qty || 0)
|
||||
const max = normalizeQty(scrapDialog.row?.remaining_qty)
|
||||
const qty = normalizeQty(scrapDialog.form.scrap_qty)
|
||||
const reason = (scrapDialog.form.reason || '').trim()
|
||||
|
||||
if (!qty || qty <= 0) {
|
||||
@ -442,6 +474,11 @@ onMounted(() => {
|
||||
color: #909399;
|
||||
padding-left: 10px;
|
||||
}
|
||||
/* 无处置权限 / 已结案的占位文案 */
|
||||
.no-perm {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dlg-danger {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user