Commit a47022ac authored by 李文光's avatar 李文光

fix(offer): 收敛 Offer 状态机并打通 Offer→待入职→已入职流程

- Offer 状态枚举统一为 待 HR 确认/审批中/审批通过/待入职/已入职,
  去掉「邮件审批中/邮件审批待发送」双轨(offer.js/recruitment.js/constants.js)
- OfferFlow 流程按钮按状态顺序门控,未满足条件禁用并给原因
- 确认已入职前置校验入职日期(startDate),缺失则阻止并提示
- 简历库抽屉 footer 随候选阶段动态渲染;Offer/待入职/已入职只留「去录用入职处理」入口
- reconcileRecruitmentLinks 阶段与 Offer 状态解耦:只做 已入职/待入职 单向推进,
  不再强置非 Offer/入职阶段为 Offer 中
- 候选人「评估」页新增 快速匹配/AI 评估 切换,有有效 AI 报告时可随时切回
- CONTEXT.md 增补 Offer 状态词条;新增 ADR 0004
parent bde02970
......@@ -13,6 +13,7 @@
- **候选人(Candidate)**:一份进入招聘流程的简历对应的人。
- **简历库(Resume Library)**:招聘专员个人工作区中的候选人集合,承载从「新入库」到「已入职」的完整流程。
- **阶段(Stage)**:候选人当前流程节点(未筛选 / 初筛通过 / 面试中 / Offer 中 / 待入职 / 已入职 等)。
- **Offer 状态(Offer Status)**:单个 Offer 在发放与入职链路中的进度(待 HR 确认 / 审批中 / 审批通过 / 待入职 / 已入职)。区别于候选阶段(Stage):阶段是候选人在整条招聘管线中的位置,Offer 状态只描述录用/入职这一子流程;二者通常保持一致,但各自演化。_Avoid_: 候选阶段(Stage)。
- **标记(Tag)**:HR 人工给候选人贴的结构化标签,AI 不参与打标;用于重点跟进、待二面、储备、已淘汰等运营与筛选。
- **期望薪资(Expected Salary)**:候选人对薪酬的期望值,自由文本(如 `20-30K` / `15k` / `面议`);来源为简历解析、平台采集或 HR 手填,旧数据可从入库备注回填。_Avoid_: 岗位薪资范围(岗位 `salaryRange`)、Offer 薪资(Offer `salary`)。
- **沟通职位(Communicated Job)**:招聘平台对话中 HR 与候选人实际沟通的岗位,区别于候选人在平台填写的「期望岗位 / 求职意向」;由采集插件上传,仅在候选人自身求职岗位为空时作为初始 `jobTitle` 兜底,不覆盖已有值。
......
# ADR 0004:Offer 流程 = 独立 Offer 状态机 + 阶段门控(录用入职子流程收敛)
- 状态:已接受(2026-09-04)
- 关联决策:AGENTS.md §4.1(整包 state)与 §4.2(工作区隔离);CONTEXT 领域词表「招聘对象·阶段(Stage)」与「Offer 状态(Offer Status)」;需求 17(风险维度)延伸。
## 背景 / Context
候选人在「Offer 中」之后流程停留在简历库抽屉无法继续:简历库抽屉(`ResumeDetail.vue`)footer 是写死的四颗按钮(快速匹配 / 生成 AI 评估报告 / 推进面试 / 移入人才池),其中的「推进面试」恒为把阶段置为「面试中」,对 Offer 中 / 待入职 / 已入职 候选人是倒退动作。同时 Offer 状态的推进只能绕道「录用入职管理」页,且该页四个流程按钮不按状态门控,用户可在任意状态乱点(未审批直接发送/确认入职)。
另有一处状态**双轨漂移**`utils/offer.js``offerWorkflow` steps 使用「邮件审批中」,而 store 的 `generateMail` 实际把状态写成「邮件审批待发送」,二者互不匹配,导致生成邮件草稿后流程步骤跳到第 0 步且 next 变成「流程完成」。
## 决策 / Decision
1. **Offer 状态收敛为单枚枚举**:待 HR 确认 → 审批中 → 审批通过 → 待入职 → 已入职;唯一数据源为 `frontend 的 utils/offer.js``OFFER_STATUS_STEPS`),删除「邮件审批中 / 邮件审批待发送」双轨;`generateMail` 只写 `mailDraft` 并把状态置为「审批中」。
2. **候选阶段与 Offer 状态是两个域概念**:候选人「阶段(Stage)」描述其在整条招聘管线中的位置;「Offer 状态(Status)」只描述录用/入职这一子流程。二者**解耦**`reconcileRecruitmentLinks` 仅对「已入职 / 待入职」两个明确里程碑做 offer→candidate 的单向推进,**不再**把存在 Offer 记录或审批通过的候选人强置回 `Offer 中`(避免覆盖 `初筛未通过 / 面试中` 等既有阶段)。候选人阶段主要随 store 的 `approveOffer / sendCandidateOffer / confirmOnboarded` 动作推进。
3. **Offer 流程按钮按状态门控**:每个状态只允许当前主按钮(生成邮件草稿 / 审批通过 / 发送 Offer / 确认已入职),未满足条件的按钮禁用并给出原因;表单「保存 Offer / 生成审批材料」始终可点,作为重新生成 / 覆盖入口。
4. **确认已入职前置校验**`confirmOnboarded` 要求 `offer.startDate` 有值,否则阻止并提示补入职日期;该校验落在 store 层,UI 同步禁用按钮。
5. **简历库抽屉 footer 动态化**:footer 依据候选人阶段渲染动作;Offer 中 / 待入职 / 已入职 阶段只提供「去录用入职处理」入口(跳转录用入职管理并定位到该候选人的 Offer 流程),不再出现「推进面试」等倒退按钮。
## 影响 / Consequences
- 正向:Offer 中 → 待入职 → 已入职 在简历库抽屉有明确入口,不再「卡住」;Offer 状态与步骤展示一致;跳步被门控阻止;入职日期缺失会在确认入职时被拦截;候选人「评估」页新增「快速匹配 / AI 评估」视图切换,有有效 AI 评估报告时可随时切回,不再只能看快速匹配。
- 代价/注意:已有历史数据中若存在「邮件审批中 / 邮件审批待发送」状态,读入后按 `steps.indexOf` 落为第 0 步(待 HR 确认),需在真实数据迁移时统一改写为新枚举;阶段与 Offer 状态解耦后,若候选人被人工退回(如 Offer 后拒绝)但 Offer 记录仍存在,系统不会再自动把阶段拉回 `Offer 中`,需 HR 在「录用入职管理」手动终止该 Offer(当前未提供取消/关闭动作,属后续补齐项)。
- 后续可做:把 Offer 状态枚举提为共享常量供后端同步;对「审批中」引入真实审批回执刷新(当前由 HR 点「审批通过」模拟);Offer 子流程若引入合同 / 背调节点再扩展状态枚举。
......@@ -2,7 +2,7 @@
import { computed, reactive } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment'
import { offerWorkflow, offerWorkflowActionButtons } from '@/utils/offer'
import { offerWorkflow, offerSalarySummary } from '@/utils/offer'
import { workflowStageClass } from '@/utils/task'
import { showToast } from '@/utils/toast'
......@@ -12,6 +12,31 @@ const offer = computed(() => store.selectedOffer || store.offers[0] || null)
const candidate = computed(() => (offer.value ? store.candidates.find((c) => c.id === offer.value.candidateId) : null))
const flow = computed(() => (offer.value ? offerWorkflow(offer.value) : { steps: [], currentIndex: 0, next: {} }))
const status = computed(() => flow.value.status || '待 HR 确认')
const blockers = computed(() => {
const o = offer.value
if (!o) return []
const list = []
if (!o.startDate) list.push('入职日期')
if (offerSalarySummary(o) === '待确认') list.push('薪资')
if (!o.workLocation) list.push('工作地点')
return list
})
const blockedReason = computed(() => (blockers.value.length ? `请先补充${blockers.value.join('、')}` : ''))
const gates = computed(() => {
const s = status.value
const canGenerateMail = s === '待 HR 确认'
return {
generateMail: { enabled: canGenerateMail, reason: canGenerateMail ? blockedReason.value : '当前阶段无需生成' },
approve: { enabled: s === '审批中', reason: s === '审批中' ? '' : '需先生成审批邮件' },
send: { enabled: s === '审批通过', reason: s === '审批通过' ? '' : '需先审批通过' },
onboard: {
enabled: s === '待入职',
reason: s === '待入职' ? (offer.value?.startDate ? '' : '请先补充入职日期') : '需先发送 Offer',
},
}
})
const materials = computed(() => offer.value?.approvalMaterials || null)
const back = () => {
......@@ -88,6 +113,10 @@ const generateMaterials = () => {
}
const generateMail = () => {
if (blockers.value.length) {
showToast(`请先补充${blockers.value.join('、')}`, 'warning')
return
}
store.generateMail(offer.value.id)
showToast('Offer 邮件草稿已生成')
}
......@@ -103,7 +132,11 @@ const sendOffer = () => {
}
const confirmOnboarded = () => {
store.confirmOnboarded(offer.value.id)
const result = store.confirmOnboarded(offer.value.id)
if (result && result.ok === false) {
showToast(result.message, 'warning')
return
}
showToast('候选人已确认入职')
}
......@@ -228,10 +261,36 @@ const copyMaterials = async () => {
<div class="card-title">流程操作</div>
</div>
<div class="actions column">
<el-button type="primary" @click="generateMail">生成邮件草稿</el-button>
<el-button type="primary" @click="approve">审批通过</el-button>
<el-button @click="sendOffer">发送 Offer 给候选人</el-button>
<el-button @click="confirmOnboarded">确认已入职</el-button>
<el-button
type="primary"
:disabled="!gates.generateMail.enabled"
:title="gates.generateMail.reason || '补齐材料后生成审批邮件'"
@click="generateMail"
>
生成邮件草稿
</el-button>
<el-button
type="primary"
:disabled="!gates.approve.enabled"
:title="gates.approve.reason || '审批通过后进入发放'"
@click="approve"
>
审批通过
</el-button>
<el-button
:disabled="!gates.send.enabled"
:title="gates.send.reason || '发送 Offer 给候选人'"
@click="sendOffer"
>
发送 Offer 给候选人
</el-button>
<el-button
:disabled="!gates.onboard.enabled"
:title="gates.onboard.reason || '确认候选人已入职'"
@click="confirmOnboarded"
>
确认已入职
</el-button>
</div>
</section>
</aside>
......
......@@ -2,8 +2,7 @@
import { computed } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment'
import { offerMetricsView, offerWorkflow } from '@/utils/offer'
import { showToast } from '@/utils/toast'
import { offerMetricsView } from '@/utils/offer'
const store = useRecruitmentStore()
......@@ -13,7 +12,7 @@ const statusTag = (offer) => {
const status = offer.status || '待 HR 确认'
if (['已入职'].includes(status)) return { text: status, type: 'success' }
if (['审批通过', '待入职'].includes(status)) return { text: status, type: 'primary' }
if (['邮件审批中', '邮件审批待发送'].includes(status)) return { text: status, type: 'warning' }
if (['审批中'].includes(status)) return { text: status, type: 'warning' }
return { text: status, type: 'info' }
}
......
<script setup>
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
......@@ -23,6 +23,7 @@ import { showToast } from '@/utils/toast'
const store = useRecruitmentStore()
const route = useRoute()
const router = useRouter()
const selected = computed(() => store.selectedCandidate || store.candidates[0] || null)
......@@ -37,6 +38,20 @@ const scoreText = computed(() => assessmentScoreText(selected.value, store.resum
const decisionInfo = computed(() => assessmentDecision(selected.value, store.resumeDetailMode))
const reportRows = computed(() => assessmentReportRows(selected.value, store.resumeDetailMode))
// 「AI 评估」是否可用:存在有效的 AI 评估报告(非过期)才允许切换,否则留在快速匹配。
const aiAvailable = computed(() => {
const c = selected.value
return Boolean(c && isAiEvaluationReport(c.analysis) && !analysisNeedsRefresh(c))
})
const modeOptions = computed(() => [
{ label: '快速匹配', value: 'quick' },
{ label: 'AI 评估', value: 'ai', disabled: !aiAvailable.value },
])
// AI 报告失效时自动回退到快速匹配,避免停留在被禁用的「AI 评估」项上。
watch(aiAvailable, (available) => {
if (!available && store.resumeDetailMode === 'ai') store.resumeDetailMode = 'quick'
})
// 需求 17:风险固定维度(跳槽频率 / 竞业协议 / 职业空窗 / 高层职业道德)
const riskProfile = computed(() => riskProfileRows(selected.value?.analysis || {}))
const riskTagMeta = (level) =>
......@@ -133,6 +148,17 @@ watch(selected, (candidate) => {
if (!candidate && store.resumeViewMode === 'detail') store.resumeViewMode = 'list'
})
// 打开候选人时:有有效 AI 评估报告则默认展示 AI 评估内容,否则回退本地快速匹配。
watch(
selected,
(candidate) => {
if (!candidate) return
const hasValidAiReport = isAiEvaluationReport(candidate.analysis) && !analysisNeedsRefresh(candidate)
store.resumeDetailMode = hasValidAiReport ? 'ai' : 'quick'
},
{ immediate: true }
)
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
revokeResumeUrl()
......@@ -509,6 +535,85 @@ const advanceStage = (candidate, nextStage) => {
showToast('候选人阶段已更新')
}
// 跳转录用入职管理并定位到该候选人的 Offer 流程。
const goOffer = (candidate) => {
if (!candidate?.id) return
let offer = store.offers.find((item) => item.candidateId === candidate.id)
if (!offer) offer = store.ensureOfferForCandidate(candidate)
if (offer) {
store.selectedOfferId = offer.id
store.focusedTarget = { type: 'offer', id: offer.id }
}
store.resumeViewMode = 'list'
router.push({ name: 'offer' })
}
// 简历库抽屉 footer 动作随候选人阶段动态变化:Offer/入职阶段只留「去录用入职处理」入口。
const footerActions = computed(() => {
const c = selected.value
if (!c) return []
const stage = c.stage || '未筛选'
const f = flow.value
const actions = []
if (['Offer 中', '待入职', '已入职'].includes(stage)) {
actions.push({ key: 'offer', label: '去录用入职处理', type: 'primary', handler: (candidate) => goOffer(candidate) })
} else if (['未筛选', '初筛通过'].includes(stage)) {
actions.push({
key: 'match',
label: '快速匹配',
type: 'default',
handler: (candidate) => runLocalMatchAction(candidate),
})
if (!f?.hasAiReport)
actions.push({
key: 'eval',
label: '生成 AI 评估报告',
type: 'primary',
loading: evaluating.value,
handler: (candidate) => generateEval(candidate),
})
actions.push({
key: 'staging',
label: '推进面试',
type: 'primary',
handler: (candidate) => advanceStage(candidate, '面试中'),
})
} else if (stage === '面试中') {
actions.push({
key: 'match',
label: '快速匹配',
type: 'default',
handler: (candidate) => runLocalMatchAction(candidate),
})
if (!f?.hasAiReport)
actions.push({
key: 'eval',
label: '生成 AI 评估报告',
type: 'primary',
loading: evaluating.value,
handler: (candidate) => generateEval(candidate),
})
actions.push({
key: 'interview',
label: f?.hasInterviewFeedback ? '确认面试结论' : '录入面试评价',
type: 'primary',
handler: () => {
store.resumeDetailSection = 'interview'
},
})
} else if (stage !== '初筛未通过' && !f?.hasAiReport) {
actions.push({
key: 'eval',
label: '生成 AI 评估报告',
type: 'primary',
loading: evaluating.value,
handler: (candidate) => generateEval(candidate),
})
}
actions.push({ key: 'pool', label: '移入人才池', type: 'default', handler: (candidate) => poolCandidate(candidate) })
return actions
})
// 移入人才池:从当前工作区迁入公司共享总库(需求 1/20),而非本地改 tag/阶段。
const poolCandidate = (candidate) => {
ElMessageBox.prompt(
......@@ -995,7 +1100,12 @@ const saveInterview = (candidate) => {
<span>当前得分</span>
<strong :style="{ color: decisionColor }">{{ scoreText }}</strong>
</div>
<el-tag size="small">{{ store.resumeDetailMode === 'ai' ? 'AI 评估' : '本地快速匹配' }}</el-tag>
<el-segmented
v-model="store.resumeDetailMode"
:options="modeOptions"
size="small"
aria-label="评估视图切换"
/>
</div>
<div class="assessment-decision" :class="`tone-${decisionInfo.tone}`">
<div>
......@@ -1133,15 +1243,28 @@ const saveInterview = (candidate) => {
<span>下一步建议:{{ flow?.next?.title || '查看候选人详情' }}</span>
</div>
<div class="footer-actions">
<el-button @click="runLocalMatchAction(selected)">快速匹配</el-button>
<el-button type="primary" :loading="evaluating" @click="generateEval(selected)">生成 AI 评估报告</el-button>
<el-button @click="advanceStage(selected, '面试中')">推进面试</el-button>
<el-button class="pool-btn" @click="poolCandidate(selected)">移入人才池</el-button>
<el-button
v-for="action in footerActions"
:key="action.key"
:type="action.type"
:loading="action.loading"
:class="{ 'pool-btn': action.key === 'pool' }"
@click="action.handler(selected)"
>
{{ action.label }}
</el-button>
</div>
</footer>
</div>
<el-dialog v-model="reportDialogOpen" :close-on-click-modal="false" title="分析报告" width="620px" append-to-body class="report-panel-dialog">
<el-dialog
v-model="reportDialogOpen"
:close-on-click-modal="false"
title="分析报告"
width="620px"
append-to-body
class="report-panel-dialog"
>
<div class="report-panel">
<div class="report-panel-head">
<div class="report-panel-identity">
......
......@@ -169,10 +169,10 @@ function reconcileRecruitmentLinks(targetState = {}) {
Object.assign(offer, createOfferRecord({ id: offer.candidateId || '' }, offer))
const candidate = candidateById.get(offer.candidateId)
if (!candidate) return
// 阶段与 Offer 状态解耦:只做「已入职 / 待入职」这两个明确里程碑的单向推进,
// 不再把存在 Offer 记录或审批通过的候选人强置回 Offer 中,避免覆盖 初筛未通过/面试中 等既有阶段。
if (offer.status === '已入职') candidate.stage = '已入职'
if (offer.status === '待入职' && candidate.stage !== '已入职') candidate.stage = '待入职'
if (offer.status === '审批通过' && !['待入职', '已入职'].includes(candidate.stage)) candidate.stage = 'Offer 中'
if (!['Offer 中', '待入职', '已入职'].includes(candidate.stage)) candidate.stage = 'Offer 中'
})
;(targetState.jobs || []).forEach((job) => {
job.hired = (targetState.candidates || []).filter(
......@@ -188,11 +188,15 @@ function reconcileRecruitmentLinks(targetState = {}) {
job.autoFilledAt = new Date().toISOString()
if (!Array.isArray(targetState.eventLog)) targetState.eventLog = []
targetState.eventLog.push(
buildRecruitmentEvent('job_filled', {}, {
buildRecruitmentEvent(
'job_filled',
{},
{
jobId: job.id,
jobTitle: job.title || '岗位',
note: '招聘目标已达成,岗位自动置为已招满',
})
}
)
)
}
})
......@@ -842,7 +846,7 @@ export const useRecruitmentStore = defineStore('recruitment', {
if (!offer) return
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId) || {}
offer.mailDraft = `尊敬的 ${candidate.name || '候选人'}:\n\n我们很高兴邀请您加入${candidate.jobTitle || '团队'}。\n\n薪资:${this.offerSalarySummary(offer)}\n入职日期:${offer.startDate || '待确认'}\n工作地点:${offer.workLocation || '待确认'}\n\n请回复确认,谢谢!`
offer.status = '邮件审批待发送'
offer.status = '审批中'
this.persist('Offer 邮件草稿已生成')
return offer.mailDraft
},
......@@ -878,7 +882,8 @@ export const useRecruitmentStore = defineStore('recruitment', {
confirmOnboarded(offerId) {
const offer = this.state.offers.find((item) => item.id === offerId)
if (!offer) return
if (!offer) return { ok: false, message: 'Offer 不存在' }
if (!offer.startDate) return { ok: false, message: '请先补充入职日期,再确认已入职' }
offer.status = '已入职'
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId)
if (candidate) {
......@@ -888,6 +893,7 @@ export const useRecruitmentStore = defineStore('recruitment', {
}
this.reconcile()
this.persist('候选人已确认入职')
return { ok: true }
},
exportBackup() {
......
......@@ -156,7 +156,7 @@ export const seedState = {
candidateId: 'cand-3',
salary: '45k/月 + 年终',
startDate: '2026-06-20',
status: '邮件审批中',
status: '审批中',
risk: '审批超时',
mailDraft: '',
},
......
......@@ -8,15 +8,21 @@ export function offerSalarySummary(offer = {}) {
return parts.join(' / ') || '待确认'
}
export const OFFER_STATUS_STEPS = ['待 HR 确认', '审批中', '审批通过', '待入职', '已入职']
export function offerWorkflow(offer = {}) {
const steps = ['待 HR 确认', '邮件审批中', '审批通过', '待入职', '已入职']
const steps = OFFER_STATUS_STEPS
const status = offer.status || '待 HR 确认'
const currentIndex = Math.max(0, steps.indexOf(status))
const next =
status === '待 HR 确认'
? { title: '生成审批材料', desc: '补齐薪资、试用期、五险一金与入职时间,生成审批邮件。', action: '生成材料' }
: status === '邮件审批中'
? { title: '等待审批结果', desc: '审批邮件已发出,等待业务或用人部门确认。', action: '刷新审批结果' }
? {
title: '生成审批邮件草稿',
desc: '补齐薪资、试用期、五险一金与入职时间,生成审批邮件草稿。',
action: '生成邮件草稿',
}
: status === '审批中'
? { title: '等待审批结果', desc: '审批邮件已生成,等待业务或用人部门确认。', action: '审批通过' }
: status === '审批通过'
? {
title: '发送 Offer 并跟进入职',
......@@ -40,7 +46,7 @@ export function offerMetricsView(offers = [], candidates = []) {
const sent = inScope.filter((offer) => ['待入职', '已入职'].includes(offer.status)).length
const onboarding = inScope.filter((offer) => offer.status === '待入职').length
const onboarded = inScope.filter((offer) => offer.status === '已入职').length
const pending = inScope.filter((offer) => offer.status === '待 HR 确认' || offer.status === '邮件审批中').length
const pending = inScope.filter((offer) => offer.status === '待 HR 确认' || offer.status === '审批中').length
return { total: inScope.length, approved, sent, onboarding, onboarded, pending }
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment