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 01/10] 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 02/10] 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 03/10] 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、验证结果及未完成项,标注“已修复”;待验收、未合并或未发布须分别说明,不能以“已修复”代替验收和关闭条件。 - 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。 - 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。 From 6356bdecc07ab9b282db07b20b5d4e851dee10ad Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 30 Oct 2026 18:14:05 +0800 Subject: [PATCH 04/10] docs(agents): adopt test ownership and automatic issue completion Default to agent validation followed by merge/push/close. Respect explicit user testing without running tests or builds, provide branch and runtime handoff, and resume completion on acceptance. Preserve separate release and service permissions. Refs #4. --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 54c3a2a8..9aab1ad8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,9 @@ - **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。 - 提交使用 Conventional Commits,完整说明“改了什么、为什么”。 - 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。 -- 修复后回写工单,附提交、PR、验证结果及未完成项,标注“已修复”;待验收、未合并或未发布须分别说明,不能以“已修复”代替验收和关闭条件。 +- 默认由 Agent 测试:自动进入任务分支/worktree,核对实际运行版本,完成测试与审查后合入 `V2.0`、提交推送、同步本地主分支并关闭 Gitea 工单。用户已授予这条常规收尾链路持续授权,不逐次询问合并;打 tag、部署与服务启停仍分别受原有授权限制。 +- 用户明确“我来测试”时,该任务由用户测试:Agent 不运行测试或构建,仍完成代码审查和 Git 核对;交付时提醒切换到具体分支,提供 worktree 路径、提交、前后端配套入口和验证要点。用户确认通过后直接合并推送、关闭工单;未确认或失败时不合并、不关闭。 +- 合并前核对验收提交与最新目标;目标更新或冲突处理改变代码时,按原测试责任补验,用户模式仍交用户验证。合并后代码树与验收版本一致不重复测试;确认远端同步成功后回写验收结果、合并提交和 PR 并关单。 - 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。 - 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。 @@ -45,7 +47,7 @@ ## 验证与交付 -- 执行与改动范围相符的检查,报告实际结果和未执行项;文档变更检查链接与引用,业务变更检查受影响测试及构建。 +- 按任务测试责任验证,报告实际结果和未执行项:Agent 模式按改动范围检查文档链接或业务测试与构建;用户模式记录用户验收结论,不由 Agent 运行测试和构建。 - CRUD 字段一致性按 [开发指南](docs/dev-field-consistency.md) 检查;根目录命令是 `python backend/scripts/check_field_consistency.py`。该工具仅覆盖注册实体,已知失效路径见该指南。 - 使用源码、引用检索和测试核对影响范围。已移除的 graphify 不再是开发依赖或交付步骤,不安装、运行或恢复其自动 hook。 From 0e985936712d50571336ed2e1f2bc096b8c45e8e Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 30 Oct 2026 18:32:04 +0800 Subject: [PATCH 05/10] docs(agents): confirm application scope before implementation Ask which applications to change when task scope is unclear, reuse confirmed scope, and confirm shared API impact before expanding it. Refs #4. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 9aab1ad8..0bbe0fd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ - **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。 - 提交使用 Conventional Commits,完整说明“改了什么、为什么”。 - 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。 +- 修改端不明确时,先查当前任务和工单的已确认范围;仍不明确则询问修改 Web、小程序、移动 OA、后端中的哪些端,还是所有相关端。确认前仅只读定位、回写工单待确认范围,不修改业务代码;截图不能代替确认。已有明确范围直接沿用,答复“所有端”须记录具体应用清单;共享后端/API 影响范围外的端时,说明影响并确认扩展后再实施。 - 默认由 Agent 测试:自动进入任务分支/worktree,核对实际运行版本,完成测试与审查后合入 `V2.0`、提交推送、同步本地主分支并关闭 Gitea 工单。用户已授予这条常规收尾链路持续授权,不逐次询问合并;打 tag、部署与服务启停仍分别受原有授权限制。 - 用户明确“我来测试”时,该任务由用户测试:Agent 不运行测试或构建,仍完成代码审查和 Git 核对;交付时提醒切换到具体分支,提供 worktree 路径、提交、前后端配套入口和验证要点。用户确认通过后直接合并推送、关闭工单;未确认或失败时不合并、不关闭。 - 合并前核对验收提交与最新目标;目标更新或冲突处理改变代码时,按原测试责任补验,用户模式仍交用户验证。合并后代码树与验收版本一致不重复测试;确认远端同步成功后回写验收结果、合并提交和 PR 并关单。 From 0e64b413ce97d45eeebefbb6c71802c105bed032 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 30 Oct 2026 20:42:05 +0800 Subject: [PATCH 06/10] feat(oa): add permission-controlled shared finance remarks to done center Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks. Add idempotent MySQL migration without automatic role grants, cross-end editors, isolated backend and frontend tests, and rollout requirements. Refs #7; platform validation and target database migration remain pending. --- backend/app/api/oa_approval.py | 52 ++- backend/app/models/models.py | 1 + backend/app/services/oa_finance_remark.py | 100 ++++++ backend/tests/test_oa_finance_remark.py | 195 ++++++++++++ docs/README.md | 2 + .../2026-10-30-oa-done-finance-remark.sql | 50 +++ mobile-h5/pages/oa/done/index.vue | 258 ++++++++++++--- mobile-h5/utils/oa-api.js | 16 + .../changes/oa-done-finance-remark/design.md | 12 + .../oa-done-finance-remark/proposal.md | 15 + .../specs/oa-done-finance-remark/spec.md | 22 ++ .../changes/oa-done-finance-remark/tasks.md | 13 + web-admin/scripts/test-oa-finance-remark.mjs | 299 ++++++++++++++++++ web-admin/src/api/oaApproval.ts | 17 +- .../src/views/oa-approval/done/index.vue | 214 ++++++++++--- 15 files changed, 1188 insertions(+), 78 deletions(-) create mode 100644 backend/app/services/oa_finance_remark.py create mode 100644 backend/tests/test_oa_finance_remark.py create mode 100644 docs/sql/migrations/2026-10-30-oa-done-finance-remark.sql create mode 100644 openspec/changes/oa-done-finance-remark/design.md create mode 100644 openspec/changes/oa-done-finance-remark/proposal.md create mode 100644 openspec/changes/oa-done-finance-remark/specs/oa-done-finance-remark/spec.md create mode 100644 openspec/changes/oa-done-finance-remark/tasks.md create mode 100644 web-admin/scripts/test-oa-finance-remark.mjs diff --git a/backend/app/api/oa_approval.py b/backend/app/api/oa_approval.py index a27e70b0..d5ab2881 100644 --- a/backend/app/api/oa_approval.py +++ b/backend/app/api/oa_approval.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session @@ -15,6 +15,7 @@ from app.crud import oa_instance_draft as oid_crud from app.crud import oa_receive_account as ora_crud from app.models.models import SysUser from app.services.oa_receive_account import normalize_account_fields +from app.services import oa_finance_remark as finance_remark_svc router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"]) @@ -1054,6 +1055,44 @@ async def list_oa_instance_form_attachments( return error_response(400, str(e)) +class OaFinanceRemarkBody(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + finance_remark: str = Field(..., alias="financeRemark", max_length=512, strict=True) + + +@router.get("/instances/{instance_id}/finance-remark") +async def get_oa_finance_remark( + instance_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + access = finance_remark_svc.resolve_access(db, user) + return success_response(finance_remark_svc.get_finance_remark(db, access, instance_id)) + except finance_remark_svc.OaFinanceRemarkError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + + +@router.put("/instances/{instance_id}/finance-remark") +async def update_oa_finance_remark( + instance_id: str, + body: OaFinanceRemarkBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + access = finance_remark_svc.resolve_access(db, user) + result = finance_remark_svc.update_finance_remark(db, access, instance_id, body.finance_remark) + db.commit() + return success_response(result, message="财务备注已保存") + except finance_remark_svc.OaFinanceRemarkError as exc: + db.rollback() + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + except Exception: + db.rollback() + raise + + @router.get("/instances/{instance_id}/bank-receipts") async def list_oa_bank_receipts( instance_id: str, @@ -1365,7 +1404,8 @@ async def list_oa_tasks( from app.services.oa_engine import OaEngineError, list_tasks try: - tenant_no = _get_tenant_no(db, user) + access = finance_remark_svc.resolve_access(db, user) if kind == "done" else None + tenant_no = access.tenant_no if access else _get_tenant_no(db, user) data = list_tasks( db, tenant_no=tenant_no, @@ -1381,7 +1421,13 @@ async def list_oa_tasks( page=page, page_size=pageSize, ) - return success_response({"list": data.get("list") or [], "total": int(data.get("total") or 0)}) + result = {"list": data.get("list") or [], "total": int(data.get("total") or 0)} + if access is not None: + finance_remark_svc.add_list_remarks(db, access, result["list"]) + result["canManageFinanceRemark"] = access.can_manage + return success_response(result) + except finance_remark_svc.OaFinanceRemarkError as exc: + raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc except OaEngineError as e: return error_response(400, str(e)) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 2e156311..60bf1632 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -920,6 +920,7 @@ class OaInstance(Base): default="running", comment="running/returned/completed/rejected/cancelled", ) + finance_remark = Column(String(512), nullable=True, comment="已办财务备注(专用权限控制)") source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID") current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") diff --git a/backend/app/services/oa_finance_remark.py b/backend/app/services/oa_finance_remark.py new file mode 100644 index 00000000..de9f4150 --- /dev/null +++ b/backend/app/services/oa_finance_remark.py @@ -0,0 +1,100 @@ +"""已办中心实例级财务备注;权限与本人已办范围同时满足才可访问。""" +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, List + +from sqlalchemy.orm import Session + +from app.models.models import OaInstance, OaTask, SysUser +from app.services.web_auth_roles import iter_user_menu_perm_codes + +PERMISSION = "oa:done:finance-remark" +DONE_STATUSES = ("approved", "rejected", "returned", "transferred") + + +class OaFinanceRemarkError(ValueError): + def __init__(self, status_code: int, message: str): + super().__init__(message) + self.status_code = status_code + + +@dataclass(frozen=True) +class FinanceRemarkAccess: + user_id: str + tenant_no: str + can_manage: bool + + +def resolve_access(db: Session, user: dict) -> FinanceRemarkAccess: + # 不使用缺省租户、客户端租户或角色名称作为敏感字段的授权依据。 + uid = str(user.get("user_id") or "").strip() + identity = db.query(SysUser.id, SysUser.tenant_no).filter( + SysUser.id == uid, SysUser.del_flag == 0, SysUser.status == "1" + ).first() + if not identity or not str(identity.tenant_no or "").strip(): + raise OaFinanceRemarkError(403, "用户不可用") + tenant = str(identity.tenant_no).strip() + granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant) + return FinanceRemarkAccess(uid, tenant, PERMISSION in granted) + + +def _accessible_instances(db: Session, access: FinanceRemarkAccess): + own_done = db.query(OaTask.id).filter( + OaTask.instance_id == OaInstance.id, + OaTask.tenant_no == access.tenant_no, + OaTask.assignee_user_id == access.user_id, + OaTask.task_type == "approval", + OaTask.status.in_(DONE_STATUSES), + OaTask.del_flag == 0, + ).exists() + return db.query(OaInstance).filter( + OaInstance.tenant_no == access.tenant_no, + OaInstance.del_flag == 0, + own_done, + ) + + +def _require_instance(db: Session, access: FinanceRemarkAccess, instance_id: str) -> OaInstance: + if not access.can_manage: + raise OaFinanceRemarkError(403, "无权查看或修改财务备注") + instance = _accessible_instances(db, access).filter(OaInstance.id == instance_id).first() + if instance is None: + raise OaFinanceRemarkError(404, "审批单不在本人已办范围内") + return instance + + +def _remark_data(instance: OaInstance) -> Dict[str, str]: + return {"instanceId": instance.id, "financeRemark": instance.finance_remark or ""} + + +def get_finance_remark(db: Session, access: FinanceRemarkAccess, instance_id: str) -> Dict[str, str]: + return _remark_data(_require_instance(db, access, instance_id)) + + +def update_finance_remark( + db: Session, access: FinanceRemarkAccess, instance_id: str, finance_remark: str +) -> Dict[str, str]: + instance = _require_instance(db, access, instance_id) + if not isinstance(finance_remark, str) or len(finance_remark) > 512: + raise OaFinanceRemarkError(422, "财务备注最多 512 字") + instance.finance_remark = finance_remark + instance.update_by = access.user_id + instance.update_time = datetime.now() + db.flush() + return _remark_data(instance) + + +def add_list_remarks(db: Session, access: FinanceRemarkAccess, rows: List[Dict[str, Any]]) -> None: + # 即使调用方未来调整序列化,也先移除敏感字段,再按权限补充。 + for row in rows: + row.pop("financeRemark", None) + if not access.can_manage: + return + ids = {row.get("instanceId") for row in rows if row.get("instanceId")} + if not ids: + return + instances = _accessible_instances(db, access).filter(OaInstance.id.in_(ids)).all() + remarks = {inst.id: inst.finance_remark or "" for inst in instances} + for row in rows: + if row.get("instanceId") in remarks: + row["financeRemark"] = remarks[row["instanceId"]] diff --git a/backend/tests/test_oa_finance_remark.py b/backend/tests/test_oa_finance_remark.py new file mode 100644 index 00000000..11e56720 --- /dev/null +++ b/backend/tests/test_oa_finance_remark.py @@ -0,0 +1,195 @@ +"""OA 财务备注:真实隔离数据库和 HTTP 路由的读写/权限契约。""" +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.oa_approval import router +from app.core.auth import get_current_user +from app.core.database import Base, get_db +from app.models.models import ( + OaApp, OaAppVersion, OaInstance, OaTask, + SysUser, SysRole, SysUserRole, SysMenu, SysRoleMenu, + SysPost, SysPostRole, SysUserPost, +) + +PERM = 'oa:done:finance-remark' +PREFIX = '/web/admin/oa-approval' +URL = PREFIX + '/instances/i1/finance-remark' + + +@pytest.fixture +def case(): + engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) + tables = (OaApp, OaAppVersion, OaInstance, OaTask, SysUser, SysRole, + SysUserRole, SysMenu, SysRoleMenu, SysPost, SysPostRole, SysUserPost) + Base.metadata.create_all(engine, tables=[m.__table__ for m in tables]) + db = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)() + for uid, tenant in [('u1', 'a'), ('u2', 'a'), ('outsider', 'a'), ('other', 'b')]: + db.add(SysUser(id=uid, user_no=uid, nick_name=uid, password='unused', tenant_no=tenant, status='1')) + db.add_all([ + OaApp(id='app', app_name='报销', tenant_no='a'), + OaAppVersion(id='v1', app_id='app', version_no=1, tenant_no='a'), + SysRole(id='finance', role_name='财务', tenant_no='a', status='1'), + SysMenu(id='menu', menu_name='财务备注', parent_id='done', menu_type='F', perms=PERM, tenant_no='a', status='1'), + SysRoleMenu(id='rm', role_id='finance', menu_id='menu', tenant_no='a'), + ]) + for uid in ['u1', 'u2', 'outsider']: + db.add(SysUserRole(id='ur-'+uid, user_id=uid, role_id='finance', tenant_no='a')) + for iid, tenant in [('i1', 'a'), ('i2', 'a'), ('foreign', 'b')]: + inst = OaInstance(id=iid, app_id='app', app_version_id='v1', title=iid, + initiator_user_id='u1', tenant_no=tenant, status='completed', form_data_json='{}') + inst.finance_remark = '原备注' + db.add(inst) + for tid, uid, iid, tenant, status in [ + ('t1', 'u1', 'i1', 'a', 'approved'), ('t2', 'u2', 'i1', 'a', 'rejected'), + ('pending', 'u1', 'i2', 'a', 'pending'), ('foreign-task', 'u1', 'foreign', 'b', 'approved'), + ]: + db.add(OaTask(id=tid, instance_id=iid, assignee_user_id=uid, tenant_no=tenant, + task_type='approval', status=status)) + db.commit() + user = {'user_id': 'u1', 'tenant_no': 'a', 'role': 'admin'} + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_current_user] = lambda: user + app.dependency_overrides[get_db] = lambda: db + with TestClient(app) as client: + yield db, client, user + db.close() + engine.dispose() + + +def test_shared_save_reload_and_clear(case): + db, client, user = case + result = client.get(URL) + assert result.status_code == 200 + assert result.json()['data'] == {'instanceId': 'i1', 'financeRemark': '原备注'} + for text in ['财务核对完成\n<标签按原文显示>', '汉' * 512, '']: + user['user_id'] = 'u1' + response = client.put(URL, json={'financeRemark': text}) + assert response.status_code == 200 + assert response.json()['code'] == 200 + db.expire_all() + assert db.get(OaInstance, 'i1').finance_remark == text + assert db.get(OaInstance, 'i1').update_by == 'u1' + user['user_id'] = 'u2' + assert client.get(URL).json()['data']['financeRemark'] == text + rows = client.get(PREFIX + '/tasks/done').json()['data'] + assert rows['canManageFinanceRemark'] is True + assert rows['list'][0]['financeRemark'] == text + + +@pytest.mark.parametrize('role', ['admin', 'super_admin']) +def test_no_permission_hides_list_and_denies_both_routes(case, role): + db, client, user = case + db.get(SysRoleMenu, 'rm').del_flag = 1 + db.commit() + user['role'] = role + data = client.get(PREFIX + '/tasks/done').json()['data'] + assert data['canManageFinanceRemark'] is False + assert len(data['list']) == 1 + assert 'financeRemark' not in data['list'][0] + assert client.get(URL).status_code == 403 + assert client.put(URL, json={'financeRemark': '泄漏'}).status_code == 403 + + +@pytest.mark.parametrize('target', ['i2', 'foreign', 'missing']) +def test_only_own_done_instances_are_accessible(case, target): + _, client, _ = case + url = PREFIX + '/instances/' + target + '/finance-remark' + assert client.get(url).status_code == 404 + assert client.put(url, json={'financeRemark': '越权'}).status_code == 404 + + +def test_authorized_outsider_cannot_read_or_write(case): + _, client, user = case + user['user_id'] = 'outsider' + assert client.get(URL).status_code == 404 + assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404 + + +@pytest.mark.parametrize('model,key', [(OaInstance, 'i1'), (OaTask, 't1')]) +def test_deleted_instance_or_done_task_denies_access(case, model, key): + db, client, _ = case + db.get(model, key).del_flag = 1 + db.commit() + assert client.get(URL).status_code == 404 + assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404 + data = client.get(PREFIX + '/tasks/done').json()['data'] + assert all('financeRemark' not in row for row in data['list']) + + +@pytest.mark.parametrize('status', ['approved', 'rejected', 'returned', 'transferred']) +def test_all_existing_done_statuses_can_manage(case, status): + db, client, _ = case + db.get(OaTask, 't1').status = status + db.commit() + assert client.get(URL).status_code == 200 + + +@pytest.mark.parametrize('status', ['pending', 'done', 'cancelled']) +def test_non_done_statuses_cannot_manage(case, status): + db, client, _ = case + db.get(OaTask, 't1').status = status + db.commit() + assert client.get(URL).status_code == 404 + + +def test_forged_tenant_uses_real_user_tenant(case): + _, client, user = case + user['tenant_no'] = 'b' + assert client.get(URL).json()['data']['financeRemark'] == '原备注' + data = client.get(PREFIX + '/tasks/done').json()['data'] + assert [r['instanceId'] for r in data['list']] == ['i1'] + assert client.get(PREFIX + '/instances/foreign/finance-remark').status_code == 404 + + +@pytest.mark.parametrize('model,key,field,value', [ + (SysUser, 'u1', 'status', '0'), (SysUser, 'u1', 'del_flag', 1), + (SysRole, 'finance', 'status', '0'), (SysRole, 'finance', 'tenant_no', 'b'), + (SysMenu, 'menu', 'status', '0'), (SysMenu, 'menu', 'tenant_no', 'b'), + (SysUserRole, 'ur-u1', 'tenant_no', 'b'), +]) +def test_inactive_or_cross_tenant_grants_deny_access(case, model, key, field, value): + db, client, _ = case + setattr(db.get(model, key), field, value) + db.commit() + assert client.get(URL).status_code == 403 + assert client.put(URL, json={'financeRemark': '越权'}).status_code == 403 + + +def test_post_role_permission_union(case): + db, client, _ = case + db.get(SysUserRole, 'ur-u1').del_flag = 1 + db.add_all([ + SysPost(id='post', post_name='财务岗', post_code='finance', tenant_no='a', status='1'), + SysUserPost(id='up', user_id='u1', post_id='post', tenant_no='a'), + SysPostRole(id='pr', post_id='post', role_id='finance', tenant_no='a'), + ]) + db.commit() + assert client.get(URL).status_code == 200 + db.get(SysPost, 'post').status = '0' + db.commit() + assert client.get(URL).status_code == 403 + + +@pytest.mark.parametrize('body', [ + {}, {'financeRemark': None}, {'financeRemark': 123}, {'financeRemark': 'a' * 513}, + {'financeRemark': 'note', 'tenantNo': 'b'}, {'financeRemark': 'note', 'status': 'running'}, +]) +def test_invalid_body_rejected_without_writes(case, body): + db, client, _ = case + assert client.put(URL, json=body).status_code == 422 + db.expire_all() + assert db.get(OaInstance, 'i1').finance_remark == '原备注' + + +def test_generic_instance_serialization_does_not_expose_finance_remark(case): + db, client, _ = case + from app.services.oa_engine import _instance_to_dict + assert 'financeRemark' not in _instance_to_dict(db.get(OaInstance, 'i1')) + for kind in ['mine', 'pending']: + data = client.get(PREFIX + '/tasks/' + kind).json()['data'] + assert all('financeRemark' not in row for row in data['list']) diff --git a/docs/README.md b/docs/README.md index 73b1527d..ca3c1de2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ | 数据库 | [SQL 初始化与迁移](sql/) | | 部署 | [部署导航](../deploy/README.md) | +OA 已办财务备注上线前先执行 [2026-10-30 迁移](sql/migrations/2026-10-30-oa-done-finance-remark.sql),再更新应用;迁移不自动赋权。通过角色菜单配置授予已办中心下“财务备注(查看和修改)”权限(`oa:done:finance-remark`),同一权限作用于 Web 和移动端,仍限本人已办单据。 + `docs/sql/zkxw.sql` 仍被交付打包和 Compose 引用,但当前初始化基线不完整,不能据此宣称可从空库部署。SOP HTML 是导入脚本的输入资源,继续保留。 历史细胞产品导入必须显式指定 `--file`,不再自带默认 TSV。实际业务测试保留在各应用源码目录;删除历史报告不代表现有测试已经执行或通过。 diff --git a/docs/sql/migrations/2026-10-30-oa-done-finance-remark.sql b/docs/sql/migrations/2026-10-30-oa-done-finance-remark.sql new file mode 100644 index 00000000..f35d4d32 --- /dev/null +++ b/docs/sql/migrations/2026-10-30-oa-done-finance-remark.sql @@ -0,0 +1,50 @@ +-- OA 已办中心财务备注(Gitea #7),MySQL 5.7,先于应用代码部署。 +-- 在目标业务库执行;结构与菜单插入均可重复执行,不自动授予任何角色权限。 +-- 权限配置:在已办中心下勾选「财务备注(查看和修改)」;同一权限码作用于两端。 +-- 菜单按当前租户中既有 OA 已办页面定位,不挂载到旧工作流已办中心。 + +SET @oa_finance_column_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'oa_instance' + AND column_name = 'finance_remark' +); +SET @oa_finance_ddl = IF( + @oa_finance_column_exists = 0, + 'ALTER TABLE oa_instance ADD COLUMN finance_remark VARCHAR(512) NULL COMMENT ''已办财务备注(专用权限控制)'' AFTER status', + 'SELECT ''oa_instance.finance_remark already exists'' AS migration_status' +); +PREPARE oa_finance_stmt FROM @oa_finance_ddl; +EXECUTE oa_finance_stmt; +DEALLOCATE PREPARE oa_finance_stmt; + +-- 由租户及父菜单生成稳定的 UUID 形状 ID,避免多行 SELECT 中 UUID 表达式被复用。 +-- 已有软删/停用权限节点不自动复活,由管理员决定是否恢复。 +INSERT INTO sys_menu ( + id, menu_name, parent_id, menu_type, path, component, perms, + is_frame, visible, status, tenant_no, order_num, remark, + create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + INSERT(INSERT(MD5(CONCAT('oa-finance-remark:', parent.tenant_no, ':', parent.id)), + 13, 1, '3'), 17, 1, '8'), + '财务备注(查看和修改)', parent.id, 'F', '', NULL, + 'oa:done:finance-remark', 1, '1', '1', parent.tenant_no, 90, + '控制本人已办审批单的共享财务备注;Web 与移动端共用权限', + 'migration', '', NOW(), 'migration', NOW(), 0 +FROM sys_menu AS parent +WHERE parent.del_flag = 0 AND parent.status = '1' AND parent.menu_type = 'C' + AND ( + parent.perms IN ('oa:done', 'mini:oa:done') + OR TRIM(BOTH '/' FROM parent.component) = 'oa-approval/done/index' + OR TRIM(BOTH '/' FROM parent.path) IN ('pages/oa/done/index', 'oa/done/index') + ) + AND NOT EXISTS ( + SELECT 1 FROM sys_menu AS existing + WHERE existing.parent_id = parent.id + AND existing.tenant_no = parent.tenant_no + AND existing.perms = 'oa:done:finance-remark' + ); + +-- 验证必须至少存在当前租户的权限节点;若无结果,先核对该租户 OA 已办菜单配置。 +SELECT tenant_no, parent_id, id, menu_name, perms, status +FROM sys_menu WHERE perms = 'oa:done:finance-remark' AND del_flag = 0; diff --git a/mobile-h5/pages/oa/done/index.vue b/mobile-h5/pages/oa/done/index.vue index 3190dea7..e8e0597b 100644 --- a/mobile-h5/pages/oa/done/index.vue +++ b/mobile-h5/pages/oa/done/index.vue @@ -32,11 +32,7 @@ @@ -58,23 +54,22 @@ }} - + {{ line }} + + 财务备注:{{ row.financeRemark || '—' }} + + {{ avatarText(row) }} {{ submitterText(row) }} - + {{ statusText(row.instanceStatus) }} @@ -82,12 +77,52 @@ 加载更多… 已经到底了 + + + + + 财务备注 + 正在加载最新备注… +