diff --git a/backend/app/api/collection_protocol.py b/backend/app/api/collection_protocol.py index c236a87f..c952ecdb 100644 --- a/backend/app/api/collection_protocol.py +++ b/backend/app/api/collection_protocol.py @@ -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, diff --git a/backend/app/crud/collection_region.py b/backend/app/crud/collection_region.py index 54d82f1f..9cad665d 100644 --- a/backend/app/crud/collection_region.py +++ b/backend/app/crud/collection_region.py @@ -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, *, diff --git a/backend/tests/test_market_support_api.py b/backend/tests/test_market_support_api.py index a3f8968e..056338c7 100644 --- a/backend/tests/test_market_support_api.py +++ b/backend/tests/test_market_support_api.py @@ -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() diff --git a/web-admin/src/api/collection-protocol.ts b/web-admin/src/api/collection-protocol.ts index 4c552057..dd610800 100644 --- a/web-admin/src/api/collection-protocol.ts +++ b/web-admin/src/api/collection-protocol.ts @@ -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 diff --git a/web-admin/src/utils/protocol-region-managers.test.ts b/web-admin/src/utils/protocol-region-managers.test.ts new file mode 100644 index 00000000..62f29371 --- /dev/null +++ b/web-admin/src/utils/protocol-region-managers.test.ts @@ -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((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') +}) diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue b/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue index 82ea62ec..625ee90d 100644 --- a/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue +++ b/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue @@ -154,10 +154,21 @@ clearable reserve-keyword :remote-method="remoteSearchManagerUsers" + @visible-change="(visible: boolean) => visible && remoteSearchManagerUsers('')" :loading="managerLoading" placeholder="默认取地区负责人,可修改" @change="onManagerUserChange" > + (null) const regionManagerUserId = ref(null) - const managerUserOptions = ref([]) + const managerUserOptions = ref>([]) + let managerSearchVersion = 0 + let managerDefaultVersion = 0 const managerLoading = ref(false) const managerSnapshot = ref(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> - 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 = []