diff --git a/inventory-web/src/utils/format.ts b/inventory-web/src/utils/format.ts
index e69de29..1a84029 100644
--- a/inventory-web/src/utils/format.ts
+++ b/inventory-web/src/utils/format.ts
@@ -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
+}
diff --git a/inventory-web/src/views/outbound/index.vue b/inventory-web/src/views/outbound/index.vue
index 8c9c88e..020c6fa 100644
--- a/inventory-web/src/views/outbound/index.vue
+++ b/inventory-web/src/views/outbound/index.vue
@@ -122,11 +122,18 @@
+ 已退满的行(returnable_quantity <= 0)按钮置灰不可点。
+
+ ★ 权限双重保障,两者用同一个权限码 outbound_return,判定一致:
+ · v-if="canReturn" 响应式判定。v-permission 指令只在 mounted
+ 执行一次,而表格行会重渲染,用响应式判定兜底更稳。
+ · v-permission 本系统规范的按钮级指令,声明式表达权限要求,
+ 无权限时直接把元素移出 DOM(普通领料员工完全看不到)。 -->
- 已退 {{ row.returned_quantity }}
+ 已退 {{ formatQty(row.returned_quantity) }}
-
@@ -222,23 +229,24 @@
- {{ returnDialog.row?.quantity ?? 0 }}
+ {{ formatQty(returnDialog.row?.quantity) }}
- {{ returnDialog.row?.returned_quantity ?? 0 }}
+ {{ formatQty(returnDialog.row?.returned_quantity) }}
- {{ returnDialog.row?.returnable_quantity ?? 0 }}
+ {{ formatQty(returnDialog.row?.returnable_quantity) }}
+
@@ -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 | 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) {
diff --git a/inventory-web/src/views/stock/defective/index.vue b/inventory-web/src/views/stock/defective/index.vue
index d04ba13..2818ed2 100644
--- a/inventory-web/src/views/stock/defective/index.vue
+++ b/inventory-web/src/views/stock/defective/index.vue
@@ -88,12 +88,14 @@
-
+
+ {{ formatQty(row.quantity) }}
+
- {{ row.remaining_qty }}
+ {{ formatQty(row.remaining_qty) }}
@@ -104,19 +106,37 @@
-
+
+ 的 remaining_qty 已归零,再操作只会拿到后端 400。
+
+ ★ 两个动作是**独立权限**(defective_restock / defective_scrap),
+ 可能有人只有其中一个,故分别判定、分别隐藏。
+ v-if 是响应式判定(表格行会重渲染),v-permission 是本系统规范的
+ 按钮级指令(无权限直接移出 DOM),两者用同一权限码,判定一致。
+ 后端 @permission_required 用的是同样的码,见
+ db_migrations/add_defective_operation_perms.sql -->
-
+
修复回库
-
+
报废销毁
+ 无处置权限
- 已结案
+ 已结案
@@ -145,14 +165,15 @@
{{ restockDialog.row?.material_name || '-' }}
- {{ restockDialog.row?.remaining_qty ?? 0 }}
+ {{ formatQty(restockDialog.row?.remaining_qty) }}
+
@@ -190,14 +211,14 @@
{{ scrapDialog.row?.material_name || '-' }}
- {{ scrapDialog.row?.remaining_qty ?? 0 }}
+ {{ formatQty(scrapDialog.row?.remaining_qty) }}
+
@@ -227,14 +248,23 @@