fix: 发起调研自动关联人员岗位

This commit is contained in:
江林铖 2026-07-29 18:36:22 +08:00
parent 64f3fb211e
commit 2466bb2b02
4 changed files with 165 additions and 66 deletions

View File

@ -174,4 +174,67 @@ describe('考核调研顶层导航保护', () => {
{ scroll: false },
))
})
test('发起调研按当前被考核人的人员分配自动关联岗位', async () => {
mockSearchParams = new URLSearchParams('tab=manage&adminWorkspace=launch&adminMonth=2026-07')
global.fetch = jest.fn(async (input) => {
const url = String(input)
if (url.endsWith('/me')) {
return jsonResponse({
user: { id: 'user-admin', name: '管理员', email: 'admin@example.com' },
employee: {
id: 'employee-admin',
displayName: '管理员',
departmentName: '品牌创意部',
positionLevel: 'M1',
},
permissions: {
canCreate: true,
isSystemAdmin: true,
isDepartmentAdmin: true,
isLeadership: true,
},
})
}
if (url.includes('/position-groups?month=2026-07')) {
return jsonResponse({
month: '2026-07',
groups: [{
id: 'group-1',
code: 'brand_designer',
name: '品牌设计师',
status: 'ready',
templateCount: 1,
}],
membership: {
status: 'matched',
groupCode: 'brand_designer',
groupName: '品牌设计师',
conflictGroupNames: [],
},
})
}
if (url.includes('/templates?month=2026-07&positionGroupCode=brand_designer')) {
return jsonResponse([{
...templateDefinition,
templateVersionId: 'version-1',
templateOptionKey: 'visual_review:version-1',
templateVariantName: '视觉案例评分',
}])
}
if (url.includes('/departments?purpose=evaluator')) return jsonResponse([])
if (url.endsWith('/surveys')) return jsonResponse([])
return jsonResponse([])
}) as jest.Mock
render(<PerformanceSurveyPage />)
expect(await screen.findByText('品牌设计师')).toBeInTheDocument()
expect(screen.queryByRole('combobox', { name: '考核岗位' })).not.toBeInTheDocument()
expect(screen.getByText('已按被考核人的人员分配自动关联,发起时无需选择。')).toBeInTheDocument()
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'/api/performance-survey/templates?month=2026-07&positionGroupCode=brand_designer',
expect.anything(),
))
})
})

View File

@ -855,11 +855,6 @@ function Dashboard({ me, permissionsLoading }: { me: any | null; permissionsLoad
<ManagePanel
currentEmployee={me?.employee || null}
isSystemAdmin={Boolean(me?.permissions?.isSystemAdmin)}
canSelectAnyPosition={Boolean(
me?.permissions?.isSystemAdmin
|| me?.permissions?.isDepartmentAdmin
|| me?.permissions?.isLeadership,
)}
configurationDirty={manageConfigurationDirty}
onConfigurationDirtyChange={setManageConfigurationDirty}
/>
@ -1219,7 +1214,6 @@ function PerformanceDashboardBoard({
params.set('tab', 'manage')
params.set('prefillMonth', targetMonth)
params.set('prefillTemplate', templateCode)
if (selectedPositionGroup?.code) params.set('prefillPositionGroup', selectedPositionGroup.code)
router.push(`/performance-survey?${params.toString()}`)
} : undefined}
/>
@ -2828,13 +2822,11 @@ const PERFORMANCE_SURVEY_ADMIN_WORKSPACES: PerformanceSurveyAdminWorkspace[] = [
function ManagePanel({
currentEmployee,
isSystemAdmin,
canSelectAnyPosition,
configurationDirty,
onConfigurationDirtyChange,
}: {
currentEmployee: Employee | null
isSystemAdmin: boolean
canSelectAnyPosition: boolean
configurationDirty: boolean
onConfigurationDirtyChange: (dirty: boolean) => void
}) {
@ -2984,7 +2976,6 @@ function ManagePanel({
<CreateSurveyForm
departments={departments.data || []}
currentEmployee={currentEmployee}
canSelectAnyPosition={canSelectAnyPosition}
defaultMonth={adminMonth}
onMonthChange={selectAdminMonth}
onAdminNavigate={isSystemAdmin ? (nextWorkspace) => void selectWorkspace(nextWorkspace) : undefined}
@ -3079,7 +3070,6 @@ function TemplateStatsPanel({ items }: { items: any[] }) {
function CreateSurveyForm({
departments,
currentEmployee,
canSelectAnyPosition,
defaultMonth,
onMonthChange,
onAdminNavigate,
@ -3087,7 +3077,6 @@ function CreateSurveyForm({
}: {
departments: DepartmentOption[]
currentEmployee: Employee | null
canSelectAnyPosition: boolean
defaultMonth?: string
onMonthChange?: (month: string) => void
onAdminNavigate?: (workspace: PerformanceSurveyAdminWorkspace) => void
@ -3097,12 +3086,10 @@ function CreateSurveyForm({
const searchParams = useSearchParams()
const prefillMonth = searchParams.get('prefillMonth') || ''
const prefillTemplate = searchParams.get('prefillTemplate') || ''
const prefillPositionGroup = searchParams.get('prefillPositionGroup') || ''
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [templateOptionKey, setTemplateOptionKey] = useState(prefillTemplate)
const [assessmentMonth, setAssessmentMonth] = useState(prefillMonth || defaultMonth || getCurrentMonthValue())
const [positionGroupCode, setPositionGroupCode] = useState(prefillPositionGroup)
const [deadlineAt, setDeadlineAt] = useState('')
const openNow = true
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
@ -3121,10 +3108,11 @@ function CreateSurveyForm({
const positionGroupsState = useApi<any>(positionGroupsPath, [positionGroupsPath])
const availablePositionGroups = positionGroupsState.data?.groups || []
const membershipStatus = positionGroupsState.data?.membership?.status as 'matched' | 'unmatched' | 'conflict' | undefined
const selectedPositionGroupCode = positionGroupCode
|| positionGroupsState.data?.membership?.groupCode
|| ''
const templatesPath = selectedPositionGroupCode
const selectedPositionGroupCode = membershipStatus === 'matched'
? positionGroupsState.data?.membership?.groupCode || ''
: ''
const selectedPositionGroup = availablePositionGroups.find((group: any) => group.code === selectedPositionGroupCode)
const templatesPath = selectedPositionGroup?.status === 'ready'
? `/templates?month=${encodeURIComponent(assessmentMonth)}&positionGroupCode=${encodeURIComponent(selectedPositionGroupCode)}`
: null
const templatesState = useApi<Template[]>(templatesPath, [templatesPath])
@ -3133,13 +3121,12 @@ function CreateSurveyForm({
useEffect(() => {
if (prefillMonth && /^\d{4}-\d{2}$/.test(prefillMonth)) setAssessmentMonth(prefillMonth)
else if (defaultMonth && /^\d{4}-\d{2}$/.test(defaultMonth)) setAssessmentMonth(defaultMonth)
if (prefillPositionGroup) setPositionGroupCode(prefillPositionGroup)
if (prefillTemplate) {
const matched = templates.find((template) => template.templateOptionKey === prefillTemplate)
|| templates.find((template) => template.code === prefillTemplate)
if (matched) setTemplateOptionKey(matched.templateOptionKey || matched.code)
}
}, [defaultMonth, prefillMonth, prefillPositionGroup, prefillTemplate, templates])
}, [defaultMonth, prefillMonth, prefillTemplate, templates])
const selectedTemplate = templateOptionKey || templates[0]?.templateOptionKey || templates[0]?.code || ''
const selectedTemplateInfo = templates.find((template) => (template.templateOptionKey || template.code) === selectedTemplate)
@ -3190,13 +3177,15 @@ function CreateSurveyForm({
requiresManualEvaluators
&& evaluatorEmployees.length === 0
) publishValidationIssues.push('至少选择 1 名评分人')
if (!selectedPositionGroupCode) publishValidationIssues.push('选择考核岗位')
if (!canSelectAnyPosition && membershipStatus === 'unmatched') {
if (membershipStatus === 'unmatched') {
publishValidationIssues.push('联系管理员配置本人考核岗位')
}
if (!canSelectAnyPosition && membershipStatus === 'conflict') {
if (membershipStatus === 'conflict') {
publishValidationIssues.push('联系管理员处理本人岗位重复匹配')
}
if (membershipStatus === 'matched' && selectedPositionGroup?.status !== 'ready') {
publishValidationIssues.push('联系管理员完成本人岗位考核项配置')
}
if (visualTemplateSelected && currentTargetSelected) {
const visibleVisualCases = visualCases.slice(0, visualRequiredCount)
if (visibleVisualCases.length < visualRequiredCount) {
@ -3270,7 +3259,6 @@ function CreateSurveyForm({
title: submitTitle,
description,
surveyKind,
positionGroupCode: selectedPositionGroupCode,
templateCode: selectedTemplateCode,
templateVersionId: selectedTemplateInfo?.templateVersionId || null,
assessmentMonth: selectedAssessmentMonth,
@ -3317,7 +3305,6 @@ function CreateSurveyForm({
setTitle('')
setDescription('')
setTemplateOptionKey('')
setPositionGroupCode('')
setAssessmentMonth(getCurrentMonthValue())
setDeadlineAt('')
setTargetEmployees([])
@ -3360,46 +3347,75 @@ function CreateSurveyForm({
}}
type="month"
/>
<label className="grid gap-2 text-sm">
<div className="grid gap-2 text-sm">
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考核岗位</span>
<select
value={selectedPositionGroupCode}
onChange={(event) => {
setPositionGroupCode(event.target.value)
setTemplateOptionKey('')
setTargetEmployees([])
}}
className="h-11 rounded border border-[#eeeeee] bg-white px-3 text-[14px] font-semibold text-[#1a1c1c] outline-none transition-colors focus:border-[#0050cb]"
<div
aria-live="polite"
className="flex min-h-11 min-w-0 items-center justify-between gap-3 rounded border border-[#eeeeee] bg-[#f9f9f9] px-3 py-2"
>
<option value="">选择考核岗位</option>
{availablePositionGroups.map((group: any) => (
<option key={group.code} value={group.code} disabled={group.status !== 'ready'}>
{group.name}{group.status === 'ready' ? ` · ${group.templateCount}项` : ' · 待配置'}
</option>
))}
</select>
{!canSelectAnyPosition && positionGroupsState.data?.membership?.status === 'conflict' && (
<span className="min-w-0 break-words text-[14px] font-semibold text-[#1a1c1c]">
{positionGroupsState.loading
? '正在关联人员岗位...'
: positionGroupsState.error
? '岗位关联失败'
: membershipStatus === 'matched'
? positionGroupsState.data?.membership?.groupName || '已关联岗位'
: membershipStatus === 'conflict'
? '岗位配置冲突'
: '尚未配置岗位'}
</span>
<span className="shrink-0 text-[11px] font-semibold text-[#5f5e5e]">自动关联</span>
</div>
{positionGroupsState.error && (
<div
role="alert"
className="flex flex-wrap items-center justify-between gap-2 border border-[#ffdad6] bg-[#fff6f4] px-3 py-2 text-[12px] font-semibold text-[#93000a]"
>
<span className="min-w-0 break-words">岗位关联失败:{positionGroupsState.error}</span>
<button
type="button"
onClick={() => void positionGroupsState.refresh()}
className="min-h-9 shrink-0 rounded border border-[#93000a] px-3 hover:bg-[#93000a] hover:text-white"
>
重新加载
</button>
</div>
)}
{membershipStatus === 'matched' && (
<div className="text-[12px] leading-5 text-[#5f5e5e]">
已按被考核人的人员分配自动关联,发起时无需选择。
</div>
)}
{positionGroupsState.data?.membership?.status === 'conflict' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前人员同时命中多个岗位:{(positionGroupsState.data.membership.conflictGroupNames || []).join('、')}
</div>
)}
{!canSelectAnyPosition && positionGroupsState.data?.membership?.status === 'unmatched' && (
{positionGroupsState.data?.membership?.status === 'unmatched' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前人员尚未匹配考核岗位,请联系系统管理员完成岗位配置
</div>
)}
</label>
{membershipStatus === 'matched' && selectedPositionGroup?.status !== 'ready' && (
<div className="text-[12px] font-semibold text-[#b42318]">
当前岗位尚未完成考核项配置,暂时不能发起调研
</div>
)}
</div>
{onAdminNavigate && positionGroupsState.data && (
availablePositionGroups.every((group: any) => group.status !== 'ready')
membershipStatus !== 'matched'
|| selectedPositionGroup?.status !== 'ready'
|| (selectedPositionGroupCode && !templatesState.loading && templates.length === 0)
) && (
<div className="border border-[#e4b86a] bg-[#fffaf0] px-3 py-3 text-[12px] leading-5 text-[#6f4700]">
<div className="font-bold">
{availablePositionGroups.every((group: any) => group.status !== 'ready')
? `${assessmentMonth} 暂无可发起的岗位`
: '所选岗位还没有可用考核模板'}
{membershipStatus !== 'matched'
? '当前人员还不能自动关联岗位'
: selectedPositionGroup?.status !== 'ready'
? '当前岗位尚未完成配置'
: '当前岗位还没有可用考核模板'}
</div>
<p className="mt-1">请先补齐岗位设置、人员归属和可用模板,再回来发起调研。</p>
<p className="mt-1">请先补齐人员归属、岗位设置和可用模板,再回来发起调研。</p>
<div className="mt-3 flex flex-wrap gap-2">
{([
['positions', '检查岗位设置'],

View File

@ -872,7 +872,6 @@ describe('performance survey service policies', () => {
await createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'brand_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-2'],
@ -891,6 +890,29 @@ describe('performance survey service policies', () => {
expect(transactionClient.performanceSurveyTarget.findMany.mock.invocationCallOrder[0]).toBeLessThan(
transactionClient.performanceSurvey.create.mock.invocationCallOrder[0],
)
expect(getPerformanceSurveyPositionGroupByCode).not.toHaveBeenCalled()
expect(transactionClient.performanceSurvey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
positionGroupCode: 'brand_designer',
positionGroupId: 'position-group-brand',
}),
})
})
test('客户端不能用旧岗位参数覆盖被考核人的自动岗位', async () => {
await expect(createPerformanceSurvey(session, {
templateCode: 'course_share',
positionGroupCode: 'ai_designer',
assessmentMonth: '2026-07',
targetEmployeeIds: ['employee-1'],
evaluatorEmployeeIds: ['employee-2'],
openNow: false,
})).rejects.toEqual(
new PerformanceSurveyApiError('考核岗位已根据被考核人的人员分配自动关联,请刷新页面后重试', 409),
)
expect(getPerformanceSurveyPositionGroupByCode).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('发起人不能手动选择自己作为评分人员', async () => {

View File

@ -2821,28 +2821,26 @@ export async function createPerformanceSurvey(session: Session | null, body: any
const assessmentMonthInput = normalizePerformanceSurveyAssessmentMonth(body?.assessmentMonth)
if (!assessmentMonthInput) throw new PerformanceSurveyApiError('请选择有效的考核月份')
const positionGroupCode = clean(body?.positionGroupCode)
if (!positionGroupCode) throw new PerformanceSurveyApiError('请选择考核岗位组')
let positionGroup
if (!ctx.employee) throw new PerformanceSurveyApiError('当前账户尚未匹配人员档案', 409)
let creatorMembership
try {
positionGroup = await getPerformanceSurveyPositionGroupByCode(positionGroupCode, assessmentMonthInput)
creatorMembership = await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, assessmentMonthInput)
} catch (error) {
return rethrowPositionConfigError(error)
}
if (creatorMembership.status !== 'matched' || !creatorMembership.group) {
throw new PerformanceSurveyApiError(
creatorMembership.status === 'conflict'
? '当前被考核人命中多个考核岗位组,请联系管理员处理'
: '当前被考核人尚未配置考核岗位组',
409,
)
}
const positionGroup = creatorMembership.group
if (positionGroup.status !== 'ready') throw new PerformanceSurveyApiError('该岗位组尚未完成配置,不能发起调研', 409)
if (!ctx.isSystemAdmin && !ctx.isLeadership && !ctx.isDepartmentAdmin) {
if (!ctx.employee) throw new PerformanceSurveyApiError('当前账户尚未匹配人员档案', 409)
const creatorMembership = await getPerformanceSurveyPositionGroupForEmployee(ctx.employee, assessmentMonthInput)
if (creatorMembership.status !== 'matched') {
throw new PerformanceSurveyApiError(
creatorMembership.status === 'conflict' ? '当前账户命中多个考核岗位组,请联系管理员处理' : '当前账户尚未配置考核岗位组',
409,
)
}
if (creatorMembership.group?.code !== positionGroup.code) {
throw new PerformanceSurveyApiError('普通员工只能发起本人岗位组的考核调研', 403)
}
const legacyRequestedPositionGroupCode = clean(body?.positionGroupCode)
if (legacyRequestedPositionGroupCode && legacyRequestedPositionGroupCode !== positionGroup.code) {
throw new PerformanceSurveyApiError('考核岗位已根据被考核人的人员分配自动关联,请刷新页面后重试', 409)
}
const templateVersionId = clean(body?.templateVersionId)