fix(web): limit protocol managers to active region leaders

Provide tenant-scoped leader options for the Web protocol form, exclude ordinary members and retain historical names only for display. Default only to a leader of the selected region and prevent delayed requests from replacing manual choices.

Refs #1. Add backend option isolation and Web cascade regression coverage.
This commit is contained in:
zlwu committed 2026-10-30 17:47:43 +08:00
1 parent f6cfce3315
commit d1347e8cea
6 files changed
+300 -61

No files matched your search

+10
View File
@@ -14,6 +14,7 @@ from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.core.utils import parse_datetime
from app.crud import collection_protocol as cp_svc
from app.crud import collection_region as region_svc
from app.models.models import (
BizCollectionProtocol,
BizProtocolPartyCustomer,
@@ -146,6 +147,15 @@ async def list_collection_protocols(
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@router.get("/collection-protocols/region-managers")
async def list_protocol_region_managers(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
return success_response({"list": region_svc.list_active_region_leaders(db, tenant_no=tenant_no)})
@router.get("/collection-protocols/{protocol_id}")
async def get_collection_protocol_detail(
protocol_id: str,
+29
View File
@@ -496,6 +496,35 @@ def list_regions_by_staff_user(
return map_staff_region_rows_to_list([(r[0], r[1]) for r in q.all()])
def list_active_region_leaders(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
"""协议表单候选:租户内有效地区负责人,按用户去重并保留负责地区。"""
if not tenant_no:
raise ValueError("租户信息缺失")
rows = (
db.query(SysUser.id, SysUser.nick_name, BizCollectionRegionStaff.region_id)
.join(BizCollectionRegionStaff, BizCollectionRegionStaff.user_id == SysUser.id)
.join(BizCollectionRegion, BizCollectionRegion.id == BizCollectionRegionStaff.region_id)
.filter(
BizCollectionRegion.tenant_no == tenant_no,
BizCollectionRegion.del_flag == 0,
BizCollectionRegionStaff.tenant_no == tenant_no,
BizCollectionRegionStaff.del_flag == 0,
BizCollectionRegionStaff.is_leader == 1,
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.status == "1",
)
.distinct()
.order_by(SysUser.nick_name.asc(), SysUser.id.asc(), BizCollectionRegionStaff.region_id.asc())
.all()
)
leaders: Dict[str, Dict[str, Any]] = {}
for user_id, nick_name, region_id in rows:
item = leaders.setdefault(user_id, {"id": user_id, "nickName": nick_name or "", "regionIds": []})
item["regionIds"].append(region_id)
return list(leaders.values())
def list_staff(
db: Session,
*,
+27
View File
@@ -163,6 +163,11 @@ def test_protocol_manager_options_only_include_active_tenant_region_leaders(regi
{'value': 'leader-c', 'label': 'c负责人'},
{'value': 'leader-d', 'label': 'd负责人'},
]
from app.crud import collection_region
leaders = collection_region.list_active_region_leaders(db, tenant_no='tenant-a')
assert [{'value': row['id'], 'label': row['nickName']} for row in leaders] == out['regionManagers']
assert leaders[0]['regionIds'] == ['ra', 'rb']
assert all(set(row) == {'id', 'nickName', 'regionIds'} for row in leaders)
if region_id == 'ra':
assert 'staff-a' in {row['value'] for row in out['staff']}
assert 'user-b' not in {row['value'] for row in out['staff']}
@@ -2518,3 +2523,25 @@ def test_payment_transfer_response_carries_server_authorized_capabilities():
'submit-finance',
]
assert pending['allowedActions'] == ['withdraw-transfer']
def test_web_protocol_manager_options_derive_tenant_from_authenticated_user():
from app.api import collection_protocol
from app.core.menu_guard import require_admin_or_super_admin
db = _session()
_seed_options(db)
response = asyncio.run(collection_protocol.list_protocol_region_managers(
user={**_user(), 'tenant_no': 'tenant-b'}, db=db))
assert response['data']['list'] == [
{'id': 'leader-a', 'nickName': 'A负责人', 'regionIds': ['ra']},
{'id': 'user-b', 'nickName': 'B经理', 'regionIds': ['rb']},
]
route = next(route for route in collection_protocol.router.routes
if route.path == '/web/admin/collection-protocols/region-managers')
assert any(dep.call == require_admin_or_super_admin for dep in route.dependant.dependencies)
paths = [route.path for route in collection_protocol.router.routes]
assert paths.index(route.path) < paths.index('/web/admin/collection-protocols/{protocol_id}')
db.query(BizCollectionRegionStaff).update({'is_leader': 0})
db.commit()
assert asyncio.run(collection_protocol.list_protocol_region_managers(user=_user(), db=db))['data']['list'] == []
db.close()
+8
View File
@@ -27,6 +27,14 @@ export function fetchCollectionProtocolList(params?: {
})
}
export type ProtocolRegionManager = { id: string; nickName: string; regionIds: string[] }
export function fetchProtocolRegionManagers() {
return request.get<{ list: ProtocolRegionManager[] }>({
url: '/web/admin/collection-protocols/region-managers'
})
}
export function fetchCollectionProtocolDetail(id: string) {
return request.get<{
protocol: Record<string, unknown>
@@ -0,0 +1,166 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import ts from 'typescript'
const source = readFileSync(
new URL('../views/cell-storage/protocol/ProtocolFormDialog.vue', import.meta.url),
'utf8'
)
function method(name: string) {
const asyncStart = source.indexOf(` async function ${name}(`)
const start = asyncStart >= 0 ? asyncStart : source.indexOf(` function ${name}(`)
const end = source.indexOf('\n }', start) + 4
return source.slice(start, end)
}
function harness() {
const requests: Array<{ resolve: (value: any) => void; reject: (error: Error) => void }> = []
const hospitals: Array<{ resolve: () => void }> = []
const refs = {
managerLoading: { value: false },
managerUserOptions: { value: [] as any[] },
managerSnapshot: { value: null as any },
regionManagerUserId: { value: null as string | null },
formData: { regionId: 'a', regionManagerUserId: '' }
}
const deps = {
...refs,
linkedRegionMode: { value: false },
regionOptions: { value: [] },
hospitalOptions: { value: [] },
fetchRegionsByStaffUser: async () => ({ list: [{ id: 'b', regionName: 'B区' }] }),
loadHospitalsByRegion: () => new Promise<void>((resolve) => hospitals.push({ resolve })),
fetchProtocolRegionManagers: () =>
new Promise((resolve, reject) => requests.push({ resolve, reject })),
fetchUserList: async () => ({ list: [{ id: 'member', nickName: '普通员工' }] }),
fetchRegionStaffList: async () => ({
list: [{ userId: 'member', nickName: '普通员工', isLeader: false }]
}),
makeManagerUserStub: (id: string, nickName: string) => ({ id, nickName }),
ElMessage: { error: () => {} }
}
const code = ts.transpileModule(
`let managerSearchVersion = 0; let managerDefaultVersion = 0;
${method('remoteSearchManagerUsers')}
${method('applyDefaultRegionManager')}
${method('onManagerUserChange')}
${method('applyRegionsForSalesUser')}
return { search: remoteSearchManagerUsers, apply: applyDefaultRegionManager, select: onManagerUserChange, applySales: applyRegionsForSalesUser }`,
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } }
).outputText
return {
...refs,
requests,
hospitals,
...new Function(...Object.keys(deps), code)(...Object.values(deps))
}
}
const leaders = [
{ id: 'head-a', nickName: 'A负责人', regionIds: ['a'] },
{ id: 'head-b', nickName: 'B负责人', regionIds: ['b'] }
]
test('manager search uses all region leaders without appending historical ordinary users', async () => {
const h = harness()
h.regionManagerUserId.value = 'member'
h.managerSnapshot.value = { id: 'member', nickName: '历史人员' }
const pending = h.search('')
h.requests[0]?.resolve({ list: leaders })
await pending
assert.deepEqual(
h.managerUserOptions.value.map((u: any) => u.id),
['head-a', 'head-b']
)
assert.equal(h.managerSnapshot.value.nickName, '历史人员')
})
test('a region without a leader stays empty instead of defaulting to an ordinary member', async () => {
const h = harness()
const pending = h.apply('a')
h.requests[0]?.resolve({ list: [leaders[1]] })
await pending
assert.equal(h.formData.regionManagerUserId, '')
assert.equal(h.regionManagerUserId.value, null)
})
test('default manager belongs to selected region while candidates include all leaders', async () => {
const h = harness()
const pending = h.apply('a')
h.requests[0]?.resolve({ list: leaders })
await pending
assert.equal(h.formData.regionManagerUserId, 'head-a')
assert.deepEqual(
h.managerUserOptions.value.map((u: any) => u.id),
['head-a', 'head-b']
)
})
test('clearing a region invalidates pending manager defaults', async () => {
const h = harness()
const pending = h.apply('a')
h.formData.regionId = ''
await h.apply('')
h.requests[0]?.resolve({ list: leaders })
await pending
assert.equal(h.formData.regionManagerUserId, '')
})
test('out-of-order manager searches cannot overwrite the latest result', async () => {
const h = harness()
const first = h.search('A')
const second = h.search('B')
h.requests[1]?.resolve({ list: leaders })
await second
h.requests[0]?.resolve({ list: leaders })
await first
assert.deepEqual(
h.managerUserOptions.value.map((u: any) => u.id),
['head-b']
)
})
test('a manual selection is not overwritten by a pending default response', async () => {
const h = harness()
const pending = h.apply('a')
h.managerUserOptions.value = leaders
h.select('head-b')
h.requests[0].resolve({ list: leaders })
await pending
assert.equal(h.formData.regionManagerUserId, 'head-b')
})
test('failed manager loading clears the old default and never falls back to ordinary users', async () => {
const h = harness()
h.formData.regionManagerUserId = 'old'
h.regionManagerUserId.value = 'old'
const pending = h.apply('a')
h.requests[0].reject(new Error('network failure'))
await pending
assert.equal(h.formData.regionManagerUserId, '')
assert.deepEqual(h.managerUserOptions.value, [])
})
test('a delayed default call for an old region cannot clear the current manager', async () => {
const h = harness()
h.formData.regionId = 'b'
h.formData.regionManagerUserId = 'head-b'
h.regionManagerUserId.value = 'head-b'
const pending = h.apply('a')
h.requests[0]?.resolve({ list: leaders })
await pending
assert.equal(h.formData.regionManagerUserId, 'head-b')
assert.equal(h.regionManagerUserId.value, 'head-b')
})
test('sales-linked region loading does not overwrite a manager chosen while hospitals load', async () => {
const h = harness()
const pending = h.applySales('sales-b')
await Promise.resolve()
h.managerUserOptions.value = leaders
h.select('head-a')
h.hospitals[0].resolve()
await Promise.resolve()
h.requests[0].resolve({ list: leaders })
await pending
assert.equal(h.formData.regionManagerUserId, 'head-a')
})
@@ -154,10 +154,21 @@
clearable
reserve-keyword
:remote-method="remoteSearchManagerUsers"
@visible-change="(visible: boolean) => visible && remoteSearchManagerUsers('')"
:loading="managerLoading"
placeholder="默认取地区负责人,可修改"
@change="onManagerUserChange"
>
<el-option
v-if="
managerSnapshot &&
!managerUserOptions.some((u) => u.id === managerSnapshot?.id)
"
v-show="false"
:label="managerSnapshot.nickName"
:value="managerSnapshot.id"
disabled
/>
<el-option
v-for="u in managerUserOptions"
:key="u.id"
@@ -1162,6 +1173,7 @@
} from '@element-plus/icons-vue'
import {
fetchCollectionProtocolDetail,
fetchProtocolRegionManagers,
fetchCreateCollectionProtocol,
fetchUpdateCollectionProtocol,
fetchSubmitReceiptFinance,
@@ -1188,7 +1200,6 @@
fetchItemUnitList,
fetchCollectionRegionList,
fetchHospitalList,
fetchRegionStaffList,
fetchRegionsByStaffUser,
fetchSampleCodeList,
fetchExpressCompanyList,
@@ -1308,7 +1319,9 @@
const salesSnapshot = ref<Api.User.UserItem | null>(null)
const regionManagerUserId = ref<string | null>(null)
const managerUserOptions = ref<Api.User.UserItem[]>([])
const managerUserOptions = ref<Array<Api.User.UserItem & { regionIds: string[] }>>([])
let managerSearchVersion = 0
let managerDefaultVersion = 0
const managerLoading = ref(false)
const managerSnapshot = ref<Api.User.UserItem | null>(null)
const linkedRegionMode = ref(false)
@@ -1607,6 +1620,9 @@
}
function resetForm() {
managerSearchVersion++
managerDefaultVersion++
managerLoading.value = false
Object.assign(formData, {
protocolNo: '',
protocolNoId: '',
@@ -2729,7 +2745,6 @@
(user) => {
managerSnapshot.value = { ...user }
regionManagerUserId.value = user.id
managerUserOptions.value = [{ ...user }]
formData.regionManagerUserId = user.id
},
() => {
@@ -2739,6 +2754,7 @@
formData.regionManagerUserId = ''
}
)
await remoteSearchManagerUsers('')
}
function makeManagerUserStub(id: string, name: string): Api.User.UserItem {
@@ -2974,28 +2990,29 @@
}
async function remoteSearchManagerUsers(query: string) {
const version = ++managerSearchVersion
managerLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword: (query || '').trim() || undefined,
status: '1'
})
let list = [...(res.list || [])]
const cur = regionManagerUserId.value
if (cur && !list.some((x) => x.id === cur)) {
const keep =
managerSnapshot.value?.id === cur
? managerSnapshot.value
: managerUserOptions.value.find((x) => x.id === cur)
if (keep) list = [keep, ...list]
const res = await fetchProtocolRegionManagers()
const keyword = (query || '').trim().toLowerCase()
const list = (res.list || []).map((item) => ({
...makeManagerUserStub(item.id, item.nickName),
regionIds: item.regionIds
}))
if (version === managerSearchVersion) {
managerUserOptions.value = list.filter((item) =>
item.nickName.toLowerCase().includes(keyword)
)
}
managerUserOptions.value = list
return list
} catch {
managerUserOptions.value = []
if (version === managerSearchVersion) {
managerUserOptions.value = []
ElMessage.error('地区负责人加载失败,请重试')
}
return []
} finally {
managerLoading.value = false
if (version === managerSearchVersion) managerLoading.value = false
}
}
@@ -3016,53 +3033,34 @@
}
function onManagerUserChange(val: string | null | undefined) {
managerDefaultVersion++
if (!val) {
regionManagerUserId.value = null
managerSnapshot.value = null
formData.regionManagerUserId = ''
return
}
const u = managerUserOptions.value.find((x) => x.id === val) || managerSnapshot.value
if (!u) {
regionManagerUserId.value = val
formData.regionManagerUserId = val
return
}
const u = managerUserOptions.value.find((x) => x.id === val)
if (!u) return
managerSnapshot.value = { ...u }
regionManagerUserId.value = u.id
formData.regionManagerUserId = u.id
}
async function applyDefaultRegionManager(regionId: string) {
if (!regionId) {
regionManagerUserId.value = null
formData.regionManagerUserId = ''
managerUserOptions.value = []
managerSnapshot.value = null
return
}
try {
const res: any = await fetchRegionStaffList(regionId, { page: 1, pageSize: 100 })
const list = (res?.list || []) as Array<Record<string, any>>
const leader = list.find((x) => x.isLeader) || list[0]
if (!leader?.userId) {
regionManagerUserId.value = null
formData.regionManagerUserId = ''
return
}
const stub = makeManagerUserStub(
String(leader.userId),
String(leader.nickName || leader.userName || '')
)
regionManagerUserId.value = stub.id
formData.regionManagerUserId = stub.id
managerSnapshot.value = { ...stub }
if (!managerUserOptions.value.some((x) => x.id === stub.id)) {
managerUserOptions.value = [stub, ...managerUserOptions.value]
}
} catch {
/* 地区无人员配置时留空,可手工选择 */
}
if (formData.regionId !== regionId) return
const version = ++managerDefaultVersion
regionManagerUserId.value = null
formData.regionManagerUserId = ''
managerSnapshot.value = null
if (!regionId) return
const list = await remoteSearchManagerUsers('')
if (version !== managerDefaultVersion || formData.regionId !== regionId) return
const leader = list.find((item) => item.regionIds.includes(regionId))
if (!leader) return
regionManagerUserId.value = leader.id
formData.regionManagerUserId = leader.id
managerSnapshot.value = { ...leader }
}
async function applyRegionsForSalesUser(
@@ -3112,10 +3110,10 @@
if (regionChanged) {
formData.hospitalId = ''
hospitalOptions.value = []
await loadHospitalsByRegion(nextId, '')
if (!opts?.skipManagerDefault) {
await applyDefaultRegionManager(nextId)
}
await Promise.all([
loadHospitalsByRegion(nextId, ''),
...(opts?.skipManagerDefault ? [] : [applyDefaultRegionManager(nextId)])
])
} else if (!opts?.skipManagerDefault && !formData.regionManagerUserId) {
await applyDefaultRegionManager(nextId)
}
@@ -3246,6 +3244,7 @@
}
async function onRegionChange(val: string | null | undefined) {
managerDefaultVersion++
if (!val) {
formData.regionId = ''
hospitalOptions.value = []
@@ -3259,11 +3258,11 @@
formData.regionId = val
formData.hospitalId = ''
hospitalOptions.value = []
await loadHospitalsByRegion(val)
await applyDefaultRegionManager(val)
await Promise.all([loadHospitalsByRegion(val), applyDefaultRegionManager(val)])
}
function onRegionClear() {
managerDefaultVersion++
formData.regionId = ''
formData.hospitalId = ''
hospitalOptions.value = []