feat(outbound): 领用人改为可搜索下拉,选中申请单自动带出申请人

背景:出库的「领用人/客户」一直是自由文本框(create.vue 的 el-input),
后端 consumer_name 必填但内容不限。实测 1497 条出库里有 7 条领用人对不上
任何在职人员,其中就有把姓名打成「刘」这种错字 —— 手输必然产生这类问题。
而同一个表单里紧挨着的「经办人(库管)」已经是下拉选择了。

改动:

前端 outbound/create.vue
  · 领用人由 el-input 改为 el-select(filterable + allow-create +
    default-first-option),数据源复用统一的在职人员接口 getActiveUsers()
    (与出库补发的「补发给谁」同一份,公司隔离由后端负责)。
  · 选中审批单时自动带出申请人(fillConsumerFromRequest),仍可改成别人。
    ★ 按 applicant_id 在在职名单里**反查**姓名,而不是直接用接口返回的
      applicant_name —— 后者是 '高雪/gaoxue'(后端 _get_user_name 返回完整
      username),直接落库会把台账的姓名口径搞乱(实测 1497 行无一带 '/')。
  · 动态模糊匹配:姓名与**拼音**都能搜。默认 filterable 只匹配 label(姓名),
    输 gaoxue 匹配不到高雪,故自定义 filter-method 一并比对账号段。
  · 软提示而非硬拦截:填了不在名单里的名字会显示
    「「X」不在在职人员名单中,请确认没写错」。保留 allow-create 是因为
    销售出库的客户可能是外部人员、没有账号,封死会让这类出库没法登记。

后端 common/users.py
  · active_user_options() 增加 account 字段(username 里 '/' 之后那段拼音),
    供前端拼音搜索。仍是 id/姓名/账号三项,不含邮箱、角色、部门。

★ 没有做后端强校验(拒绝非系统人员):用户明确选择「下拉为主,保留手输兜底」,
  硬拦会挡住外部客户。错字的防线是"默认从名单里选 + 不在名单时给提示"。

验证(12 + 6 项断言全过):
  · 39 个在职人员,每项含 id/name/account;name 是裸姓名、account 非空
  · 拼音搜索:输 gaoxue / GAOXUE → 高雪;输 gao → 高前赐/高闯/高雪;输 高雪 → 高雪
  · 在职人员无重名(按姓名绑定才无歧义)
  · 已通过的审批单能反查到在职申请人(单 514/513/512/511/509 全部命中)
  · 公司隔离:IRIS 用户 20 人只看到「石利」、LICA 用户 19 人只看到
    「石利LICA」、超管 39 人 —— 跨公司同名在各自视角下不会同时出现
  · 借库人员选择器(共用同一实现)仍正常,新增字段向后兼容
  · 日报三天附件 490.5K/193.4K/92.3K 与 6 列结构未变;出库列表接口未受影响
  · vue-tsc 与 vite build exit=0

注意:
  · 历史 7 条不匹配的领用人**未改动**(其中「常国玺」疑似离职人员)。
  · 前端只做了编译验证,未在浏览器实际点过 —— 下拉交互与提示样式需人工确认。
This commit is contained in:
yueli
2026-09-28 15:18:43 +08:00
parent d12435a801
commit 9c1a9886a5
2 changed files with 129 additions and 5 deletions

View File

@ -7,7 +7,7 @@ from . import common_bp
def active_user_options(): def active_user_options():
""" """
在职人员名单(id + 姓名)——「选择某人」类下拉框的**共用实现**。 在职人员名单(id + 姓名 + 账号)——「选择某人」类下拉框的**共用实现**。
★ 为什么单独开一条中性路径,而不是复用 /transactions/borrow/users: ★ 为什么单独开一条中性路径,而不是复用 /transactions/borrow/users:
那条路径在语义上属于借库模块,出库补发、报废执行等处若直接复用, 那条路径在语义上属于借库模块,出库补发、报废执行等处若直接复用,
@ -17,7 +17,13 @@ def active_user_options():
★ 公司隔离与业务台账同口径(get_current_company_filter): ★ 公司隔离与业务台账同口径(get_current_company_filter):
否则 A 公司的人能在选择器里看到 B 公司人员。 否则 A 公司的人能在选择器里看到 B 公司人员。
★ 只返回 id 与姓名:不含邮箱 / 角色 / 部门,最小披露。 ★ 只返回 id / 姓名 / 账号:不含邮箱、角色、部门 —— 仍是最小披露。
账号(username 里 '/' 之后那一段,如 'gaoxue')是**拼音**,
选择器靠它支持「输拼音找人」(输 gaoxue 匹配到高雪)——
这是加它的唯一原因,不是要把登录名铺开。
★ 姓名口径用 user_display_name('高雪/gaoxue' → '高雪'),
与出库/借库台账里存姓名的写法一致;否则同一人在台账里会出现两种写法。
""" """
from app.utils.decorators import get_current_company_filter from app.utils.decorators import get_current_company_filter
from app.models.system import SysUser from app.models.system import SysUser
@ -29,8 +35,15 @@ def active_user_options():
# 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度 # 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度
query = query.filter(SysUser.department == company_limit) query = query.filter(SysUser.department == company_limit)
return [{'id': u.id, 'name': user_display_name(u)} out = []
for u in query.order_by(SysUser.username).all()] for u in query.order_by(SysUser.username).all():
raw = str(u.username or '')
out.append({
'id': u.id,
'name': user_display_name(u),
'account': raw.split('/')[-1] if '/' in raw else '',
})
return out
@common_bp.route('/active-users', methods=['GET']) @common_bp.route('/active-users', methods=['GET'])

View File

@ -235,8 +235,41 @@
</el-col> </el-col>
<el-col :span="24" :md="8"> <el-col :span="24" :md="8">
<!--
★ 领用人改为**下拉选择**(此前是自由文本框)。
为什么:实测 1497 条出库里有 7 条领用人对不上任何在职人员,
其中就有把姓名打成「刘」这种错字 —— 手输必然会有这类问题。
现在从人员名单里选,与「经办人(库管)」的交互也统一了。
★ 选了审批单会自动带出申请人(见 handleRequestChange),
仍可改成别人。
★ 保留 allow-create:销售出库的客户可能是外部人员、没有账号,
封死会让这类出库没法登记。但**手输的名字会给出提示**
(见下方 consumerUnknown 的告警),既不挡业务也不放任错字。
-->
<el-form-item label="领用人/客户" prop="consumer_name"> <el-form-item label="领用人/客户" prop="consumer_name">
<el-input v-model="form.consumer_name" placeholder="请输入姓名" /> <el-select
v-model="form.consumer_name"
filterable
allow-create
default-first-option
:filter-method="filterUsers"
placeholder="选择或输入姓名"
style="width: 100%"
>
<el-option
v-for="u in filteredUsers"
:key="u.id"
:label="u.name"
:value="u.name"
/>
</el-select>
<!-- ★ 软提示而非硬拦截:不在名单里也能提交(外部客户),
但要让库管看见"这个名字系统里没有" —— 错字就是这么挡住的 -->
<div v-if="consumerUnknown" class="field-warn">
「{{ form.consumer_name }}」不在在职人员名单中,请确认没写错
</div>
</el-form-item> </el-form-item>
</el-col> </el-col>
@ -385,6 +418,8 @@ import {
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview, getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
} from '@/api/outbound' } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload' import { uploadFile } from '@/api/common/upload'
// 在职人员名单 —— 领用人下拉的数据源(与出库补发的「补发给谁」同一份)
import { getActiveUsers } from '@/api/common/users'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const userStore = useUserStore() const userStore = useUserStore()
@ -416,6 +451,76 @@ const lastY = ref(0)
const operatorOptions = ref<string[]>([]) const operatorOptions = ref<string[]>([])
// ============================================================================
// 领用人选择器
//
// 数据源是统一的在职人员接口(与「补发给谁」同一个),公司隔离由后端负责。
// ============================================================================
const activeUsers = ref<Array<{ id: number; name: string; account: string }>>([])
const userQuery = ref('')
// 动态模糊匹配:姓名与**拼音**都能搜。
// ★ 默认的 filterable 只匹配 label(也就是姓名),输 gaoxue 匹配不到高雪;
// 账号字段正是那段拼音,这里一并比对。
const filterUsers = (query: string) => {
userQuery.value = query || ''
}
const filteredUsers = computed(() => {
const q = userQuery.value.trim().toLowerCase()
if (!q) return activeUsers.value
return activeUsers.value.filter(u =>
u.name.toLowerCase().includes(q) ||
(u.account || '').toLowerCase().includes(q)
)
})
// 当前填的领用人是否**不在**在职名单里。
// ★ 只做提示不做拦截:外部客户确实可能没有账号,但错字必须让人看见。
const consumerUnknown = computed(() => {
const v = String(form.consumer_name || '').trim()
if (!v || !activeUsers.value.length) return false
return !activeUsers.value.some(u => u.name === v)
})
const loadActiveUsers = async () => {
try {
const res: any = await getActiveUsers()
if (res.code === 200 && res.data) {
activeUsers.value = res.data
}
} catch (e) {
console.warn('[出库] 在职人员名单拉取失败,领用人需手动输入', e)
}
}
/**
* 领用人默认取所选审批单的**申请人**。
*
* ★ 优先按 applicant_id 在在职名单里反查,而不是直接用接口返回的
* applicant_name:后者是 '高雪/gaoxue'(后端 _get_user_name 返回完整
* username),直接落库会把台账里存姓名的口径搞乱(实测 1497 行里
* 没有一条带 '/' 的领用人)。按 id 反查拿到的姓名与下拉选项**同源**。
*/
const fillConsumerFromRequest = () => {
const req = selectedRequest.value
if (!req) return
const hit = activeUsers.value.find(u => u.id === req.applicant_id)
if (hit) {
form.consumer_name = hit.name
return
}
// 回落:申请人已离职(不在在职名单)或名单还没加载完
const raw = String(req.applicant_name || '')
const nm = raw.split('/')[0].trim()
// 后端查不到用户时会返回 '未知用户(7)' / '用户(7)',这种不能当姓名用
if (nm && !nm.startsWith('未知用户') && !nm.startsWith('用户(')) {
form.consumer_name = nm
}
}
const form = reactive({ const form = reactive({
outbound_type: '', outbound_type: '',
consumer_name: '', consumer_name: '',
@ -566,6 +671,8 @@ const handleRequestChange = async (val: number | null) => {
if (selectedRequest.value?.outbound_type) { if (selectedRequest.value?.outbound_type) {
form.outbound_type = selectedRequest.value.outbound_type form.outbound_type = selectedRequest.value.outbound_type
} }
// ★ 领用人默认就是申请人(仍可改成别人)
fillConsumerFromRequest()
} }
cartItems.value = [] cartItems.value = []
@ -805,6 +912,8 @@ onMounted(() => {
operatorOptions.value.push(userStore.username) operatorOptions.value.push(userStore.username)
} }
loadHistoryOperators() loadHistoryOperators()
// 领用人下拉的数据源(与「补发给谁」同一个接口)
loadActiveUsers()
}) })
const loadHistoryOperators = async () => { const loadHistoryOperators = async () => {
@ -1298,6 +1407,8 @@ onUnmounted(() => {
/* ★ 审批单选择 */ /* ★ 审批单选择 */
.approval-request-select { margin-bottom: 16px; } .approval-request-select { margin-bottom: 16px; }
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; } .select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
/* 领用人不在在职名单时的软提示(外部客户不算错,但要让人看见) */
.field-warn { margin-top: 4px; color: #E6A23C; font-size: 12px; line-height: 1.4; }
/* ★ 计划清单 */ /* ★ 计划清单 */
.planned-items-section { .planned-items-section {