feat: 规范月度调研命名与明细
This commit is contained in:
parent
7af0c1b926
commit
993a92dd3f
@ -518,7 +518,7 @@ export default function PerformanceSurveyPage() {
|
||||
|
||||
const isSurveySubmitPage = path[0] === 'survey' && Boolean(path[1])
|
||||
const isResultsPage = path[0] === 'results' && Boolean(path[1])
|
||||
let content = <Dashboard />
|
||||
let content = <Dashboard me={currentUser.data} />
|
||||
if (isSurveySubmitPage && path[1]) {
|
||||
content = <SurveySubmit surveyId={path[1]} session={effectiveSession} />
|
||||
}
|
||||
@ -583,16 +583,15 @@ function PerformanceSurveyMobileHeader({ session }: { session: any }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
function Dashboard({ me }: { me: any | null }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const requestedTabRaw = searchParams.get('tab') || 'personal'
|
||||
const requestedTab = normalizePerformanceSurveyDashboardTab(requestedTabRaw)
|
||||
const searchParamsKey = searchParams.toString()
|
||||
const [tab, setTab] = useState<PerformanceSurveyDashboardTab>(requestedTab)
|
||||
const me = useApi<any>('/me', [])
|
||||
const displayName = me.data?.employee?.displayName || me.data?.employee?.flowerName || me.data?.user?.name || '当前用户'
|
||||
const avatar = me.data?.user?.avatar || me.data?.user?.image || ''
|
||||
const displayName = me?.employee?.displayName || me?.employee?.flowerName || me?.user?.name || '当前用户'
|
||||
const avatar = me?.user?.avatar || me?.user?.image || ''
|
||||
const availableTabIds = useMemo(
|
||||
() => ['personal', 'public', 'manage'],
|
||||
[],
|
||||
@ -650,7 +649,7 @@ function Dashboard() {
|
||||
<PerformanceDashboardBoard view={tab === 'public' ? 'public' : 'personal'} />
|
||||
)}
|
||||
{tab === 'manage' && (
|
||||
<ManagePanel currentEmployee={me.data?.employee || null} />
|
||||
<ManagePanel currentEmployee={me?.employee || null} />
|
||||
)}
|
||||
</main>
|
||||
<nav className="fixed inset-x-0 bottom-0 z-30 grid h-[68px] grid-cols-3 border-t border-[#e2e2e2] bg-white sm:hidden">
|
||||
@ -908,16 +907,21 @@ function MonthlyPersonalLedger({
|
||||
{rows.map((row) => (
|
||||
<article
|
||||
key={row.month}
|
||||
className="border border-[#c2c6d8] bg-white p-4"
|
||||
className={`rounded-lg border bg-white p-4 ${
|
||||
row.month === getCurrentMonthValue() ? 'border-[#0050cb]' : 'border-[#e2e2e2]'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[20px] font-bold text-[#1a1c1c]">{formatMonthLabel(row.month)}</div>
|
||||
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">CURRENT</div>
|
||||
{row.month === getCurrentMonthValue() && (
|
||||
<div className="rounded-full bg-[#dae1ff] px-2 py-1 text-[11px] font-bold text-[#0050cb]">本月</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.map((template) => (
|
||||
<MonthlyScoreCell
|
||||
key={template.code}
|
||||
embedded
|
||||
template={template}
|
||||
cell={row.cells?.[template.code]}
|
||||
onClick={() => onOpenDetail({
|
||||
@ -995,7 +999,7 @@ function MonthlyPublicLedger({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.map((template) => (
|
||||
<MonthlyScoreCell
|
||||
key={template.code}
|
||||
@ -1067,28 +1071,39 @@ function MonthlyScoreCell({
|
||||
template,
|
||||
cell,
|
||||
compact = false,
|
||||
embedded = false,
|
||||
canOpenOverride,
|
||||
onClick,
|
||||
}: {
|
||||
template: any
|
||||
cell: any
|
||||
compact?: boolean
|
||||
embedded?: boolean
|
||||
canOpenOverride?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const hasScore = cell && cell.score !== null && cell.score !== undefined
|
||||
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
|
||||
const className = `${compact ? 'min-h-[58px] px-2 py-2' : 'min-h-[86px] px-3 py-3'} border text-left transition-colors ${
|
||||
canOpen
|
||||
? 'border-[#c2c6d8] bg-white hover:border-[#0050cb] hover:bg-[#f9fbff]'
|
||||
: 'border-[#e2e2e2] bg-[#f9f9f9]'
|
||||
}`
|
||||
const hasSurvey = Number(cell?.surveyCount || 0) > 0
|
||||
const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed))
|
||||
const className = embedded
|
||||
? `${compact ? 'min-h-[82px]' : 'min-h-[100px]'} px-1 py-2 text-left transition-colors ${canOpen ? 'hover:bg-[#f8faff]' : ''}`
|
||||
: `${compact ? 'min-h-[82px] px-2 py-2' : 'min-h-[108px] px-3 py-3'} border text-left transition-colors ${
|
||||
canOpen
|
||||
? 'border-[#c2c6d8] bg-white hover:border-[#0050cb] hover:bg-[#f9fbff]'
|
||||
: 'border-[#e2e2e2] bg-[#f9f9f9]'
|
||||
}`
|
||||
const content = (
|
||||
<>
|
||||
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="truncate text-[11px] font-semibold text-[#5f5e5e]">{getTemplateDisplayName(template)}</div>
|
||||
{hasSurvey && <InlineSurveyState status={cell?.status} />}
|
||||
</div>
|
||||
<div className={`${compact ? 'line-clamp-2 text-[10px] leading-4' : 'truncate text-[12px]'} mt-1.5 font-semibold text-[#1a1c1c]`}>
|
||||
{cell?.title || '未发起'}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className={`${compact ? 'text-[17px]' : 'text-[24px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
|
||||
{hasScore ? formatNullableScore(cell.score) : '-'}
|
||||
<span className={`${compact ? 'text-[16px]' : 'text-[22px]'} font-bold leading-none ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
|
||||
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
|
||||
</span>
|
||||
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
|
||||
</div>
|
||||
@ -1116,25 +1131,29 @@ function MonthlyTableScoreCell({
|
||||
onClick: () => void
|
||||
}) {
|
||||
const hasScore = cell && cell.score !== null && cell.score !== undefined
|
||||
const canOpen = Boolean(hasScore && (canOpenOverride ?? cell?.detailAllowed))
|
||||
const hasSurvey = Number(cell?.surveyCount || 0) > 0
|
||||
const canOpen = Boolean(hasSurvey && (canOpenOverride ?? cell?.detailAllowed))
|
||||
const content = (
|
||||
<>
|
||||
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : 'text-[#727687]'}`}>
|
||||
{hasScore ? formatNullableScore(cell.score) : '-'}
|
||||
</span>
|
||||
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
|
||||
</>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className={`text-[16px] font-bold ${hasScore ? (canOpen ? 'text-[#0050cb]' : 'text-[#1a1c1c]') : hasSurvey ? 'text-[#0050cb]' : 'text-[#727687]'}`}>
|
||||
{hasScore ? formatNullableScore(cell.score) : hasSurvey ? '待评分' : '-'}
|
||||
</span>
|
||||
{hasScore && <span className="text-[11px] font-medium text-[#5f5e5e]">/120</span>}
|
||||
</div>
|
||||
{hasSurvey && <div className="mt-1 truncate text-[10px] font-medium text-[#5f5e5e]" title={cell?.title || ''}>{cell?.title}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!canOpen) {
|
||||
return <div className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left">{content}</div>
|
||||
return <div className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center">{content}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex min-h-9 min-w-[74px] items-baseline justify-center gap-1 text-left transition-colors hover:text-[#003fa4]"
|
||||
className="inline-flex min-h-12 w-full min-w-[74px] items-center justify-center text-center transition-colors hover:text-[#003fa4]"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
@ -1150,6 +1169,24 @@ function EmptyLedger({ title }: { title: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getSurveyStatusMeta(status: unknown) {
|
||||
const value = String(status || '').toLowerCase()
|
||||
if (value === 'open') return { label: '进行中', dot: 'bg-[#0050cb]', text: 'text-[#0050cb]' }
|
||||
if (value === 'closed') return { label: '已结束', dot: 'bg-[#5f5e5e]', text: 'text-[#5f5e5e]' }
|
||||
if (value === 'draft') return { label: '草稿', dot: 'bg-[#b26a00]', text: 'text-[#8a5200]' }
|
||||
return { label: '待发布', dot: 'bg-[#8a8a8a]', text: 'text-[#5f5e5e]' }
|
||||
}
|
||||
|
||||
function InlineSurveyState({ status }: { status: unknown }) {
|
||||
const meta = getSurveyStatusMeta(status)
|
||||
return (
|
||||
<span className={`inline-flex shrink-0 items-center gap-1 text-[10px] font-semibold ${meta.text}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} />
|
||||
{meta.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthlyScoreDetailModal({
|
||||
request,
|
||||
onClose,
|
||||
@ -1201,7 +1238,8 @@ function MonthlyScoreDetailModal({
|
||||
|
||||
const isVisualDetail = Boolean(detail.data?.template?.isVisual || detail.data?.workItems?.length)
|
||||
const responseCount = Number(detail.data?.responseCount || detail.data?.participants?.length || 0)
|
||||
const surveyCount = Number(detail.data?.surveyCount || 0)
|
||||
const primarySurvey = detail.data?.surveys?.[0] || null
|
||||
const workflow = detail.data?.workflow || {}
|
||||
|
||||
return createPortal((
|
||||
<div
|
||||
@ -1220,16 +1258,21 @@ function MonthlyScoreDetailModal({
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-[#e2e2e2] bg-white px-4 pb-4 pt-[calc(16px+env(safe-area-inset-top))] sm:px-5 sm:pt-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-semibold text-[#5f5e5e]">评估详情报告</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[12px] font-semibold text-[#5f5e5e]">
|
||||
<span>{formatMonthLabel(detail.data?.month || request.month)}</span>
|
||||
<span>·</span>
|
||||
<span>{detail.data?.template?.name || '考核明细'}</span>
|
||||
{primarySurvey && <InlineSurveyState status={primarySurvey.status} />}
|
||||
</div>
|
||||
<h2 id="performance-survey-detail-title" className="mt-2 break-words text-[22px] font-bold leading-tight text-[#1a1c1c] sm:text-[24px]">
|
||||
{detail.data?.template?.fullName || detail.data?.template?.name || '考核明细'}
|
||||
{primarySurvey?.title || detail.data?.template?.fullName || '考核明细'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-[28px] font-light leading-none text-[#5f5e5e] transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c]"
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-[28px] font-light leading-none text-[#5f5e5e] outline-none transition-colors hover:bg-[#eeeeee] hover:text-[#1a1c1c] focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2"
|
||||
aria-label="关闭详情"
|
||||
>
|
||||
×
|
||||
@ -1241,20 +1284,21 @@ function MonthlyScoreDetailModal({
|
||||
{detail.error && <ErrorPanel message={detail.error} />}
|
||||
{detail.data && (
|
||||
<div className="space-y-7">
|
||||
<div className="grid grid-cols-2 gap-5 sm:grid-cols-4">
|
||||
<DetailMetaItem label="评估周期" value={detail.data.month || request.month} />
|
||||
<div className="grid grid-cols-2 gap-5 border-b border-[#e2e2e2] pb-5 sm:grid-cols-4">
|
||||
<DetailMetaItem label="被评估人" value={detail.data.employee?.displayName || '-'} />
|
||||
<DetailMetaItem label="评分人数" value={responseCount || '-'} />
|
||||
<DetailMetaItem label="记录数" value={surveyCount || '-'} />
|
||||
<DetailMetaItem label="发起人" value={primarySurvey?.creatorName || '-'} />
|
||||
<DetailMetaItem label="截止时间" value={primarySurvey?.dueAt ? formatDate(primarySurvey.dueAt) : '未设置'} />
|
||||
<DetailMetaItem label="当前得分" value={detail.data.score === null || detail.data.score === undefined ? '待评分' : `${formatNullableScore(detail.data.score)} / 120`} />
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<DetailSectionTitle>核心评分</DetailSectionTitle>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<DetailScoreCard label="最终分" value={formatNullableScore(detail.data.score)} suffix="/120" strong />
|
||||
<DetailScoreCard label="评分人数" value={responseCount || 0} suffix="人" />
|
||||
<DetailScoreCard label="月度记录" value={surveyCount || 0} suffix="条" />
|
||||
<section className="space-y-4">
|
||||
<DetailSectionTitle>调研进度</DetailSectionTitle>
|
||||
<div className="grid grid-cols-3 border-y border-[#e2e2e2] py-4 text-center">
|
||||
<WorkflowCount label="通知" value={workflow.notifiedCount || 0} total={workflow.assignmentCount || 0} />
|
||||
<WorkflowCount label="已查看" value={workflow.readCount || 0} total={workflow.assignmentCount || 0} />
|
||||
<WorkflowCount label="已评分" value={workflow.submittedCount || responseCount || 0} total={workflow.assignmentCount || 0} />
|
||||
</div>
|
||||
<EvaluatorWorkflowList items={detail.data.evaluators || []} />
|
||||
</section>
|
||||
|
||||
{isVisualDetail && (
|
||||
@ -1292,15 +1336,6 @@ function MonthlyScoreDetailModal({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="sticky bottom-0 border-t border-[#e2e2e2] bg-white px-4 pb-[calc(12px+env(safe-area-inset-bottom))] pt-3 sm:px-5 sm:pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-11 w-full rounded-lg border border-[#c2c6d8] text-[13px] font-semibold text-[#1a1c1c] transition-colors hover:bg-[#eeeeee]"
|
||||
>
|
||||
关闭详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body)
|
||||
@ -1323,28 +1358,77 @@ function DetailSectionTitle({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DetailScoreCard({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
strong = false,
|
||||
}: {
|
||||
label: string
|
||||
value: ReactNode
|
||||
suffix?: string
|
||||
strong?: boolean
|
||||
}) {
|
||||
function WorkflowCount({ label, value, total }: { label: string; value: number; total: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[#e2e2e2] bg-[#f9f9f9] p-4">
|
||||
<div className="mb-2 block text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`${strong ? 'text-[32px] leading-10' : 'text-[24px] leading-8'} font-bold text-[#1a1c1c]`}>{value}</span>
|
||||
{suffix && <span className="text-[12px] font-medium text-[#5f5e5e]">{suffix}</span>}
|
||||
<div className="border-r border-[#e2e2e2] last:border-r-0">
|
||||
<div className="text-[12px] font-semibold text-[#5f5e5e]">{label}</div>
|
||||
<div className="mt-1 text-[20px] font-bold text-[#1a1c1c]">
|
||||
{value}<span className="ml-1 text-[12px] font-medium text-[#727687]">/ {total}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowState({ label, tone = 'muted' }: { label: string; tone?: 'blue' | 'green' | 'red' | 'muted' }) {
|
||||
const toneClass = tone === 'green'
|
||||
? 'bg-[#149447]'
|
||||
: tone === 'blue'
|
||||
? 'bg-[#0050cb]'
|
||||
: tone === 'red'
|
||||
? 'bg-[#ba1a1a]'
|
||||
: 'bg-[#8a8a8a]'
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap text-[11px] font-semibold text-[#5f5e5e]">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${toneClass}`} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EvaluatorWorkflowList({ items }: { items: any[] }) {
|
||||
if (!items.length) {
|
||||
return <div className="border border-dashed border-[#c2c6d8] px-4 py-6 text-center text-sm font-semibold text-[#5f5e5e]">暂无评价人</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-[#eeeeee] border-y border-[#eeeeee]">
|
||||
{items.map((item) => {
|
||||
const noticeStatus = String(item.notification?.status || '')
|
||||
const readStatus = String(item.notification?.readStatus || 'unknown')
|
||||
const notice = noticeStatus === 'succeeded'
|
||||
? { label: '已发送', tone: 'blue' as const }
|
||||
: noticeStatus === 'processing' || noticeStatus === 'pending' || noticeStatus === 'retrying'
|
||||
? { label: '发送中', tone: 'muted' as const }
|
||||
: noticeStatus === 'skipped' || noticeStatus === 'exhausted'
|
||||
? { label: '发送失败', tone: 'red' as const }
|
||||
: { label: '未发起', tone: 'muted' as const }
|
||||
const read = readStatus === 'read'
|
||||
? { label: '已查看', tone: 'green' as const }
|
||||
: readStatus === 'unread'
|
||||
? { label: '未查看', tone: 'muted' as const }
|
||||
: { label: '未确认', tone: 'muted' as const }
|
||||
const submitted = item.assignmentStatus === 'submitted'
|
||||
? { label: '已评分', tone: 'green' as const }
|
||||
: { label: '待评分', tone: 'muted' as const }
|
||||
|
||||
return (
|
||||
<div key={item.id} className="grid gap-3 py-3 sm:grid-cols-[minmax(120px,1fr)_repeat(3,auto)] sm:items-center sm:gap-6">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-bold text-[#1a1c1c]">{item.name}</div>
|
||||
{item.submittedAt && <div className="mt-1 text-[11px] text-[#727687]">{formatDate(item.submittedAt)}</div>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:contents">
|
||||
<WorkflowState {...notice} />
|
||||
<WorkflowState {...read} />
|
||||
<WorkflowState {...submitted} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreParticipantList({ items }: { items: any[] }) {
|
||||
if (!items.length) {
|
||||
return <div className="rounded-lg border border-[#eeeeee] bg-[#f9f9f9] px-4 py-4 text-sm font-semibold text-[#5f5e5e]">暂无评分记录</div>
|
||||
@ -2117,7 +2201,8 @@ function CreateSurveyForm({
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [templateCode, setTemplateCode] = useState('')
|
||||
const [dueAt, setDueAt] = useState(getCurrentMonthValue())
|
||||
const [assessmentMonth, setAssessmentMonth] = useState(getCurrentMonthValue())
|
||||
const [deadlineAt, setDeadlineAt] = useState('')
|
||||
const openNow = true
|
||||
const [targetEmployees, setTargetEmployees] = useState<Employee[]>([])
|
||||
const [manualEvaluatorEmployees, setManualEvaluatorEmployees] = useState<Employee[]>([])
|
||||
@ -2200,10 +2285,10 @@ function CreateSurveyForm({
|
||||
}
|
||||
}
|
||||
}
|
||||
const assessmentMonth = dueAt || getCurrentMonthValue()
|
||||
const selectedAssessmentMonth = assessmentMonth || getCurrentMonthValue()
|
||||
const submitTitle = title.trim()
|
||||
|| targetItemsToSubmit[0]?.name
|
||||
|| `${selectedTemplateInfo?.shortName || selectedTemplateInfo?.name || '考核'}-${assessmentMonth}`
|
||||
|| `${formatMonthLabel(selectedAssessmentMonth)} · ${getTemplateDisplayName(selectedTemplateInfo)}`
|
||||
const created = await apiRequest<any>('/surveys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@ -2211,7 +2296,8 @@ function CreateSurveyForm({
|
||||
description,
|
||||
surveyKind,
|
||||
templateCode: selectedTemplate,
|
||||
dueAt: assessmentMonth ? `${assessmentMonth}-01T00:00:00` : null,
|
||||
assessmentMonth: selectedAssessmentMonth,
|
||||
dueAt: deadlineAt ? new Date(deadlineAt).toISOString() : null,
|
||||
openNow,
|
||||
targetEmployeeIds: effectiveTargetEmployees.map((employee) => employee.id),
|
||||
targetItems: targetItemsToSubmit,
|
||||
@ -2243,7 +2329,8 @@ function CreateSurveyForm({
|
||||
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setDueAt(getCurrentMonthValue())
|
||||
setAssessmentMonth(getCurrentMonthValue())
|
||||
setDeadlineAt('')
|
||||
setTargetEmployees([])
|
||||
setManualEvaluatorEmployees([])
|
||||
setEvaluatorDepartmentValues([])
|
||||
@ -2274,7 +2361,8 @@ function CreateSurveyForm({
|
||||
<div className="space-y-6">
|
||||
<div className="border border-[#eeeeee] bg-white p-6">
|
||||
<div className="space-y-6">
|
||||
<TextInput label="考核月份" value={dueAt} onChange={setDueAt} type="month" />
|
||||
<TextInput label="考核月份" value={assessmentMonth} onChange={setAssessmentMonth} type="month" />
|
||||
<TextInput label="截止时间(可选)" value={deadlineAt} onChange={setDeadlineAt} type="datetime-local" />
|
||||
<label className="grid gap-2 text-sm">
|
||||
<span className="text-[12px] font-semibold tracking-[0.05em] text-[#5f5e5e]">考评模板</span>
|
||||
<select
|
||||
|
||||
@ -120,4 +120,39 @@ describe('dingtalk client', () => {
|
||||
expect(body.msg.action_card.single_url).toContain('app_id=0_4279191869')
|
||||
expect(body.msg.action_card.single_url).toContain(encodeURIComponent('https://sc.jiansun.vip/dingtalk-entry?redirect=%2Fperformance-survey'))
|
||||
})
|
||||
|
||||
test('工作通知发送结果会区分已读和未读人员', async () => {
|
||||
process.env.DINGTALK_CLIENT_ID = 'login-client-id'
|
||||
process.env.DINGTALK_CLIENT_SECRET = 'login-client-secret'
|
||||
process.env.DINGTALK_WORK_NOTICE_AGENT_ID = '4279191869'
|
||||
|
||||
;(global.fetch as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ errcode: 0, access_token: 'notice-token', expires_in: 7200 }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
errcode: 0,
|
||||
send_result: {
|
||||
read_user_id_list: ['ding-read'],
|
||||
unread_user_id_list: ['ding-unread'],
|
||||
failed_user_id_list: ['ding-failed'],
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const { getDingtalkWorkNoticeSendResult } = await import('@/lib/dingtalk-client')
|
||||
const result = await getDingtalkWorkNoticeSendResult('task-1')
|
||||
|
||||
expect(result.readUserIds).toEqual(['ding-read'])
|
||||
expect(result.unreadUserIds).toEqual(['ding-unread'])
|
||||
expect(result.failedUserIds).toEqual(['ding-failed'])
|
||||
const [, options] = (global.fetch as jest.Mock).mock.calls[1]
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
agent_id: 4279191869,
|
||||
task_id: 'task-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,10 +4,11 @@ import {
|
||||
processPerformanceSurveyNotification,
|
||||
queueSurveyTargetConfirmationNotifications,
|
||||
recoverDuePerformanceSurveyNotifications,
|
||||
refreshPerformanceSurveyNotificationReadStatuses,
|
||||
retryPerformanceSurveyNotification,
|
||||
} from '@/lib/performance-survey-notifications'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sendDingtalkWorkNotice } from '@/lib/dingtalk-client'
|
||||
import { getDingtalkWorkNoticeSendResult, sendDingtalkWorkNotice } from '@/lib/dingtalk-client'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
@ -33,6 +34,7 @@ jest.mock('@/lib/prisma', () => ({
|
||||
|
||||
jest.mock('@/lib/dingtalk-client', () => ({
|
||||
sendDingtalkWorkNotice: jest.fn(),
|
||||
getDingtalkWorkNoticeSendResult: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/public-app-url', () => ({
|
||||
@ -70,6 +72,14 @@ describe('performance survey notifications', () => {
|
||||
taskId: 'task-1',
|
||||
raw: { errcode: 0, task_id: 'task-1' },
|
||||
})
|
||||
;(getDingtalkWorkNoticeSendResult as jest.Mock).mockResolvedValue({
|
||||
readUserIds: [],
|
||||
unreadUserIds: [],
|
||||
failedUserIds: [],
|
||||
invalidUserIds: [],
|
||||
forbiddenUserIds: [],
|
||||
raw: { errcode: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -264,4 +274,32 @@ describe('performance survey notifications', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
test('通知发送结果会把收件人更新为已查看', async () => {
|
||||
;(prisma.performanceSurveyNotification.findMany as jest.Mock).mockResolvedValue([{
|
||||
id: 'notification-1',
|
||||
providerMessageId: 'task-1',
|
||||
dingtalkUserId: 'ding-user-1',
|
||||
}])
|
||||
;(getDingtalkWorkNoticeSendResult as jest.Mock).mockResolvedValue({
|
||||
readUserIds: ['ding-user-1'],
|
||||
unreadUserIds: [],
|
||||
failedUserIds: [],
|
||||
invalidUserIds: [],
|
||||
forbiddenUserIds: [],
|
||||
raw: { errcode: 0 },
|
||||
})
|
||||
|
||||
const count = await refreshPerformanceSurveyNotificationReadStatuses()
|
||||
|
||||
expect(count).toBe(1)
|
||||
expect(prisma.performanceSurveyNotification.update).toHaveBeenCalledWith({
|
||||
where: { id: 'notification-1' },
|
||||
data: expect.objectContaining({
|
||||
readStatus: 'read',
|
||||
readAt: expect.any(Date),
|
||||
readStatusCheckedAt: expect.any(Date),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
49
lib/__tests__/performance-survey-period.test.ts
Normal file
49
lib/__tests__/performance-survey-period.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
PerformanceSurveyApiError,
|
||||
resolvePerformanceSurveyPeriodInput,
|
||||
} from '@/lib/performance-survey-service'
|
||||
|
||||
describe('performance survey period', () => {
|
||||
const now = new Date('2026-07-10T08:00:00.000Z')
|
||||
|
||||
test('考核月份不会再自动变成截止时间', () => {
|
||||
const result = resolvePerformanceSurveyPeriodInput({
|
||||
assessmentMonth: '2026-03',
|
||||
dueAt: null,
|
||||
openNow: true,
|
||||
}, now)
|
||||
|
||||
expect(result).toEqual({
|
||||
assessmentMonth: '2026-03',
|
||||
dueAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('独立设置的未来截止时间会被保留', () => {
|
||||
const result = resolvePerformanceSurveyPeriodInput({
|
||||
assessmentMonth: '2026-07',
|
||||
dueAt: '2026-07-20T10:00:00.000Z',
|
||||
openNow: true,
|
||||
}, now)
|
||||
|
||||
expect(result.assessmentMonth).toBe('2026-07')
|
||||
expect(result.dueAt?.toISOString()).toBe('2026-07-20T10:00:00.000Z')
|
||||
})
|
||||
|
||||
test('发布时不允许使用已经过去的截止时间', () => {
|
||||
expect(() => resolvePerformanceSurveyPeriodInput({
|
||||
assessmentMonth: '2026-07',
|
||||
dueAt: '2026-07-01T00:00:00.000Z',
|
||||
openNow: true,
|
||||
}, now)).toThrow(new PerformanceSurveyApiError('截止时间必须晚于当前时间'))
|
||||
})
|
||||
|
||||
test('显式传入无效月份时不会静默改成当前月', () => {
|
||||
expect(() => resolvePerformanceSurveyPeriodInput({
|
||||
assessmentMonth: '2026-13',
|
||||
dueAt: null,
|
||||
}, now)).toThrow(new PerformanceSurveyApiError('考核月份格式无效'))
|
||||
})
|
||||
})
|
||||
@ -13,6 +13,15 @@ type DingtalkWorkNoticeResult = {
|
||||
raw?: unknown
|
||||
}
|
||||
|
||||
export type DingtalkWorkNoticeSendResult = {
|
||||
readUserIds: string[]
|
||||
unreadUserIds: string[]
|
||||
failedUserIds: string[]
|
||||
invalidUserIds: string[]
|
||||
forbiddenUserIds: string[]
|
||||
raw: unknown
|
||||
}
|
||||
|
||||
type DingtalkAppConfig = {
|
||||
appKey: string
|
||||
appSecret: string
|
||||
@ -264,3 +273,49 @@ export async function sendDingtalkWorkNotice(
|
||||
raw: data,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDingtalkWorkNoticeSendResult(
|
||||
taskId: string,
|
||||
): Promise<DingtalkWorkNoticeSendResult> {
|
||||
const agentId = Number(getDingtalkWorkNoticeAgentId())
|
||||
if (!Number.isFinite(agentId) || agentId <= 0) {
|
||||
throw new Error('钉钉工作通知 DINGTALK_WORK_NOTICE_AGENT_ID 必须是正数')
|
||||
}
|
||||
|
||||
const normalizedTaskId = String(taskId || '').trim()
|
||||
if (!normalizedTaskId) throw new Error('缺少钉钉工作通知 taskId')
|
||||
|
||||
const token = await getDingtalkWorkNoticeAccessToken()
|
||||
const response = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/message/corpconversation/getsendresult?access_token=${encodeURIComponent(token)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agent_id: agentId,
|
||||
task_id: normalizedTaskId,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const data = await response.json().catch(async () => ({ raw: await response.text() }))
|
||||
if (!response.ok) {
|
||||
throw new Error(`查询钉钉工作通知发送结果失败: HTTP ${response.status}`)
|
||||
}
|
||||
if (data.errcode !== 0) {
|
||||
throw new Error(`查询钉钉工作通知发送结果失败: ${data.errmsg || '未知错误'}`)
|
||||
}
|
||||
|
||||
const result = data.send_result && typeof data.send_result === 'object' ? data.send_result : {}
|
||||
const stringList = (value: unknown) => Array.isArray(value)
|
||||
? value.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
return {
|
||||
readUserIds: stringList(result.read_user_id_list),
|
||||
unreadUserIds: stringList(result.unread_user_id_list),
|
||||
failedUserIds: stringList(result.failed_user_id_list),
|
||||
invalidUserIds: stringList(result.invalid_user_id_list),
|
||||
forbiddenUserIds: stringList(result.forbidden_user_id_list),
|
||||
raw: data,
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@ import { randomUUID } from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||||
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
|
||||
import { sendDingtalkWorkNotice } from '@/lib/dingtalk-client'
|
||||
import {
|
||||
getDingtalkWorkNoticeSendResult,
|
||||
sendDingtalkWorkNotice,
|
||||
} from '@/lib/dingtalk-client'
|
||||
|
||||
const STATUS = {
|
||||
PENDING: 'pending',
|
||||
@ -18,11 +21,13 @@ const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 6
|
||||
const RECOVERY_INTERVAL_MS = 60_000
|
||||
const REMINDER_INTERVAL_MS = 10 * 60_000
|
||||
const STALE_PROCESSING_MS = 5 * 60_000
|
||||
const READ_STATUS_REFRESH_MS = 5 * 60_000
|
||||
|
||||
let workerStarted = false
|
||||
let recoveryTimer: NodeJS.Timeout | null = null
|
||||
let reminderTimer: NodeJS.Timeout | null = null
|
||||
const deliveryTimers = new Map<string, NodeJS.Timeout>()
|
||||
let readStatusRefreshInFlight: Promise<number> | null = null
|
||||
|
||||
type QueueNotificationInput = {
|
||||
surveyId?: string | null
|
||||
@ -98,11 +103,13 @@ export function ensurePerformanceSurveyNotificationWorkerStarted() {
|
||||
const bootstrap = setTimeout(() => {
|
||||
void recoverDuePerformanceSurveyNotifications()
|
||||
void queueDuePerformanceSurveyReminders()
|
||||
void refreshPerformanceSurveyNotificationReadStatuses()
|
||||
}, 1000)
|
||||
bootstrap.unref?.()
|
||||
|
||||
recoveryTimer = setInterval(() => {
|
||||
void recoverDuePerformanceSurveyNotifications()
|
||||
void refreshPerformanceSurveyNotificationReadStatuses()
|
||||
}, RECOVERY_INTERVAL_MS)
|
||||
recoveryTimer.unref?.()
|
||||
|
||||
@ -251,8 +258,8 @@ export async function queueSurveyTargetConfirmationNotifications(surveyId: strin
|
||||
businessKey: `target-confirmation:${survey.id}:${target.id}`,
|
||||
title: isVisualSurvey ? '请确认并提交视觉评分作品' : '请确认一项代发起调研',
|
||||
content: isVisualSurvey
|
||||
? `${survey.creatorName} 为你发起了一项视觉评分调研,请确认被考核人信息并提交 ${requiredWorkItemCount} 个案例作品。\n\n调研:${survey.title}\n被考核人:${target.targetName}${survey.dueAt ? `\n考核月份:${formatMonth(survey.dueAt)}` : ''}\n\n如信息有误,请联系发起人。`
|
||||
: `${survey.creatorName} 为你发起了一项调研,请确认被考核人信息。\n\n调研:${survey.title}\n被考核人:${target.targetName}${survey.dueAt ? `\n考核月份:${formatMonth(survey.dueAt)}` : ''}\n\n如信息有误,请联系发起人。`,
|
||||
? `${survey.creatorName} 为你发起了一项视觉评分调研,请确认被考核人信息并提交 ${requiredWorkItemCount} 个案例作品。\n\n调研:${survey.title}\n被考核人:${target.targetName}\n考核月份:${formatAssessmentMonth(survey.assessmentMonth, survey.createdAt)}\n\n如信息有误,请联系发起人。`
|
||||
: `${survey.creatorName} 为你发起了一项调研,请确认被考核人信息。\n\n调研:${survey.title}\n被考核人:${target.targetName}\n考核月份:${formatAssessmentMonth(survey.assessmentMonth, survey.createdAt)}\n\n如信息有误,请联系发起人。`,
|
||||
actionUrl: getSurveyUrl(
|
||||
isVisualSurvey
|
||||
? `/performance-survey/survey/${survey.id}?targetId=${target.id}`
|
||||
@ -419,6 +426,9 @@ export async function retryPerformanceSurveyNotification(notificationId: string)
|
||||
lastResponseBody: null,
|
||||
lastError: null,
|
||||
providerMessageId: null,
|
||||
readStatus: 'unknown',
|
||||
readAt: null,
|
||||
readStatusCheckedAt: null,
|
||||
sentAt: null,
|
||||
},
|
||||
})
|
||||
@ -495,6 +505,9 @@ export async function processPerformanceSurveyNotification(notificationId: strin
|
||||
status: STATUS.SUCCEEDED,
|
||||
attemptCount: { increment: 1 },
|
||||
providerMessageId: result.taskId || result.requestId || null,
|
||||
readStatus: 'unknown',
|
||||
readAt: null,
|
||||
readStatusCheckedAt: null,
|
||||
lastStatusCode: 200,
|
||||
lastResponseBody: truncateBody(JSON.stringify(result.raw || result)),
|
||||
lastError: null,
|
||||
@ -530,6 +543,73 @@ export async function processPerformanceSurveyNotification(notificationId: strin
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshPerformanceSurveyNotificationReadStatuses(limit = 20) {
|
||||
if (readStatusRefreshInFlight) return readStatusRefreshInFlight
|
||||
readStatusRefreshInFlight = refreshPerformanceSurveyNotificationReadStatusesInternal(limit)
|
||||
.finally(() => {
|
||||
readStatusRefreshInFlight = null
|
||||
})
|
||||
return readStatusRefreshInFlight
|
||||
}
|
||||
|
||||
async function refreshPerformanceSurveyNotificationReadStatusesInternal(limit = 20) {
|
||||
const staleBefore = new Date(Date.now() - READ_STATUS_REFRESH_MS)
|
||||
const notifications = await prisma.performanceSurveyNotification.findMany({
|
||||
where: {
|
||||
status: STATUS.SUCCEEDED,
|
||||
providerMessageId: { not: null },
|
||||
readStatus: { in: ['unknown', 'unread'] },
|
||||
OR: [
|
||||
{ readStatusCheckedAt: null },
|
||||
{ readStatusCheckedAt: { lte: staleBefore } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
providerMessageId: true,
|
||||
dingtalkUserId: true,
|
||||
},
|
||||
orderBy: { sentAt: 'asc' },
|
||||
take: Math.max(1, Math.min(limit, 100)),
|
||||
})
|
||||
|
||||
let refreshed = 0
|
||||
for (const notification of notifications) {
|
||||
const taskId = clean(notification.providerMessageId)
|
||||
const recipientDingtalkUserId = clean(notification.dingtalkUserId)
|
||||
if (!taskId || !recipientDingtalkUserId) continue
|
||||
|
||||
try {
|
||||
const result = await getDingtalkWorkNoticeSendResult(taskId)
|
||||
const read = result.readUserIds.includes(recipientDingtalkUserId)
|
||||
const unread = result.unreadUserIds.includes(recipientDingtalkUserId)
|
||||
const deliveryFailed = [
|
||||
...result.failedUserIds,
|
||||
...result.invalidUserIds,
|
||||
...result.forbiddenUserIds,
|
||||
].includes(recipientDingtalkUserId)
|
||||
const checkedAt = new Date()
|
||||
|
||||
await prisma.performanceSurveyNotification.update({
|
||||
where: { id: notification.id },
|
||||
data: {
|
||||
readStatus: read ? 'read' : unread ? 'unread' : deliveryFailed ? 'delivery_failed' : 'unknown',
|
||||
readAt: read ? checkedAt : null,
|
||||
readStatusCheckedAt: checkedAt,
|
||||
},
|
||||
})
|
||||
refreshed += 1
|
||||
} catch {
|
||||
await prisma.performanceSurveyNotification.update({
|
||||
where: { id: notification.id },
|
||||
data: { readStatusCheckedAt: new Date() },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return refreshed
|
||||
}
|
||||
|
||||
function formatDateTime(value: Date | string | null | undefined) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
@ -537,8 +617,12 @@ function formatDateTime(value: Date | string | null | undefined) {
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function formatMonth(value: Date | string) {
|
||||
const date = new Date(value)
|
||||
function formatAssessmentMonth(value: string | null | undefined, fallback: Date | string) {
|
||||
const normalized = String(value || '').trim()
|
||||
const match = normalized.match(/^(\d{4})-(\d{2})$/)
|
||||
if (match) return `${match[1]}年${Number(match[2])}月`
|
||||
|
||||
const date = new Date(fallback)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月`
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
queueSurveyResultNotifications,
|
||||
queueSurveyTargetConfirmationNotifications,
|
||||
recoverDuePerformanceSurveyNotifications,
|
||||
refreshPerformanceSurveyNotificationReadStatuses,
|
||||
retryPerformanceSurveyNotification,
|
||||
} from '@/lib/performance-survey-notifications'
|
||||
import {
|
||||
@ -155,6 +156,16 @@ function isMonthlyAssessmentTemplateCode(code?: string | null): code is MonthlyA
|
||||
return Boolean(code && MONTHLY_ASSESSMENT_TEMPLATE_SET.has(code))
|
||||
}
|
||||
|
||||
function normalizeAssessmentMonth(value: unknown) {
|
||||
const normalized = clean(value)
|
||||
return normalized && /^\d{4}-(0[1-9]|1[0-2])$/.test(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
function formatAssessmentMonthTitle(month: string, templateName: string) {
|
||||
const [year, monthValue] = month.split('-')
|
||||
return `${year}年${Number(monthValue)}月 · ${templateName}`
|
||||
}
|
||||
|
||||
function encodeDepartmentPart(value: string) {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
@ -281,7 +292,7 @@ function toDate(value: unknown) {
|
||||
}
|
||||
|
||||
function getMonthRange(value: unknown) {
|
||||
const normalized = clean(value)
|
||||
const normalized = normalizeAssessmentMonth(value)
|
||||
const match = normalized?.match(/^(\d{4})-(\d{2})$/)
|
||||
if (!match) return null
|
||||
|
||||
@ -291,7 +302,7 @@ function getMonthRange(value: unknown) {
|
||||
|
||||
const start = new Date(year, month - 1, 1, 0, 0, 0, 0)
|
||||
const end = new Date(year, month, 1, 0, 0, 0, 0)
|
||||
return { start, end }
|
||||
return { start, end, value: normalized }
|
||||
}
|
||||
|
||||
function getCurrentMonthRange() {
|
||||
@ -301,6 +312,29 @@ function getCurrentMonthRange() {
|
||||
return { start, end, value: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` }
|
||||
}
|
||||
|
||||
export function resolvePerformanceSurveyPeriodInput(
|
||||
input: { assessmentMonth?: unknown; dueAt?: unknown; openNow?: boolean },
|
||||
now = new Date(),
|
||||
) {
|
||||
const requestedAssessmentMonth = clean(input.assessmentMonth)
|
||||
const assessmentMonth = normalizeAssessmentMonth(requestedAssessmentMonth)
|
||||
if (requestedAssessmentMonth && !assessmentMonth) {
|
||||
throw new PerformanceSurveyApiError('考核月份格式无效')
|
||||
}
|
||||
|
||||
const requestedDueAt = clean(input.dueAt)
|
||||
const dueAt = toDate(input.dueAt)
|
||||
if (requestedDueAt && !dueAt) throw new PerformanceSurveyApiError('截止时间格式无效')
|
||||
if (input.openNow && dueAt && dueAt.getTime() <= now.getTime()) {
|
||||
throw new PerformanceSurveyApiError('截止时间必须晚于当前时间')
|
||||
}
|
||||
|
||||
return {
|
||||
assessmentMonth: assessmentMonth || getMonthRangeFromDate(now).value,
|
||||
dueAt,
|
||||
}
|
||||
}
|
||||
|
||||
function getMonthRangeFromDate(value: Date) {
|
||||
const start = new Date(value.getFullYear(), value.getMonth(), 1, 0, 0, 0, 0)
|
||||
const end = new Date(value.getFullYear(), value.getMonth() + 1, 1, 0, 0, 0, 0)
|
||||
@ -322,20 +356,36 @@ function getRecentMonthValues(count = 12) {
|
||||
}
|
||||
|
||||
function getMonthSpanRange(months: string[]) {
|
||||
const ranges = months.map((month) => getMonthRange(month)).filter((range): range is { start: Date; end: Date } => Boolean(range))
|
||||
const ranges = months.flatMap((month) => {
|
||||
const range = getMonthRange(month)
|
||||
return range ? [range] : []
|
||||
})
|
||||
const start = new Date(Math.min(...ranges.map((range) => range.start.getTime())))
|
||||
const end = new Date(Math.max(...ranges.map((range) => range.end.getTime())))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function getSurveyMonthWhere(monthRange: { start: Date; end: Date }) {
|
||||
const startMonth = getMonthValueFromDate(monthRange.start)
|
||||
const inclusiveEnd = new Date(monthRange.end.getTime() - 1)
|
||||
const endMonth = getMonthValueFromDate(inclusiveEnd)
|
||||
return {
|
||||
OR: [
|
||||
{ dueAt: { gte: monthRange.start, lt: monthRange.end } },
|
||||
{ assessmentMonth: { gte: startMonth, lte: endMonth } },
|
||||
{
|
||||
AND: [
|
||||
{ dueAt: null },
|
||||
{ createdAt: { gte: monthRange.start, lt: monthRange.end } },
|
||||
{ assessmentMonth: null },
|
||||
{
|
||||
OR: [
|
||||
{ dueAt: { gte: monthRange.start, lt: monthRange.end } },
|
||||
{
|
||||
AND: [
|
||||
{ dueAt: null },
|
||||
{ createdAt: { gte: monthRange.start, lt: monthRange.end } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -520,17 +570,7 @@ async function getManagerSurveyFilterWhere(ctx: UserContext, params?: URLSearchP
|
||||
}
|
||||
|
||||
if (monthRange) {
|
||||
and.push({
|
||||
OR: [
|
||||
{ dueAt: { gte: monthRange.start, lt: monthRange.end } },
|
||||
{
|
||||
AND: [
|
||||
{ dueAt: null },
|
||||
{ createdAt: { gte: monthRange.start, lt: monthRange.end } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
and.push(getSurveyMonthWhere(monthRange))
|
||||
}
|
||||
|
||||
return and.length === 1 ? and[0] : { AND: and }
|
||||
@ -1018,6 +1058,7 @@ export async function listPerformanceSurveys(session: Session | null, params?: U
|
||||
templateCode: survey.templateCode,
|
||||
status: survey.status,
|
||||
departmentName: survey.departmentName,
|
||||
assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt),
|
||||
dueAt: survey.dueAt,
|
||||
openedAt: survey.openedAt,
|
||||
closedAt: survey.closedAt,
|
||||
@ -1032,6 +1073,7 @@ export async function listPerformanceSurveys(session: Session | null, params?: U
|
||||
templateCode: survey.templateCode,
|
||||
status: survey.status,
|
||||
departmentName: survey.departmentName,
|
||||
assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt),
|
||||
dueAt: survey.dueAt,
|
||||
openedAt: survey.openedAt,
|
||||
closedAt: survey.closedAt,
|
||||
@ -1090,7 +1132,8 @@ function getMonthlyTemplates(templates: PerformanceSurveyTemplateShape[]) {
|
||||
}
|
||||
|
||||
function getDashboardTargetMonth(target: any) {
|
||||
return getMonthValueFromDate(target?.survey?.dueAt || target?.survey?.createdAt || target?.createdAt)
|
||||
return normalizeAssessmentMonth(target?.survey?.assessmentMonth)
|
||||
|| getMonthValueFromDate(target?.survey?.dueAt || target?.survey?.createdAt || target?.createdAt)
|
||||
}
|
||||
|
||||
function getTargetResponses(targets: any[]) {
|
||||
@ -1105,6 +1148,12 @@ function getTargetWorkItems(targets: any[]) {
|
||||
return targets.flatMap((target) => target.workItems || [])
|
||||
}
|
||||
|
||||
function getTargetWorkItemCount(targets: any[]) {
|
||||
return targets.reduce((sum, target) => (
|
||||
sum + Number(target?._count?.workItems ?? target?.workItems?.length ?? 0)
|
||||
), 0)
|
||||
}
|
||||
|
||||
function summarizeMonthlyScoreCell(
|
||||
template: PerformanceSurveyTemplateShape,
|
||||
targets: any[],
|
||||
@ -1113,6 +1162,13 @@ function summarizeMonthlyScoreCell(
|
||||
if (targets.length === 0) {
|
||||
return {
|
||||
templateCode: template.code,
|
||||
title: null,
|
||||
titles: [],
|
||||
surveyId: null,
|
||||
targetId: null,
|
||||
status: null,
|
||||
assessmentMonth: null,
|
||||
dueAt: null,
|
||||
score: null,
|
||||
surveyCount: 0,
|
||||
responseCount: 0,
|
||||
@ -1132,16 +1188,29 @@ function summarizeMonthlyScoreCell(
|
||||
respondentName: response.respondentName,
|
||||
}))
|
||||
const aggregate = calculateAggregateScore(responseItems, template.scoreBands, template.weight, template)
|
||||
const primaryTarget = targets[0]
|
||||
const titles = Array.from(new Set(targets.map((target) => clean(target.survey?.title)).filter(Boolean)))
|
||||
const statuses = targets.map((target) => clean(target.survey?.status)).filter(Boolean)
|
||||
const status = statuses.includes('open') ? 'open' : statuses.includes('draft') ? 'draft' : statuses[0] || null
|
||||
|
||||
return {
|
||||
templateCode: template.code,
|
||||
title: titles[0] || null,
|
||||
titles,
|
||||
surveyId: primaryTarget?.surveyId || primaryTarget?.survey?.id || null,
|
||||
targetId: primaryTarget?.id || null,
|
||||
status,
|
||||
assessmentMonth: getDashboardTargetMonth(primaryTarget),
|
||||
dueAt: primaryTarget?.survey?.dueAt || null,
|
||||
openedAt: primaryTarget?.survey?.openedAt || null,
|
||||
closedAt: primaryTarget?.survey?.closedAt || null,
|
||||
score: responses.length > 0 ? aggregate.kpiScore : null,
|
||||
surveyCount: targets.length,
|
||||
responseCount: responses.length,
|
||||
assignmentCount: assignments.length,
|
||||
pendingCount: assignments.filter((assignment: any) => assignment.status !== 'submitted').length,
|
||||
workItemCount: getTargetWorkItems(targets).length,
|
||||
detailAllowed: detailAllowed && responses.length > 0,
|
||||
workItemCount: getTargetWorkItemCount(targets),
|
||||
detailAllowed,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1206,31 +1275,59 @@ async function getMonthlyTargets(employeeIds: string[], monthRange: { start: Dat
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
include: {
|
||||
employeeProfile: true,
|
||||
survey: true,
|
||||
assignments: true,
|
||||
responses: true,
|
||||
workItems: {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: { scores: true },
|
||||
select: {
|
||||
id: true,
|
||||
surveyId: true,
|
||||
employeeProfileId: true,
|
||||
createdAt: true,
|
||||
survey: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
templateCode: true,
|
||||
status: true,
|
||||
assessmentMonth: true,
|
||||
dueAt: true,
|
||||
openedAt: true,
|
||||
closedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
submittedAt: true,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
select: {
|
||||
id: true,
|
||||
totalScore: true,
|
||||
kpiScore: true,
|
||||
createdAt: true,
|
||||
scores: true,
|
||||
respondentName: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { workItems: true } },
|
||||
},
|
||||
orderBy: [{ survey: { dueAt: 'desc' } }, { createdAt: 'desc' }],
|
||||
orderBy: [{ survey: { createdAt: 'desc' } }, { createdAt: 'desc' }],
|
||||
take: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
async function assertNoMonthlyAssessmentDuplicate(
|
||||
templateCode: string,
|
||||
assessmentDate: Date,
|
||||
assessmentMonth: string,
|
||||
targetRows: Array<{ employeeProfileId?: string | null }>,
|
||||
) {
|
||||
if (!isMonthlyAssessmentTemplateCode(templateCode)) return
|
||||
const employeeIds = Array.from(new Set(targetRows.map((target) => target.employeeProfileId).filter((id): id is string => Boolean(id))))
|
||||
if (employeeIds.length === 0) return
|
||||
|
||||
const monthRange = getMonthRangeFromDate(assessmentDate)
|
||||
const monthRange = getMonthRange(assessmentMonth)
|
||||
if (!monthRange) throw new PerformanceSurveyApiError('考核月份格式无效')
|
||||
const existingTargets = await prisma.performanceSurveyTarget.findMany({
|
||||
where: {
|
||||
targetType: 'employee',
|
||||
@ -1256,7 +1353,7 @@ async function assertNoMonthlyAssessmentDuplicate(
|
||||
))))
|
||||
|
||||
throw new PerformanceSurveyApiError(
|
||||
`${monthRange.value} 已存在该模板考核:${duplicateNames.slice(0, 8).join('、')}${duplicateNames.length > 8 ? '等' : ''}`,
|
||||
`${assessmentMonth} 已存在该模板考核:${duplicateNames.slice(0, 8).join('、')}${duplicateNames.length > 8 ? '等' : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
@ -1282,12 +1379,20 @@ export async function getPerformanceSurveyDashboard(session: Session | null, par
|
||||
? await getMonthlyTargets(publicEmployees.map((employee) => employee.id), monthRange)
|
||||
: []
|
||||
const publicDetailAllowed = Boolean(ctx.isSystemAdmin || ctx.isLeadership)
|
||||
const publicTargetsByEmployee = new Map<string, any[]>()
|
||||
for (const target of publicTargets) {
|
||||
const key = clean(target.employeeProfileId)
|
||||
if (!key) continue
|
||||
const current = publicTargetsByEmployee.get(key) || []
|
||||
current.push(target)
|
||||
publicTargetsByEmployee.set(key, current)
|
||||
}
|
||||
const publicRows = publicEmployees
|
||||
.map((employee) => buildMonthlyRow(
|
||||
employee,
|
||||
monthValue,
|
||||
templates,
|
||||
publicTargets.filter((target) => target.employeeProfileId === employee.id),
|
||||
publicTargetsByEmployee.get(employee.id) || [],
|
||||
publicDetailAllowed,
|
||||
))
|
||||
.filter((row) => row.hasData)
|
||||
@ -1398,13 +1503,50 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ survey: { dueAt: 'desc' } }, { createdAt: 'desc' }],
|
||||
orderBy: [{ survey: { createdAt: 'desc' } }, { createdAt: 'desc' }],
|
||||
take: 200,
|
||||
})
|
||||
const surveyIds = Array.from(new Set(targets.map((target) => target.surveyId)))
|
||||
const notifications = surveyIds.length > 0
|
||||
? await prisma.performanceSurveyNotification.findMany({
|
||||
where: {
|
||||
surveyId: { in: surveyIds },
|
||||
assignmentId: { not: null },
|
||||
eventType: 'survey.assignment.created',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
surveyId: true,
|
||||
targetId: true,
|
||||
assignmentId: true,
|
||||
recipientName: true,
|
||||
eventType: true,
|
||||
status: true,
|
||||
readStatus: true,
|
||||
readAt: true,
|
||||
readStatusCheckedAt: true,
|
||||
sentAt: true,
|
||||
completedAt: true,
|
||||
lastError: true,
|
||||
providerMessageId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: []
|
||||
const notificationByAssignment = new Map<string, (typeof notifications)[number]>()
|
||||
for (const notification of notifications) {
|
||||
if (notification.assignmentId && !notificationByAssignment.has(notification.assignmentId)) {
|
||||
notificationByAssignment.set(notification.assignmentId, notification)
|
||||
}
|
||||
}
|
||||
const cell = summarizeMonthlyScoreCell(template, targets, true)
|
||||
const responses = getTargetResponses(targets)
|
||||
const participants = responses.map((response: any) => ({
|
||||
id: response.id,
|
||||
surveyId: response.surveyId,
|
||||
surveyTitle: targets.find((target) => target.id === response.targetId)?.survey.title || '',
|
||||
targetId: response.targetId,
|
||||
name: response.respondentEmployee
|
||||
? getEmployeeDisplayName(response.respondentEmployee)
|
||||
: response.respondentName || '匿名评价',
|
||||
@ -1427,6 +1569,9 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
surveyId: item.surveyId,
|
||||
surveyTitle: targets.find((target) => target.id === item.targetId)?.survey.title || '',
|
||||
targetId: item.targetId,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
imageUrls: images.imageUrls,
|
||||
@ -1435,6 +1580,57 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul
|
||||
scores,
|
||||
}
|
||||
})
|
||||
const surveys = targets.map((target) => {
|
||||
const targetResponses = target.responses || []
|
||||
return {
|
||||
id: target.survey.id,
|
||||
targetId: target.id,
|
||||
title: target.survey.title,
|
||||
status: target.survey.status,
|
||||
assessmentMonth: normalizeAssessmentMonth(target.survey.assessmentMonth) || getDashboardTargetMonth(target),
|
||||
dueAt: target.survey.dueAt,
|
||||
openedAt: target.survey.openedAt,
|
||||
closedAt: target.survey.closedAt,
|
||||
createdAt: target.survey.createdAt,
|
||||
creatorName: target.survey.creatorName,
|
||||
responseCount: targetResponses.length,
|
||||
assignmentCount: target.assignments.length,
|
||||
pendingCount: target.assignments.filter((assignment) => assignment.status !== 'submitted').length,
|
||||
}
|
||||
})
|
||||
const evaluators = targets.flatMap((target) => (
|
||||
target.assignments.map((assignment) => {
|
||||
const notification = notificationByAssignment.get(assignment.id)
|
||||
const response = target.responses.find((item) => item.assignmentId === assignment.id)
|
||||
return {
|
||||
id: assignment.id,
|
||||
surveyId: target.survey.id,
|
||||
surveyTitle: target.survey.title,
|
||||
targetId: target.id,
|
||||
name: assignment.evaluatorName,
|
||||
assignmentStatus: assignment.status,
|
||||
submittedAt: assignment.submittedAt,
|
||||
score: response ? roundScore(Number(response.kpiScore ?? response.totalScore ?? 0)) : null,
|
||||
notification: notification
|
||||
? {
|
||||
id: notification.id,
|
||||
status: notification.status,
|
||||
sentAt: notification.sentAt,
|
||||
completedAt: notification.completedAt,
|
||||
lastError: notification.lastError,
|
||||
providerMessageId: notification.providerMessageId,
|
||||
readStatus: notification.readStatus,
|
||||
readAt: notification.readAt,
|
||||
readStatusCheckedAt: notification.readStatusCheckedAt,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
))
|
||||
const assignmentCount = evaluators.length
|
||||
const submittedCount = evaluators.filter((item) => item.assignmentStatus === 'submitted').length
|
||||
const notifiedCount = evaluators.filter((item) => item.notification?.status === 'succeeded').length
|
||||
const readCount = evaluators.filter((item) => item.notification?.readStatus === 'read').length
|
||||
|
||||
return {
|
||||
month: monthValue,
|
||||
@ -1449,6 +1645,16 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul
|
||||
score: cell.score,
|
||||
surveyCount: cell.surveyCount,
|
||||
responseCount: cell.responseCount,
|
||||
surveys,
|
||||
evaluators,
|
||||
workflow: {
|
||||
assignmentCount,
|
||||
notifiedCount,
|
||||
readCount,
|
||||
submittedCount,
|
||||
pendingCount: Math.max(0, assignmentCount - submittedCount),
|
||||
isClosed: surveys.length > 0 && surveys.every((survey) => survey.status === 'closed'),
|
||||
},
|
||||
participants,
|
||||
workItems,
|
||||
}
|
||||
@ -1457,15 +1663,24 @@ export async function getPerformanceSurveyDashboardDetail(session: Session | nul
|
||||
export async function createPerformanceSurvey(session: Session | null, body: any) {
|
||||
const ctx = await getPerformanceSurveyContext(session)
|
||||
|
||||
const title = clean(body?.title)
|
||||
if (!title) throw new PerformanceSurveyApiError('调研标题不能为空')
|
||||
|
||||
const templateCode = clean(body?.templateCode) || 'cross_department'
|
||||
const template = await getPerformanceSurveyTemplate(templateCode)
|
||||
const requestedSurveyKind = clean(body?.surveyKind) || template.surveyType
|
||||
const surveyKind = template.code === 'course_share' ? 'person_period' : requestedSurveyKind
|
||||
const dueAt = toDate(body?.dueAt)
|
||||
const openNow = Boolean(body?.openNow || body?.status === 'open')
|
||||
const period = resolvePerformanceSurveyPeriodInput({
|
||||
assessmentMonth: body?.assessmentMonth,
|
||||
dueAt: body?.dueAt,
|
||||
openNow,
|
||||
})
|
||||
const assessmentMonth = period.assessmentMonth
|
||||
const dueAt = period.dueAt
|
||||
const requestedTitle = clean(body?.title)
|
||||
const title = isMonthlyAssessmentTemplateCode(template.code)
|
||||
? formatAssessmentMonthTitle(assessmentMonth, template.shortName || template.name)
|
||||
: requestedTitle
|
||||
if (!title) throw new PerformanceSurveyApiError('调研标题不能为空')
|
||||
|
||||
const requestedDepartmentName = clean(body?.departmentName)
|
||||
const departmentName = requestedDepartmentName || ctx.scopeDepartment
|
||||
|
||||
@ -1502,7 +1717,7 @@ export async function createPerformanceSurvey(session: Session | null, body: any
|
||||
sortOrder: index,
|
||||
}))
|
||||
|
||||
await assertNoMonthlyAssessmentDuplicate(template.code, dueAt || new Date(), targetRows)
|
||||
await assertNoMonthlyAssessmentDuplicate(template.code, assessmentMonth, targetRows)
|
||||
|
||||
const assignmentRows: any[] = []
|
||||
for (const target of targetRows) {
|
||||
@ -1548,6 +1763,7 @@ export async function createPerformanceSurvey(session: Session | null, body: any
|
||||
creatorName: getDisplayName(ctx.user),
|
||||
creatorEmployeeId: ctx.employee?.id || null,
|
||||
departmentName,
|
||||
assessmentMonth,
|
||||
dueAt,
|
||||
openedAt: openNow ? new Date() : null,
|
||||
},
|
||||
@ -1629,6 +1845,7 @@ export async function getPerformanceSurveyDetail(session: Session | null, survey
|
||||
status: survey.status,
|
||||
creatorName: survey.creatorName,
|
||||
departmentName: survey.departmentName,
|
||||
assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt),
|
||||
dueAt: survey.dueAt,
|
||||
openedAt: survey.openedAt,
|
||||
closedAt: survey.closedAt,
|
||||
@ -1724,7 +1941,20 @@ export async function updatePerformanceSurvey(session: Session | null, surveyId:
|
||||
|
||||
if (body?.title != null) data.title = clean(body.title) || survey.title
|
||||
if (body?.description !== undefined) data.description = clean(body.description)
|
||||
if (body?.dueAt !== undefined) data.dueAt = toDate(body.dueAt)
|
||||
if (body?.assessmentMonth !== undefined) {
|
||||
const assessmentMonth = normalizeAssessmentMonth(body.assessmentMonth)
|
||||
if (!assessmentMonth) throw new PerformanceSurveyApiError('考核月份格式无效')
|
||||
data.assessmentMonth = assessmentMonth
|
||||
if (isMonthlyAssessmentTemplateCode(survey.templateCode)) {
|
||||
const template = survey.templateSnapshot as PerformanceSurveyTemplateShape
|
||||
data.title = formatAssessmentMonthTitle(assessmentMonth, template.shortName || template.name)
|
||||
}
|
||||
}
|
||||
if (body?.dueAt !== undefined) {
|
||||
const requestedDueAt = clean(body.dueAt)
|
||||
data.dueAt = toDate(body.dueAt)
|
||||
if (requestedDueAt && !data.dueAt) throw new PerformanceSurveyApiError('截止时间格式无效')
|
||||
}
|
||||
|
||||
if (nextStatus === 'open' && survey.status === 'draft') {
|
||||
data.status = 'open'
|
||||
@ -1736,6 +1966,12 @@ export async function updatePerformanceSurvey(session: Session | null, surveyId:
|
||||
throw new PerformanceSurveyApiError('无效的调研状态')
|
||||
}
|
||||
|
||||
const effectiveDueAt = body?.dueAt !== undefined ? data.dueAt : survey.dueAt
|
||||
const effectiveStatus = data.status || survey.status
|
||||
if (effectiveStatus === 'open' && effectiveDueAt && effectiveDueAt.getTime() <= Date.now()) {
|
||||
throw new PerformanceSurveyApiError('截止时间必须晚于当前时间')
|
||||
}
|
||||
|
||||
await prisma.performanceSurvey.update({
|
||||
where: { id: surveyId },
|
||||
data,
|
||||
@ -1900,6 +2136,7 @@ export async function getPerformanceSurveyResults(
|
||||
status: survey.status,
|
||||
templateCode: survey.templateCode,
|
||||
template,
|
||||
assessmentMonth: survey.assessmentMonth || getMonthValueFromDate(survey.dueAt || survey.createdAt),
|
||||
dueAt: survey.dueAt,
|
||||
closedAt: survey.closedAt,
|
||||
counts: survey._count,
|
||||
@ -2332,7 +2569,7 @@ export async function exportPerformanceSurveyCsv(session: Session | null) {
|
||||
})
|
||||
|
||||
const rows = [
|
||||
['提交时间', '调研ID', '问卷模板', '发起人', '发起人ID', '调研标题', '评价对象', '评价人', '部门', '维度得分明细', '总分', '满分', 'KPI分', '建议'],
|
||||
['提交时间', '调研ID', '考核月份', '模板代码', '问卷模板', '发起人', '发起人ID', '调研标题', '评价对象', '评价人', '部门', '维度得分明细', '总分', '满分', 'KPI分', '建议'],
|
||||
...responses.map((response) => {
|
||||
const template = response.survey.templateSnapshot as PerformanceSurveyTemplateShape
|
||||
const respondentSnapshot = response.respondentSnapshot as Record<string, unknown>
|
||||
@ -2348,6 +2585,8 @@ export async function exportPerformanceSurveyCsv(session: Session | null) {
|
||||
return [
|
||||
response.createdAt.toISOString(),
|
||||
response.surveyId,
|
||||
response.survey.assessmentMonth || getMonthValueFromDate(response.survey.dueAt || response.survey.createdAt),
|
||||
response.survey.templateCode,
|
||||
template.name || response.survey.templateCode,
|
||||
response.survey.creatorName,
|
||||
response.survey.creatorUserId,
|
||||
@ -2397,6 +2636,9 @@ export async function listPerformanceSurveyNotifications(session: Session | null
|
||||
maxAttempts: item.maxAttempts,
|
||||
nextRetryAt: item.nextRetryAt,
|
||||
lastError: item.lastError,
|
||||
readStatus: item.readStatus,
|
||||
readAt: item.readAt,
|
||||
readStatusCheckedAt: item.readStatusCheckedAt,
|
||||
sentAt: item.sentAt,
|
||||
completedAt: item.completedAt,
|
||||
createdAt: item.createdAt,
|
||||
@ -2444,7 +2686,8 @@ export async function runPerformanceSurveyNotificationMaintenance(session: Sessi
|
||||
ensureManager(ctx)
|
||||
const reminderIds = await queueDuePerformanceSurveyReminders()
|
||||
const recovered = await recoverDuePerformanceSurveyNotifications()
|
||||
return { reminderCount: reminderIds.length, recovered }
|
||||
const readStatusRefreshed = await refreshPerformanceSurveyNotificationReadStatuses()
|
||||
return { reminderCount: reminderIds.length, recovered, readStatusRefreshed }
|
||||
}
|
||||
|
||||
async function resolveSurveyTargets(body: any, surveyKind: string, currentEmployeeId?: string | null) {
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
ALTER TABLE "performance_surveys"
|
||||
ADD COLUMN IF NOT EXISTS "assessment_month" VARCHAR(7);
|
||||
|
||||
UPDATE "performance_surveys"
|
||||
SET "assessment_month" = TO_CHAR(
|
||||
COALESCE("due_at", "created_at"),
|
||||
'YYYY-MM'
|
||||
)
|
||||
WHERE "assessment_month" IS NULL;
|
||||
|
||||
-- 早期月度调研把“考核月份”误写进了截止时间。仅清理月初零点且发布时已过期的记录。
|
||||
UPDATE "performance_surveys"
|
||||
SET "due_at" = NULL
|
||||
WHERE "template_code" IN (
|
||||
'course_share',
|
||||
'cross_department',
|
||||
'work_cooperation',
|
||||
'visual_brand_tone'
|
||||
)
|
||||
AND "due_at" IS NOT NULL
|
||||
AND EXTRACT(DAY FROM "due_at") = 1
|
||||
AND "due_at"::time = TIME '00:00:00'
|
||||
AND COALESCE("opened_at", "created_at") > "due_at";
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_surveys_assessment_month_idx"
|
||||
ON "performance_surveys" ("assessment_month");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_surveys_template_month_idx"
|
||||
ON "performance_surveys" ("template_code", "assessment_month");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_targets_employee_survey_idx"
|
||||
ON "performance_survey_targets" ("employee_profile_id", "survey_id");
|
||||
|
||||
ALTER TABLE "performance_survey_notifications"
|
||||
ADD COLUMN IF NOT EXISTS "read_status" TEXT NOT NULL DEFAULT 'unknown',
|
||||
ADD COLUMN IF NOT EXISTS "read_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "read_status_checked_at" TIMESTAMP(3);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "performance_survey_notifications_read_status_checked_at_idx"
|
||||
ON "performance_survey_notifications" ("read_status", "read_status_checked_at");
|
||||
@ -574,6 +574,7 @@ model PerformanceSurvey {
|
||||
creatorName String @map("creator_name")
|
||||
creatorEmployeeId String? @map("creator_employee_id")
|
||||
departmentName String? @map("department_name")
|
||||
assessmentMonth String? @map("assessment_month")
|
||||
dueAt DateTime? @map("due_at")
|
||||
openedAt DateTime? @map("opened_at")
|
||||
closedAt DateTime? @map("closed_at")
|
||||
@ -589,8 +590,10 @@ model PerformanceSurvey {
|
||||
@@index([status])
|
||||
@@index([surveyKind])
|
||||
@@index([templateCode])
|
||||
@@index([templateCode, assessmentMonth], map: "performance_surveys_template_month_idx")
|
||||
@@index([creatorUserId])
|
||||
@@index([departmentName])
|
||||
@@index([assessmentMonth])
|
||||
@@index([dueAt])
|
||||
@@map("performance_surveys")
|
||||
}
|
||||
@ -615,6 +618,7 @@ model PerformanceSurveyTarget {
|
||||
@@index([surveyId])
|
||||
@@index([targetType])
|
||||
@@index([employeeProfileId])
|
||||
@@index([employeeProfileId, surveyId], map: "performance_survey_targets_employee_survey_idx")
|
||||
@@map("performance_survey_targets")
|
||||
}
|
||||
|
||||
@ -757,6 +761,9 @@ model PerformanceSurveyNotification {
|
||||
lastResponseBody String? @map("last_response_body")
|
||||
lastError String? @map("last_error")
|
||||
providerMessageId String? @map("provider_message_id")
|
||||
readStatus String @default("unknown") @map("read_status")
|
||||
readAt DateTime? @map("read_at")
|
||||
readStatusCheckedAt DateTime? @map("read_status_checked_at")
|
||||
sentAt DateTime? @map("sent_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@ -774,6 +781,7 @@ model PerformanceSurveyNotification {
|
||||
@@index([recipientUserId])
|
||||
@@index([dingtalkUserId])
|
||||
@@index([status, nextRetryAt])
|
||||
@@index([readStatus, readStatusCheckedAt], map: "performance_survey_notifications_read_status_checked_at_idx")
|
||||
@@index([createdAt])
|
||||
@@map("performance_survey_notifications")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user