From f6cfce3315187c5238a02194e07090513a522f86 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 30 Oct 2026 16:11:17 +0800 Subject: [PATCH 1/3] fix(market): restrict protocol managers to region leaders Use a dedicated tenant-scoped active region leader list instead of all region staff. Preserve historical labels without offering them as choices and remove the ordinary-member default during region changes. Refs #1. Add regression coverage for tenant isolation, inactive records, deduplication, historical labels and region cascades. --- backend/app/services/market_support.py | 18 +++++++ backend/tests/test_market_support_api.py | 49 +++++++++++++++++++ .../market/protocol-region-cascade.test.js | 20 ++++++-- mobile-oa-h5/src/market/reference-options.js | 10 +++- .../src/market/reference-options.test.js | 27 +++++++++- .../src/oa/components/OaSelectField.vue | 7 +-- .../src/oa/oa-select-display-only.test.js | 26 ++++++++++ .../src/views/market/MarketModuleForm.vue | 13 +++-- 8 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 mobile-oa-h5/src/oa/oa-select-display-only.test.js diff --git a/backend/app/services/market_support.py b/backend/app/services/market_support.py index db62c93f..7690608e 100644 --- a/backend/app/services/market_support.py +++ b/backend/app/services/market_support.py @@ -470,6 +470,24 @@ def get_form_options( 'currentUserRegions': current_user_regions, } if normalized_module == 'protocol': + # 地区经理独立取全部有效地区负责人,不随医院/地区人员联动缩小。 + manager_rows = ( + db.query(SysUser.id, SysUser.nick_name) + .join(BizCollectionRegionStaff, BizCollectionRegionStaff.user_id == SysUser.id) + .filter( + BizCollectionRegionStaff.region_id.in_(active_region_ids), + BizCollectionRegionStaff.tenant_no == ctx.tenant_no, + BizCollectionRegionStaff.del_flag == 0, + BizCollectionRegionStaff.is_leader == 1, + SysUser.tenant_no == ctx.tenant_no, + SysUser.del_flag == 0, + SysUser.status == '1', + ) + .distinct() + .order_by(SysUser.nick_name.asc(), SysUser.id.asc()) + .all() + ) + result['regionManagers'] = [_option(row.id, row.nick_name) for row in manager_rows] ethnicities = ( db.query(SysEthnic) .filter( diff --git a/backend/tests/test_market_support_api.py b/backend/tests/test_market_support_api.py index f050ec9c..a3f8968e 100644 --- a/backend/tests/test_market_support_api.py +++ b/backend/tests/test_market_support_api.py @@ -131,6 +131,55 @@ def _seed_options(db): db.commit() +@pytest.mark.parametrize('region_id', [None, 'ra', 'rb']) +def test_protocol_manager_options_only_include_active_tenant_region_leaders(region_id): + db = _session() + _seed_options(db) + common = dict(tenant_no='tenant-a', create_dept_id='', del_flag=0) + for suffix in ('c', 'd'): + db.add(BizCollectionRegion(id=f'r{suffix}', region_name=f'{suffix}区', **common)) + db.add(SysUser(id=f'leader-{suffix}', user_no=f'l{suffix}', nick_name=f'{suffix}负责人', status='1', **common)) + db.add(BizCollectionRegionStaff(id=f'leader-binding-{suffix}', region_id=f'r{suffix}', user_id=f'leader-{suffix}', is_leader=1, **common)) + db.add(BizCollectionRegion(id='deleted-region', region_name='已删区', **{**common, 'del_flag': 1})) + db.add(SysUser(id='deleted-user', user_no='deleted', nick_name='已删用户', status='1', **{**common, 'del_flag': 1})) + for binding_id, region, user, tenant, deleted in [ + ('duplicate-leader', 'rb', 'leader-a', 'tenant-a', 0), + ('disabled-leader', 'ra', 'disabled', 'tenant-a', 0), + ('deleted-user-leader', 'ra', 'deleted-user', 'tenant-a', 0), + ('deleted-region-leader', 'deleted-region', 'staff-a', 'tenant-a', 0), + ('deleted-binding', 'ra', 'staff-a', 'tenant-a', 1), + ('cross-tenant-user', 'ra', 'cross-user', 'tenant-a', 0), + ('cross-tenant-binding', 'ra', 'staff-a', 'tenant-b', 0), + ('cross-tenant-region', 'rx', 'staff-a', 'tenant-a', 0), + ]: + db.add(BizCollectionRegionStaff(id=binding_id, region_id=region, user_id=user, is_leader=1, + tenant_no=tenant, create_dept_id='', del_flag=deleted)) + db.commit() + + out = market_support.get_form_options(db, module='protocol', ctx=_ctx(), user_info=_user(), region_id=region_id) + assert out['regionManagers'] == [ + {'value': 'leader-a', 'label': 'A负责人'}, + {'value': 'user-b', 'label': 'B经理'}, + {'value': 'leader-c', 'label': 'c负责人'}, + {'value': 'leader-d', 'label': 'd负责人'}, + ] + 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']} + db.close() + + +def test_protocol_manager_options_are_empty_without_leaders(): + db = _session() + _seed_options(db) + db.query(BizCollectionRegionStaff).update({'is_leader': 0}) + db.commit() + out = market_support.get_form_options(db, module='protocol', ctx=_ctx(), user_info=_user()) + assert out['regionManagers'] == [] + assert out['staff'] + db.close() + + def test_specialist_form_options_list_all_tenant_regions_like_web(monkeypatch): """市场专员采集地区选项应与 Web 一致:本租户全部地区,不依赖人员绑定。""" from app.services.market_support import get_form_options diff --git a/mobile-oa-h5/src/market/protocol-region-cascade.test.js b/mobile-oa-h5/src/market/protocol-region-cascade.test.js index 9c719e71..5e3f6cc5 100644 --- a/mobile-oa-h5/src/market/protocol-region-cascade.test.js +++ b/mobile-oa-h5/src/market/protocol-region-cascade.test.js @@ -8,6 +8,8 @@ const source = readFileSync(new URL('../views/market/MarketModuleForm.vue', impo const start = source.indexOf('async function applyCollectionRegion(') const end = source.indexOf('\nfunction sampleKey(', start) +const regionManagers = [{ value: 'ma', label: '经理 A' }, { value: 'mb', label: '经理 B' }] + function createHarness() { const formData = { value: { collection: { regionId: 'a', regionName: '地区 A', hospitalId: 'ha', hospitalName: '医院 A', @@ -17,7 +19,8 @@ function createHarness() { regions: [{ value: 'a', label: '地区 A' }, { value: 'b', label: '地区 B' }], hospitals: [{ value: 'ha', label: '医院 A' }], staff: [{ value: 'ma', label: '经理 A' }], - managerStaff: [{ value: 'ma', label: '经理 A' }] + managerStaff: regionManagers, + regionManagers } } const requests = [] const messages = [] @@ -38,6 +41,7 @@ function createHarness() { } const optionsB = { + regionManagers, hospitals: [{ value: 'hb', label: '医院 B' }], staff: [{ value: 'mb', label: '经理 B', isLeader: true }] } @@ -53,7 +57,7 @@ test('changing protocol region clears the old manager and selects the new region assert.equal(during.regionManagerUserName, '') assert.deepEqual(pendingHospitals, []) assert.equal(h.formData.value.collection.regionManagerUserId, 'mb') - assert.deepEqual(h.formOptions.value.managerStaff.map((item) => item.value), ['mb']) + assert.deepEqual(h.formOptions.value.managerStaff.map((item) => item.value), ['ma', 'mb']) }) test('clearing protocol region invalidates a pending response and clears dependent selections', async () => { @@ -67,7 +71,7 @@ test('clearing protocol region invalidates a pending response and clears depende assert.equal(h.formData.value.collection.regionName, '') assert.equal(h.formData.value.collection.regionManagerUserId, '') assert.deepEqual(h.formOptions.value.hospitals, []) - assert.deepEqual(h.formOptions.value.managerStaff, []) + assert.deepEqual(h.formOptions.value.managerStaff, regionManagers) }) test('out-of-order protocol region responses cannot replace the latest selection', async () => { @@ -82,3 +86,13 @@ test('out-of-order protocol region responses cannot replace the latest selection assert.deepEqual(h.formOptions.value.hospitals.map((item) => item.value), ['hb']) assert.equal(h.formData.value.collection.regionManagerUserId, 'mb') }) + + +test('a region without a leader does not default its ordinary member as manager', async () => { + const h = createHarness() + const pending = h.change('b', { defaultManager: true }) + h.requests[0].resolve({ ...optionsB, staff: [{ value: 'member', label: '普通员工', isLeader: false }] }) + await pending + assert.equal(h.formData.value.collection.regionManagerUserId, '') + assert.deepEqual(h.formOptions.value.managerStaff, regionManagers) +}) diff --git a/mobile-oa-h5/src/market/reference-options.js b/mobile-oa-h5/src/market/reference-options.js index 92c091c2..fe3b27b7 100644 --- a/mobile-oa-h5/src/market/reference-options.js +++ b/mobile-oa-h5/src/market/reference-options.js @@ -115,6 +115,9 @@ export function hydrateProtocolReferenceOptions(options, detail) { const collection = detail?.collection || {} const samples = Array.isArray(detail?.samples) ? detail.samples : [] const currentSampleCodeIds = samples.map((item) => item?.sampleCodeId).filter(Boolean) + const regionManagers = personOptions(source.regionManagers, []) + const managerId = normalizedText(collection.regionManagerUserId) + const currentManagerIsEligible = regionManagers.some((item) => normalizedText(item.value) === managerId) const sampleTypeReference = (item) => { const extra = {} const storagePrice = item?.originalPrice ?? item?.price @@ -141,8 +144,11 @@ export function hydrateProtocolReferenceOptions(options, detail) { ] ), managerStaff: personOptions( - source.staff, - reference(collection.regionManagerUserId, collection.regionManagerUserName) + regionManagers, + // 历史负责人仅用于姓名回显,不重新加入可选人员。 + currentManagerIsEligible ? [] : reference( + collection.regionManagerUserId, collection.regionManagerUserName, { displayOnly: true } + ) ), regions: referenceOptions(source.regions, reference(collection.regionId, collection.regionName)), hospitals: referenceOptions(source.hospitals, reference(collection.hospitalId, collection.hospitalName)), diff --git a/mobile-oa-h5/src/market/reference-options.test.js b/mobile-oa-h5/src/market/reference-options.test.js index 7fff4a61..91101c1c 100644 --- a/mobile-oa-h5/src/market/reference-options.test.js +++ b/mobile-oa-h5/src/market/reference-options.test.js @@ -63,7 +63,8 @@ test('protocol options hydrate all selected reference values from detail labels' options.salesStaff.map((item) => item.label), ['现有专员', '市场专员', '当前登录人'] ) - assert.deepEqual(options.managerStaff.map((item) => item.label), ['现有专员', '地区经理']) + assert.deepEqual(options.managerStaff.map((item) => item.label), ['地区经理']) + assert.equal(options.managerStaff[0].displayOnly, true) assert.deepEqual(options.regions, [{ value: 'region-1', label: '合肥区域' }]) assert.deepEqual(options.hospitals, [{ value: 'hospital-1', label: '省妇幼' }]) assert.deepEqual(options.sampleTypes, [{ value: 'type-1', label: '脐带血' }]) @@ -290,3 +291,27 @@ test('presign options retain current values without exposing raw ids', () => { assert.deepEqual(presign.orderStatuses, [{ value: 'raw-order-status', label: '未知' }]) assert.deepEqual(presign.transferTypes, [{ value: 'raw-transfer', label: '未知' }]) }) + + +test('protocol managers use only the dedicated leader list and keep old names for display', () => { + const source = { + staff: [{ value: 'member', label: '普通员工' }], + regionManagers: [ + { value: 'manager-a', label: 'A负责人' }, + { value: 'manager-b', label: 'B负责人' } + ], + currentUser: { value: 'member', label: '普通员工' } + } + const historical = hydrateProtocolReferenceOptions(source, { + collection: { regionManagerUserId: 'old', regionManagerUserName: '历史负责人' } + }) + assert.deepEqual(historical.managerStaff, [ + ...source.regionManagers, + { value: 'old', label: '历史负责人', displayOnly: true } + ]) + const current = hydrateProtocolReferenceOptions(source, { + collection: { regionManagerUserId: 'manager-a', regionManagerUserName: '旧姓名' } + }) + assert.deepEqual(current.managerStaff, source.regionManagers) + assert.deepEqual(hydrateProtocolReferenceOptions({ staff: source.staff }, {}).managerStaff, []) +}) diff --git a/mobile-oa-h5/src/oa/components/OaSelectField.vue b/mobile-oa-h5/src/oa/components/OaSelectField.vue index bdf673a1..459ae760 100644 --- a/mobile-oa-h5/src/oa/components/OaSelectField.vue +++ b/mobile-oa-h5/src/oa/components/OaSelectField.vue @@ -128,7 +128,8 @@ const normalizedOptions = computed(() => { }) }) -const pickerColumns = computed(() => normalizedOptions.value) +// 历史引用可回显,但不作为新的选择候选。 +const pickerColumns = computed(() => normalizedOptions.value.filter((option) => !option.displayOnly)) const columnsFieldNames = computed(() => ({ text: 'text', value: 'value', @@ -170,8 +171,8 @@ const pickerValue = computed(() => { const filteredOptions = computed(() => { const q = keyword.value.trim().toLowerCase() - if (!q) return normalizedOptions.value - return normalizedOptions.value.filter( + if (!q) return pickerColumns.value + return pickerColumns.value.filter( (o) => String(o.text || '') .toLowerCase() diff --git a/mobile-oa-h5/src/oa/oa-select-display-only.test.js b/mobile-oa-h5/src/oa/oa-select-display-only.test.js new file mode 100644 index 00000000..d54b5119 --- /dev/null +++ b/mobile-oa-h5/src/oa/oa-select-display-only.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./components/OaSelectField.vue', import.meta.url), 'utf8') +const start = source.indexOf('const textKey = computed(') +const end = source.indexOf('\nfunction emitValue(', start) + +function selectState(props, keyword = '') { + return new Function('props', 'computed', 'isMultiple', 'keyword', + `${source.slice(start, end)}; return { pickerColumns, filteredOptions, displayText }` + )(props, (read) => ({ get value() { return read() } }), { value: !!props.multiple }, { value: keyword }) +} + +test('historical values retain readable labels but cannot appear in the picker or search', () => { + const options = [ + { value: 'active', label: '现任负责人' }, + { value: 'old', label: '历史负责人', displayOnly: true } + ] + const state = selectState({ options, modelValue: 'old' }) + assert.equal(state.displayText.value, '历史负责人') + assert.deepEqual(state.pickerColumns.value.map((item) => item.value), ['active']) + assert.deepEqual(state.filteredOptions.value.map((item) => item.value), ['active']) + assert.deepEqual(selectState({ options, multiple: true }, '历史').filteredOptions.value, []) + assert.equal(selectState({ options, modelValue: 'active' }).displayText.value, '现任负责人') +}) diff --git a/mobile-oa-h5/src/views/market/MarketModuleForm.vue b/mobile-oa-h5/src/views/market/MarketModuleForm.vue index 7220e0db..8bc60274 100644 --- a/mobile-oa-h5/src/views/market/MarketModuleForm.vue +++ b/mobile-oa-h5/src/views/market/MarketModuleForm.vue @@ -602,12 +602,11 @@ async function applyCollectionRegion(regionId, { resetHospital = true, defaultMa collection.regionManagerUserName = '' } formData.value = { ...formData.value, collection } - formOptions.value = { + formOptions.value = hydrateProtocolReferenceOptions({ ...formOptions.value, ...(resetHospital ? { hospitals: [] } : {}), - staff: [], - managerStaff: [] - } + staff: [] + }, formData.value) if (!value) return try { const options = await fetchMarketFormOptions( @@ -621,7 +620,11 @@ async function applyCollectionRegion(regionId, { resetHospital = true, defaultMa formData.value ) if (defaultManager && !formData.value.collection?.regionManagerUserId) { - const leader = (options?.staff || []).find((item) => item.isLeader) || options?.staff?.[0] + const leader = (options?.staff || []).find((item) => + item.isLeader && (options?.regionManagers || []).some( + (manager) => String(manager.value) === String(item.value) + ) + ) if (leader?.value) { formData.value = { ...formData.value, From d1347e8cea2db57a550f01b9b9d2dc07255a8417 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 30 Oct 2026 17:47:43 +0800 Subject: [PATCH 2/3] 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. --- backend/app/api/collection_protocol.py | 10 ++ backend/app/crud/collection_region.py | 29 +++ backend/tests/test_market_support_api.py | 27 +++ web-admin/src/api/collection-protocol.ts | 8 + .../utils/protocol-region-managers.test.ts | 166 ++++++++++++++++++ .../protocol/ProtocolFormDialog.vue | 121 +++++++------ 6 files changed, 300 insertions(+), 61 deletions(-) create mode 100644 web-admin/src/utils/protocol-region-managers.test.ts 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 = [] From 3793dcdfac48d764b339d354e5d3b3dbf7167db8 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 30 Oct 2026 17:52:45 +0800 Subject: [PATCH 3/3] docs(agents): require Gitea issue intake and repair status updates Create issues for new unnumbered development requests, read supplied issues before implementation and reuse the active issue for follow-ups. Report repaired, acceptance, merge and release states separately. --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3be651c3..54c3a2a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,8 @@ - 仅做任务必要的改动,不夹带无关重构或全仓库格式化;保留用户已有改动。 - **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。 - 提交使用 Conventional Commits,完整说明“改了什么、为什么”。 +- 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。 +- 修复后回写工单,附提交、PR、验证结果及未完成项,标注“已修复”;待验收、未合并或未发布须分别说明,不能以“已修复”代替验收和关闭条件。 - 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。 - 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。