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

feat(offer): 补齐 Offer 异常分支与重开 + 审批材料单源化 + 前端单测

- Offer 状态新增 审批驳回(回路)/已拒绝/已作废(终止),流程面板以退回/终止徽标展示,不再硬塞线性五步
- 允许同一候选人重开 Offer:放开 offers.candidate_id 唯一约束,重开带 reofferOf,保留历史
- 审批材料/薪资摘要单源化到 utils/offer.js(buildOfferApprovalMaterials/offerBlockers),删除 store 重复实现与死代码
- 风险作为审批材料一行展示,默认不作流程硬门槛;驳回/作废理由写入 approvalNote 并记事件
- 修复 OfferList 打开未设 selectedOfferId 的入口 bug;修复 OfferList 仍引用已删 store.offerSalarySummary 的回归
- 新增 utils/offer.js Vitest 单测(14 用例);AGENTS/CONTEXT 更新;新增 ADR 0005
parent a47022ac
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
| 简历解析 CLI | `... backend/scripts/parse_resume.py <简历文件>` | | 简历解析 CLI | `... backend/scripts/parse_resume.py <简历文件>` |
| 用户管理 CLI | `... backend/scripts/create_user.py create/list/reset/disable/enable` | | 用户管理 CLI | `... backend/scripts/create_user.py create/list/reset/disable/enable` |
| 前端开发 | `cd vue-app && npm run dev`(http://127.0.0.1:5173,/api 代理到 4177) | | 前端开发 | `cd vue-app && npm run dev`(http://127.0.0.1:5173,/api 代理到 4177) |
| 前端构建 / 检查 | `npm run build` / `npm run lint` / `npm run format` | | 前端构建 / 检查 / 测试 | `npm run build` / `npm run lint` / `npm run format` / `npm test`(Vitest 纯逻辑单测) |
端口约定:后端 **4177**,前端 dev **5173**(Vite 已配代理)。 端口约定:后端 **4177**,前端 dev **5173**(Vite 已配代理)。
...@@ -85,7 +85,7 @@ ...@@ -85,7 +85,7 @@
- 新增测试放 `backend/tests/`,用 `client` fixture(开放模式);**改认证/权限/工作区逻辑时同步更新 `test_auth.py`**(其 `auth_client` fixture 设置了 `ADMIN_PASSWORD` 等,需模拟登录拿 token)。 - 新增测试放 `backend/tests/`,用 `client` fixture(开放模式);**改认证/权限/工作区逻辑时同步更新 `test_auth.py`**(其 `auth_client` fixture 设置了 `ADMIN_PASSWORD` 等,需模拟登录拿 token)。
- 改动访谈题单/状态机时同步更新 `test_ai.py` - 改动访谈题单/状态机时同步更新 `test_ai.py`
- `test_static_security.py` 断言 Vue `index.html` 实际引用的 JS 资源——若 `backend/static/` 为空,先 `start_server.py --with-frontend` 生成再跑。 - `test_static_security.py` 断言 Vue `index.html` 实际引用的 JS 资源——若 `backend/static/` 为空,先 `start_server.py --with-frontend` 生成再跑。
- 前端目前无自动化测试;改 `utils/` 纯逻辑时至少保持现有行为不被破坏(npm run build 能过) - 前端 `utils/` 纯逻辑已有 Vitest 单测(`npm test`);改 `utils/` 纯逻辑时保持现有行为不被破坏,并跑 `npm test``npm run build`
## 6. 常见陷阱(高频踩坑点) ## 6. 常见陷阱(高频踩坑点)
......
...@@ -14,6 +14,18 @@ ...@@ -14,6 +14,18 @@
- **简历库(Resume Library)**:招聘专员个人工作区中的候选人集合,承载从「新入库」到「已入职」的完整流程。 - **简历库(Resume Library)**:招聘专员个人工作区中的候选人集合,承载从「新入库」到「已入职」的完整流程。
- **阶段(Stage)**:候选人当前流程节点(未筛选 / 初筛通过 / 面试中 / Offer 中 / 待入职 / 已入职 等)。 - **阶段(Stage)**:候选人当前流程节点(未筛选 / 初筛通过 / 面试中 / Offer 中 / 待入职 / 已入职 等)。
- **Offer 状态(Offer Status)**:单个 Offer 在发放与入职链路中的进度(待 HR 确认 / 审批中 / 审批通过 / 待入职 / 已入职)。区别于候选阶段(Stage):阶段是候选人在整条招聘管线中的位置,Offer 状态只描述录用/入职这一子流程;二者通常保持一致,但各自演化。_Avoid_: 候选阶段(Stage)。 - **Offer 状态(Offer Status)**:单个 Offer 在发放与入职链路中的进度(待 HR 确认 / 审批中 / 审批通过 / 待入职 / 已入职)。区别于候选阶段(Stage):阶段是候选人在整条招聘管线中的位置,Offer 状态只描述录用/入职这一子流程;二者通常保持一致,但各自演化。_Avoid_: 候选阶段(Stage)。
- **审批驳回(Offer Rejected)**:Offer 审批被退回 HR 的动作;Offer 状态进入「审批驳回」是流程回路(非终止),候选人保持原阶段,需补齐材料后重新生成审批邮件。_Avoid_: 驳回(岗位 / JD 语境)。
- **已拒绝(Offer Declined)**:候选人拒绝该 Offer;Offer 终止(已拒绝),本流程及其待办关闭;若该候选人处于待入职,阶段回退到 Offer 中(避免漏斗继续计入待入职/入职),允许对同一候选人重开。_Avoid_: 初筛未通过、面试不通过。
- **已作废(Offer Voided)**:Offer 在发出前被撤回或入职前放弃,统一收敛为单一终止态;若该候选人处于待入职,阶段回退到 Offer 中;允许对同一候选人重开。_Avoid_: 已放弃(术语收敛为已作废)。
- **重开 Offer(Re-Offer)**:同一候选人上一份 Offer 处于已拒绝 / 已作废后重新发起一份新 Offer;一名候选人在任一时刻至多一份「进行中」Offer,历史保留。_Avoid_: 直接覆盖原 Offer。
- **审批意见(Approval Note)**:审批人驳回 / 作废 Offer 时填写的理由,随 Offer 与事件日志保留,供追溯。_Avoid_: 审批结果。
- **风险标记(Risk)**:HR 对 Offer 的备注性风险(如 审批超时 / 离职证明缺失),在审批材料中展示供审批人参考,默认不作为流程硬门槛。_Avoid_: 候选人风险维度(评估)。
- **标记(Tag)**:HR 人工给候选人贴的结构化标签,AI 不参与打标;用于重点跟进、待二面、储备、已淘汰等运营与筛选。 - **标记(Tag)**:HR 人工给候选人贴的结构化标签,AI 不参与打标;用于重点跟进、待二面、储备、已淘汰等运营与筛选。
- **期望薪资(Expected Salary)**:候选人对薪酬的期望值,自由文本(如 `20-30K` / `15k` / `面议`);来源为简历解析、平台采集或 HR 手填,旧数据可从入库备注回填。_Avoid_: 岗位薪资范围(岗位 `salaryRange`)、Offer 薪资(Offer `salary`)。 - **期望薪资(Expected Salary)**:候选人对薪酬的期望值,自由文本(如 `20-30K` / `15k` / `面议`);来源为简历解析、平台采集或 HR 手填,旧数据可从入库备注回填。_Avoid_: 岗位薪资范围(岗位 `salaryRange`)、Offer 薪资(Offer `salary`)。
- **沟通职位(Communicated Job)**:招聘平台对话中 HR 与候选人实际沟通的岗位,区别于候选人在平台填写的「期望岗位 / 求职意向」;由采集插件上传,仅在候选人自身求职岗位为空时作为初始 `jobTitle` 兜底,不覆盖已有值。 - **沟通职位(Communicated Job)**:招聘平台对话中 HR 与候选人实际沟通的岗位,区别于候选人在平台填写的「期望岗位 / 求职意向」;由采集插件上传,仅在候选人自身求职岗位为空时作为初始 `jobTitle` 兜底,不覆盖已有值。
......
...@@ -198,7 +198,7 @@ class Offer(Base): ...@@ -198,7 +198,7 @@ class Offer(Base):
id: Mapped[str] = mapped_column(String(120), primary_key=True) id: Mapped[str] = mapped_column(String(120), primary_key=True)
owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True) owner_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), unique=True, nullable=False) candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), nullable=False)
status: Mapped[str | None] = mapped_column(String(80)) status: Mapped[str | None] = mapped_column(String(80))
salary: Mapped[str | None] = mapped_column(String(120)) salary: Mapped[str | None] = mapped_column(String(120))
start_date: Mapped[str | None] = mapped_column(String(80)) start_date: Mapped[str | None] = mapped_column(String(80))
......
# ADR 0005:Offer 异常分支与重开(录用入职子流程补齐)
- 状态:已接受(2026-09-04)
- 关联决策:ADR 0004(Offer 状态机收敛 + 门控);CONTEXT 词表「Offer 状态 / 审批驳回 / 已拒绝 / 已作废 / 重开 Offer」。
## 背景 / Context
ADR 0004 把 Offer 状态收敛为线性五步并明确「若候选人被人工退回(如 Offer 后拒绝)但 Offer 记录仍存在,系统不会再自动把阶段拉回 Offer 中,需 HR 在录用入职管理手动终止该 Offer——当前未提供取消/关闭动作,属后续补齐项」。本 ADR 补齐该项:为 Offer 引入**异常/终止分支**,并支持对同一候选人**重开 Offer**(此前 `offers.candidate_id` 为唯一约束,天然禁止二次 Offer)。
## 决策 / Decision
1. **新增三个非线性 Offer 状态**(不进 `OFFER_STATUS_STEPS` 线性步骤条,面板以「退回/已终止」徽标展示):
- `审批驳回`:审批中退回 HR;流程**回路**,候选人保持原阶段,需补齐材料后重新生成审批邮件;驳回理由写入 `approvalNote` 并记事件。
- `已拒绝`:候选人拒绝 Offer;流程**终止**,本流程及待办关闭。
- `已作废`:发放前撤回 / 入职前放弃,统一收敛为单一终止态。
2. **候选阶段与 Offer 状态仍解耦,但异常需止损**:Offer 异常状态不按正向里程碑反向推进阶段;唯一例外——已拒绝 / 已作废时,若候选人处于 待入职,将阶段拉回 Offer 中(避免漏斗继续计入「待入职/入职」),以便重开或归档;审批驳回保持候选人原阶段。
3. **允许重开 Offer**:放开 `offers.candidate_id``unique` 约束;新 Offer 以 `reofferOf` 指向上一份已拒绝/已作废 Offer。一名候选人**任一时刻至多一条「进行中」Offer**`待 HR 确认 / 审批中 / 审批通过 / 待入职`),历史 Offer 全部保留。
4. **审批材料单源化**(关联 Q3):`frontend utils/offer.js``buildOfferApprovalMaterials` 成为唯一生成器(含风险行),store 与页面复用;删除重复实现与死代码;风险在审批材料中展示,默认不作为流程硬门槛。
## 影响 / Consequences
- 正向:`待入职` 后出现候选人拒绝 / 作废时流程不再死锁,可重开;审批不是单向,可驳回并留下理由。
- 代价/注意:放开唯一约束属**不可逆 schema 变更**——现有 SQLite 由 `create_all` 建库时会采用新建模,但**已存在的库**需在正式迁移(Alembic)或重建时去掉 `offers.candidate_id` 上的唯一索引,否则旧库仍会拒绝重开;`reconcileRecruitmentLinks` 的自动补 Offer 兜底以「进行中 / 已入职」为准,避免为已终止历史误建默认 Offer。
- 后续可做:把 Offer 状态枚举提炼为前后端共享常量;若引入合同 / 背调 / 入职确认节点再扩展状态。
\ No newline at end of file
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"sass": "^1.83.0", "sass": "^1.83.0",
"vite": "^6.0.5", "vite": "^6.0.5",
"vitest": "^5.0.0",
"vue-eslint-parser": "^10.4.1" "vue-eslint-parser": "^10.4.1"
}, },
"engines": { "engines": {
...@@ -770,12 +771,33 @@ ...@@ -770,12 +771,33 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
...@@ -1455,6 +1477,24 @@ ...@@ -1455,6 +1477,24 @@
"win32" "win32"
] ]
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
...@@ -1504,6 +1544,64 @@ ...@@ -1504,6 +1544,64 @@
"vue": "^3.2.25" "vue": "^3.2.25"
} }
}, },
"node_modules/@vitest/mocker": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz",
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.42", "version": "3.5.42",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz", "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
...@@ -1736,6 +1834,16 @@ ...@@ -1736,6 +1834,16 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async-validator": { "node_modules/async-validator": {
"version": "4.2.5", "version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
...@@ -1808,6 +1916,16 @@ ...@@ -1808,6 +1916,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
...@@ -2034,6 +2152,13 @@ ...@@ -2034,6 +2152,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
...@@ -2435,6 +2560,16 @@ ...@@ -2435,6 +2560,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
...@@ -3033,6 +3168,20 @@ ...@@ -3033,6 +3168,20 @@
"url": "https://github.com/fb55/nth-check?sponsor=1" "url": "https://github.com/fb55/nth-check?sponsor=1"
} }
}, },
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
...@@ -3384,6 +3533,13 @@ ...@@ -3384,6 +3533,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
...@@ -3393,6 +3549,20 @@ ...@@ -3393,6 +3549,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
...@@ -3435,6 +3605,26 @@ ...@@ -3435,6 +3605,26 @@
"url": "https://opencollective.com/synckit" "url": "https://opencollective.com/synckit"
} }
}, },
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
...@@ -3570,6 +3760,99 @@ ...@@ -3570,6 +3760,99 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.2.3.tgz",
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.42", "version": "3.5.42",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz",
...@@ -3678,6 +3961,23 @@ ...@@ -3678,6 +3961,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
......
...@@ -11,7 +11,9 @@ ...@@ -11,7 +11,9 @@
"lint": "eslint . --ext .js,.vue", "lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.vue --fix", "lint:fix": "eslint . --ext .js,.vue --fix",
"format": "prettier --write \"src/**/*.{js,vue,scss}\"", "format": "prettier --write \"src/**/*.{js,vue,scss}\"",
"format:check": "prettier --check \"src/**/*.{js,vue,scss}\"" "format:check": "prettier --check \"src/**/*.{js,vue,scss}\"",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
...@@ -29,6 +31,7 @@ ...@@ -29,6 +31,7 @@
"prettier": "^3.4.2", "prettier": "^3.4.2",
"sass": "^1.83.0", "sass": "^1.83.0",
"vite": "^6.0.5", "vite": "^6.0.5",
"vitest": "^5.0.0",
"vue-eslint-parser": "^10.4.1" "vue-eslint-parser": "^10.4.1"
}, },
"engines": { "engines": {
......
<script setup> <script setup>
import { computed, reactive } from 'vue' import { computed, reactive, ref } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { offerWorkflow, offerSalarySummary } from '@/utils/offer' import { offerWorkflow, offerBlockers } from '@/utils/offer'
import { workflowStageClass } from '@/utils/task' import { workflowStageClass } from '@/utils/task'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
...@@ -16,16 +16,14 @@ const status = computed(() => flow.value.status || '待 HR 确认') ...@@ -16,16 +16,14 @@ const status = computed(() => flow.value.status || '待 HR 确认')
const blockers = computed(() => { const blockers = computed(() => {
const o = offer.value const o = offer.value
if (!o) return [] if (!o) return []
const list = [] return offerBlockers(o, candidate.value || {})
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 blockedReason = computed(() => (blockers.value.length ? `请先补充${blockers.value.join('、')}` : ''))
const gates = computed(() => { const gates = computed(() => {
const s = status.value const s = status.value
const canGenerateMail = s === '待 HR 确认' const active = ['待 HR 确认', '审批中', '审批通过', '待入职'].includes(s)
const canGenerateMail = ['待 HR 确认', '审批驳回'].includes(s)
const terminal = ['已拒绝', '已作废'].includes(s)
return { return {
generateMail: { enabled: canGenerateMail, reason: canGenerateMail ? blockedReason.value : '当前阶段无需生成' }, generateMail: { enabled: canGenerateMail, reason: canGenerateMail ? blockedReason.value : '当前阶段无需生成' },
approve: { enabled: s === '审批中', reason: s === '审批中' ? '' : '需先生成审批邮件' }, approve: { enabled: s === '审批中', reason: s === '审批中' ? '' : '需先生成审批邮件' },
...@@ -34,6 +32,10 @@ const gates = computed(() => { ...@@ -34,6 +32,10 @@ const gates = computed(() => {
enabled: s === '待入职', enabled: s === '待入职',
reason: s === '待入职' ? (offer.value?.startDate ? '' : '请先补充入职日期') : '需先发送 Offer', reason: s === '待入职' ? (offer.value?.startDate ? '' : '请先补充入职日期') : '需先发送 Offer',
}, },
reject: { enabled: s === '审批中', reason: s === '审批中' ? '' : '仅审批中可驳回' },
decline: { enabled: s === '待入职', reason: s === '待入职' ? '' : '已发出 Offer 后可标记候选人拒绝' },
void: { enabled: active, reason: active ? '' : '当前状态无需作废' },
reopen: { enabled: terminal, reason: terminal ? '' : '仅已终止/作废后可重开' },
} }
}) })
...@@ -140,6 +142,47 @@ const confirmOnboarded = () => { ...@@ -140,6 +142,47 @@ const confirmOnboarded = () => {
showToast('候选人已确认入职') showToast('候选人已确认入职')
} }
const noteInput = ref('')
const rejectOffer = () => {
const result = store.rejectOffer(offer.value.id, noteInput.value)
if (result && result.ok === false) {
showToast(result.message, 'warning')
return
}
showToast('Offer 已驳回')
}
const markDeclined = () => {
const result = store.declineOffer(offer.value.id)
if (result && result.ok === false) {
showToast(result.message, 'warning')
return
}
showToast('候选人已拒绝 Offer')
}
const voidOffer = () => {
const result = store.voidOffer(offer.value.id, noteInput.value)
if (result && result.ok === false) {
showToast(result.message, 'warning')
return
}
showToast('Offer 已作废')
}
const reopenOffer = () => {
const cand = candidate.value
if (!cand) return
const newOffer = store.ensureOfferForCandidate(cand)
if (newOffer) {
store.selectedOfferId = newOffer.id
store.focusedTarget = { type: 'offer', id: newOffer.id }
store.persist('Offer 已重开')
showToast('已为该候选人重开 Offer')
}
}
const copyMaterials = async () => { const copyMaterials = async () => {
const text = materials.value?.items.map((item) => `${item.label}${item.value}`).join('\n') || '' const text = materials.value?.items.map((item) => `${item.label}${item.value}`).join('\n') || ''
try { try {
...@@ -175,6 +218,8 @@ const copyMaterials = async () => { ...@@ -175,6 +218,8 @@ const copyMaterials = async () => {
</div> </div>
<div class="workflow-next"> <div class="workflow-next">
<div> <div>
<el-tag v-if="flow.terminal" type="danger" size="small" class="flow-tag">流程已终止</el-tag>
<el-tag v-else-if="flow.rejected" type="warning" size="small" class="flow-tag">审批已驳回</el-tag>
<strong>{{ flow.next.title }}</strong> <strong>{{ flow.next.title }}</strong>
<p>{{ flow.next.desc }}</p> <p>{{ flow.next.desc }}</p>
</div> </div>
...@@ -291,6 +336,40 @@ const copyMaterials = async () => { ...@@ -291,6 +336,40 @@ const copyMaterials = async () => {
> >
确认已入职 确认已入职
</el-button> </el-button>
<el-divider />
<el-input v-model="noteInput" class="note-input" placeholder="驳回 / 作废理由(写入审批意见)" clearable />
<el-button
type="warning"
:disabled="!gates.reject.enabled"
:title="gates.reject.reason || '审批不通过,退回 HR 补材料'"
@click="rejectOffer"
>
审批驳回
</el-button>
<el-button
:disabled="!gates.void.enabled"
:title="gates.void.reason || '发放前撤回 / 作废本 Offer'"
@click="voidOffer"
>
撤回 / 作废
</el-button>
<el-button
:disabled="!gates.decline.enabled"
:title="gates.decline.reason || '候选人拒绝该 Offer,关闭本流程'"
@click="markDeclined"
>
候选人拒绝
</el-button>
<el-button
type="primary"
plain
:disabled="!gates.reopen.enabled"
:title="gates.reopen.reason || '对同一候选人重开一份 Offer'"
@click="reopenOffer"
>
重开 Offer
</el-button>
</div> </div>
</section> </section>
</aside> </aside>
...@@ -426,4 +505,13 @@ const copyMaterials = async () => { ...@@ -426,4 +505,13 @@ const copyMaterials = async () => {
flex-direction: column; flex-direction: column;
} }
} }
.note-input {
margin-top: 4px;
}
.flow-tag {
margin-right: 8px;
vertical-align: middle;
}
</style> </style>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRecruitmentStore } from '@/stores/recruitment' import { useRecruitmentStore } from '@/stores/recruitment'
import { offerMetricsView } from '@/utils/offer' import { offerMetricsView, offerSalarySummary } from '@/utils/offer'
const store = useRecruitmentStore() const store = useRecruitmentStore()
...@@ -12,11 +12,13 @@ const statusTag = (offer) => { ...@@ -12,11 +12,13 @@ const statusTag = (offer) => {
const status = offer.status || '待 HR 确认' const status = offer.status || '待 HR 确认'
if (['已入职'].includes(status)) return { text: status, type: 'success' } if (['已入职'].includes(status)) return { text: status, type: 'success' }
if (['审批通过', '待入职'].includes(status)) return { text: status, type: 'primary' } if (['审批通过', '待入职'].includes(status)) return { text: status, type: 'primary' }
if (['审批中'].includes(status)) return { text: status, type: 'warning' } if (['审批中', '审批驳回'].includes(status)) return { text: status, type: 'warning' }
if (['已拒绝', '已作废'].includes(status)) return { text: status, type: 'danger' }
return { text: status, type: 'info' } return { text: status, type: 'info' }
} }
const openOffer = (offer) => { const openOffer = (offer) => {
store.selectedOfferId = offer.id
store.focusedTarget = { type: 'offer', id: offer.id } store.focusedTarget = { type: 'offer', id: offer.id }
} }
...@@ -62,7 +64,7 @@ const candidateFor = (offer) => store.candidates.find((c) => c.id === offer.cand ...@@ -62,7 +64,7 @@ const candidateFor = (offer) => store.candidates.find((c) => c.id === offer.cand
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="薪资" min-width="160"> <el-table-column label="薪资" min-width="160">
<template #default="{ row }">{{ store.offerSalarySummary(row) }}</template> <template #default="{ row }">{{ offerSalarySummary(row) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="入职日期" width="120"> <el-table-column label="入职日期" width="120">
<template #default="{ row }">{{ row.startDate || '待确认' }}</template> <template #default="{ row }">{{ row.startDate || '待确认' }}</template>
......
...@@ -26,6 +26,13 @@ import { analysisInputKey, buildLocalAnalysis, sanitizeResumeForLlm } from '@/ut ...@@ -26,6 +26,13 @@ import { analysisInputKey, buildLocalAnalysis, sanitizeResumeForLlm } from '@/ut
import { isAiEvaluationReport } from '@/utils/scoring' import { isAiEvaluationReport } from '@/utils/scoring'
import { matchedJobForCandidate, candidateJobTitle } from '@/utils/links' import { matchedJobForCandidate, candidateJobTitle } from '@/utils/links'
import { createOfferRecord } from '@/utils/normalize' import { createOfferRecord } from '@/utils/normalize'
import {
offerSalarySummary,
buildOfferApprovalMaterials,
OFFER_ACTIVE_STATUSES,
OFFER_TERMINAL_STATUSES,
OFFER_REJECTED_STATUS,
} from '@/utils/offer'
// 业务数据一律以数据库为准,不使用浏览器本地缓存;空工作区用这个骨架初始化 // 业务数据一律以数据库为准,不使用浏览器本地缓存;空工作区用这个骨架初始化
const EMPTY_STATE = { jobs: [], candidates: [], offers: [], tasks: [], eventLog: [] } const EMPTY_STATE = { jobs: [], candidates: [], offers: [], tasks: [], eventLog: [] }
...@@ -47,21 +54,17 @@ function buildRecruitmentEvent(type, candidate = {}, extra = {}) { ...@@ -47,21 +54,17 @@ function buildRecruitmentEvent(type, candidate = {}, extra = {}) {
} }
function dedupeOffersByCandidate(offers = []) { function dedupeOffersByCandidate(offers = []) {
// 保留全部 Offer 记录(含已拒绝 / 已作废历史,支持对同一候选人重开);仅按 id 去重。
// 原实现按候选人折叠为一条,会丢失重开历史并破坏「一候选人多 Offer」的约束。
const seen = new Set()
const result = [] const result = []
const indexByCandidate = new Map()
offers.forEach((offer) => { offers.forEach((offer) => {
const candidateId = offer.candidateId || '' const id = offer.id || ''
if (!candidateId || !indexByCandidate.has(candidateId)) { if (id) {
indexByCandidate.set(candidateId, result.length) if (seen.has(id)) return
result.push(offer) seen.add(id)
} else {
Object.assign(result[indexByCandidate.get(candidateId)], {
...offer,
id: result[indexByCandidate.get(candidateId)].id || offer.id,
approvalMaterials: offer.approvalMaterials,
mailDraft: offer.mailDraft || result[indexByCandidate.get(candidateId)].mailDraft,
})
} }
result.push(offer)
}) })
return result return result
} }
...@@ -158,7 +161,13 @@ function reconcileRecruitmentLinks(targetState = {}) { ...@@ -158,7 +161,13 @@ function reconcileRecruitmentLinks(targetState = {}) {
targetState.offers = dedupeOffersByCandidate(targetState.offers || []).filter((offer) => targetState.offers = dedupeOffersByCandidate(targetState.offers || []).filter((offer) =>
candidateById.has(offer.candidateId) candidateById.has(offer.candidateId)
) )
const offerCandidateIds = new Set(targetState.offers.map((offer) => offer.candidateId)) // 自动补 Offer 兜底:优先以「进行中 / 已入职」为准,避免为已拒绝/已作废后仍需重开的情况重复建记录;
// 也避免把已入职候选人的历史 offer 误判为缺失而新建一条默认 Offer。
const offerCandidateIds = new Set(
targetState.offers
.filter((offer) => OFFER_ACTIVE_STATUSES.includes(offer.status) || offer.status === '已入职')
.map((offer) => offer.candidateId)
)
;(targetState.candidates || []).forEach((candidate) => { ;(targetState.candidates || []).forEach((candidate) => {
if (['Offer 中', '待入职', '已入职'].includes(candidate.stage) && !offerCandidateIds.has(candidate.id)) { if (['Offer 中', '待入职', '已入职'].includes(candidate.stage) && !offerCandidateIds.has(candidate.id)) {
targetState.offers.push(createOfferRecord(candidate)) targetState.offers.push(createOfferRecord(candidate))
...@@ -469,12 +478,19 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -469,12 +478,19 @@ export const useRecruitmentStore = defineStore('recruitment', {
}, },
ensureOfferForCandidate(candidate, overrides = {}) { ensureOfferForCandidate(candidate, overrides = {}) {
const existing = this.state.offers.find((offer) => offer.candidateId === candidate.id) // 重开策略:一名候选人任一时刻至多一条「进行中」Offer。
// 存在进行中 Offer 则复用;否则(无 Offer,或仅历史 已拒绝/已作废)新建一条并记录 reofferOf。
const existing = this.state.offers.find(
(offer) => offer.candidateId === candidate.id && OFFER_ACTIVE_STATUSES.includes(offer.status)
)
if (existing) { if (existing) {
Object.assign(existing, overrides) Object.assign(existing, overrides)
return existing return existing
} }
const offer = createOfferRecord(candidate, overrides) const latest = this.state.offers
.filter((offer) => offer.candidateId === candidate.id && OFFER_TERMINAL_STATUSES.includes(offer.status))
.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')))[0]
const offer = createOfferRecord(candidate, { reofferOf: latest?.id || '', ...overrides })
this.state.offers.push(offer) this.state.offers.push(offer)
return offer return offer
}, },
...@@ -812,40 +828,18 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -812,40 +828,18 @@ export const useRecruitmentStore = defineStore('recruitment', {
return api.uploadResumeFile(file) return api.uploadResumeFile(file)
}, },
offerSalarySummary(offer = {}) {
if (offer.salary && offer.salary !== '待确认') return offer.salary
const parts = [
offer.probationSalary ? `试用 ${offer.probationSalary}` : '',
offer.regularSalary ? `转正 ${offer.regularSalary}` : '',
offer.probationMonths ? `试用期 ${offer.probationMonths}个月` : '',
].filter(Boolean)
return parts.join(' / ') || '待确认'
},
refreshOfferMaterials(offer) { refreshOfferMaterials(offer) {
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId) || {} const candidate = this.state.candidates.find((item) => item.id === offer.candidateId) || {}
const fields = [ const materials = buildOfferApprovalMaterials(offer, candidate)
{ key: 'candidateName', label: '候选人', value: candidate.name }, offer.approvalMaterials = materials
{ key: 'jobTitle', label: '岗位', value: candidate.jobTitle }, return { blockers: materials.blockers, materials }
{ key: 'salary', label: '薪资', value: this.offerSalarySummary(offer) },
{ key: 'startDate', label: '入职日期', value: offer.startDate },
{ key: 'workLocation', label: '工作地点', value: offer.workLocation },
{ key: 'contractType', label: '合同类型', value: offer.contractType || '劳动合同' },
]
const blockers = fields.filter((field) => !field.value).map((field) => field.label)
offer.approvalMaterials = {
title: 'Offer 审批材料',
items: fields.map((field) => ({ label: field.label, value: field.value || '待补充', missing: !field.value })),
blockers,
}
return { blockers, materials: offer.approvalMaterials }
}, },
generateMail(offerId) { generateMail(offerId) {
const offer = this.state.offers.find((item) => item.id === offerId) const offer = this.state.offers.find((item) => item.id === offerId)
if (!offer) return if (!offer) return
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId) || {} 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.mailDraft = `尊敬的 ${candidate.name || '候选人'}:\n\n我们很高兴邀请您加入${candidate.jobTitle || '团队'}。\n\n薪资:${offerSalarySummary(offer)}\n入职日期:${offer.startDate || '待确认'}\n工作地点:${offer.workLocation || '待确认'}\n\n请回复确认,谢谢!`
offer.status = '审批中' offer.status = '审批中'
this.persist('Offer 邮件草稿已生成') this.persist('Offer 邮件草稿已生成')
return offer.mailDraft return offer.mailDraft
...@@ -896,6 +890,51 @@ export const useRecruitmentStore = defineStore('recruitment', { ...@@ -896,6 +890,51 @@ export const useRecruitmentStore = defineStore('recruitment', {
return { ok: true } return { ok: true }
}, },
rejectOffer(offerId, note = '') {
const offer = this.state.offers.find((item) => item.id === offerId)
if (!offer) return { ok: false, message: 'Offer 不存在' }
offer.status = OFFER_REJECTED_STATUS
offer.approvalNote = note
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId)
if (candidate) {
this.completeTasksForCandidate(candidate, [/Offer|审批|材料/])
this.logEvent('offer_rejected', candidate, { offerId, note: note || '审批驳回' })
}
this.persist('Offer 已驳回')
return { ok: true }
},
declineOffer(offerId) {
const offer = this.state.offers.find((item) => item.id === offerId)
if (!offer) return { ok: false, message: 'Offer 不存在' }
offer.status = '已拒绝'
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId)
if (candidate) {
// 候选人拒绝后不应继续计入「待入职/入职」,拉回 Offer 中以便重开或归档。
if (candidate.stage === '待入职') this.setCandidateStage(candidate, 'Offer 中', '候选人拒绝 Offer')
this.completeTasksForCandidate(candidate, [/Offer|入职|材料|发送/])
this.logEvent('offer_declined', candidate, { offerId, note: '候选人拒绝 Offer' })
}
this.persist('Offer 已标记为候选人拒绝')
return { ok: true }
},
voidOffer(offerId, note = '') {
const offer = this.state.offers.find((item) => item.id === offerId)
if (!offer) return { ok: false, message: 'Offer 不存在' }
offer.status = '已作废'
offer.approvalNote = offer.approvalNote || note
const candidate = this.state.candidates.find((item) => item.id === offer.candidateId)
if (candidate) {
// 作废/撤回后不应继续计入「待入职/入职」,拉回 Offer 中以便重开或归档。
if (candidate.stage === '待入职') this.setCandidateStage(candidate, 'Offer 中', 'Offer 作废/撤回')
this.completeTasksForCandidate(candidate, [/Offer|入职|材料|发送/])
this.logEvent('offer_voided', candidate, { offerId, note: note || 'Offer 已作废' })
}
this.persist('Offer 已作废')
return { ok: true }
},
exportBackup() { exportBackup() {
return JSON.stringify(this.state, null, 2) return JSON.stringify(this.state, null, 2)
}, },
......
import { describe, it, expect } from 'vitest'
import {
offerSalarySummary,
offerWorkflow,
offerMetricsView,
buildOfferApprovalMaterials,
offerBlockers,
OFFER_STATUS_STEPS,
OFFER_ACTIVE_STATUSES,
OFFER_TERMINAL_STATUSES,
OFFER_REJECTED_STATUS,
} from '../offer'
const baseOffer = (status = '待 HR 确认', extra = {}) => ({ candidateId: 'cand-1', status, salary: '', ...extra })
const candidate = { id: 'cand-1', name: '张三', jobTitle: '销售总监' }
describe('offerSalarySummary', () => {
it('returns salary directly when set', () => {
expect(offerSalarySummary({ salary: '45k/月' })).toBe('45k/月')
})
it('builds from probation/regular salary', () => {
expect(offerSalarySummary({ probationSalary: '30k', regularSalary: '45k', probationMonths: '3' })).toBe(
'试用 30k / 转正 45k / 试用期 3个月'
)
})
it('treats 待确认 as empty (falls through to parts)', () => {
expect(offerSalarySummary({ salary: '待确认', probationSalary: '30k' })).toBe('试用 30k')
expect(offerSalarySummary({ salary: '待确认' })).toBe('待确认')
})
it('returns 待确认 when nothing set', () => {
expect(offerSalarySummary({})).toBe('待确认')
})
})
describe('status constants', () => {
it('keeps linear steps and exception sets', () => {
expect(OFFER_STATUS_STEPS).toEqual(['待 HR 确认', '审批中', '审批通过', '待入职', '已入职'])
expect(OFFER_ACTIVE_STATUSES).toEqual(['待 HR 确认', '审批中', '审批通过', '待入职'])
expect(OFFER_TERMINAL_STATUSES).toEqual(['已拒绝', '已作废'])
expect(OFFER_REJECTED_STATUS).toBe('审批驳回')
})
})
describe('offerWorkflow', () => {
it('maps every linear status to the right step', () => {
expect(offerWorkflow(baseOffer('待 HR 确认')).next.title).toBe('生成审批邮件草稿')
expect(offerWorkflow(baseOffer('审批中')).next.title).toBe('等待审批结果')
expect(offerWorkflow(baseOffer('审批通过')).next.title).toBe('发送 Offer 并跟进入职')
expect(offerWorkflow(baseOffer('待入职')).next.title).toBe('确认候选人已入职')
expect(offerWorkflow(baseOffer('已入职')).next.title).toBe('流程完成')
})
it('defaults missing status to 待 HR 确认', () => {
const w = offerWorkflow({})
expect(w.status).toBe('待 HR 确认')
expect(w.currentIndex).toBe(0)
})
it('marks 审批驳回 as a rejection loop (back to 审批中)', () => {
const w = offerWorkflow(baseOffer('审批驳回'))
expect(w.rejected).toBe(true)
expect(w.terminal).toBe(false)
expect(w.currentIndex).toBe(1)
expect(w.next.title).toBe('审批退回')
})
it('marks 已拒绝 / 已作废 as terminal', () => {
const declined = offerWorkflow(baseOffer('已拒绝'))
expect(declined.terminal).toBe(true)
expect(declined.currentIndex).toBe(OFFER_STATUS_STEPS.length)
expect(declined.next.title).toBe('候选人已拒绝')
const voided = offerWorkflow(baseOffer('已作废'))
expect(voided.terminal).toBe(true)
expect(voided.next.title).toBe('Offer 已作废')
})
})
describe('buildOfferApprovalMaterials', () => {
it('flags required missing fields and keeps risk as non-required row', () => {
const m = buildOfferApprovalMaterials(baseOffer(), candidate)
const labels = m.items.map((i) => i.label)
expect(labels).toEqual(['候选人', '岗位', '工作地点', '薪资', '入职日期', '合同类型', '风险'])
expect(m.blockers).toEqual(['工作地点', '薪资', '入职日期'])
const risk = m.items.find((i) => i.label === '风险')
expect(risk.value).toBe('正常')
expect(risk.missing).toBe(false)
})
it('reports no blockers when complete', () => {
const offer = baseOffer('待 HR 确认', {
salary: '45k/月',
startDate: '2026-06-20',
workLocation: '上海',
risk: '审批超时',
})
const m = buildOfferApprovalMaterials(offer, candidate)
expect(m.blockers).toEqual([])
expect(m.items.find((i) => i.label === '风险').value).toBe('审批超时')
})
it('treats salary 待确认 as missing', () => {
const m = buildOfferApprovalMaterials(
baseOffer('待 HR 确认', { salary: '待确认', startDate: '2026-06-20', workLocation: '上海' }),
candidate
)
expect(m.blockers).toContain('薪资')
expect(m.items.find((i) => i.label === '薪资').value).toBe('待补充')
})
})
describe('offerBlockers', () => {
it('returns blocker labels from the single source', () => {
expect(offerBlockers(baseOffer(), candidate)).toEqual(['工作地点', '薪资', '入职日期'])
})
})
describe('offerMetricsView', () => {
it('excludes terminal (已拒绝/已作废) from total so cards sum coherently', () => {
const offers = [
baseOffer('待 HR 确认'),
baseOffer('审批驳回'),
baseOffer('已拒绝'),
baseOffer('已作废'),
baseOffer('待入职'),
baseOffer('已入职'),
]
const m = offerMetricsView(offers, [candidate])
expect(m.total).toBe(4) // 待HR确认 + 审批驳回 + 待入职 + 已入职
expect(m.pending).toBe(2) // 待HR确认 + 审批驳回
expect(m.sent).toBe(2) // 待入职 + 已入职
expect(m.onboarding).toBe(1)
expect(m.onboarded).toBe(1)
})
})
export function offerSalarySummary(offer = {}) { export function offerSalarySummary(offer = {}) {
if (offer.salary) return offer.salary if (offer.salary && offer.salary !== '待确认') return offer.salary
const parts = [ const parts = [
offer.probationSalary ? `试用 ${offer.probationSalary}` : '', offer.probationSalary ? `试用 ${offer.probationSalary}` : '',
offer.regularSalary ? `转正 ${offer.regularSalary}` : '', offer.regularSalary ? `转正 ${offer.regularSalary}` : '',
...@@ -8,31 +8,67 @@ export function offerSalarySummary(offer = {}) { ...@@ -8,31 +8,67 @@ export function offerSalarySummary(offer = {}) {
return parts.join(' / ') || '待确认' return parts.join(' / ') || '待确认'
} }
// Offer 状态枚举唯一数据源(ADR 0004 + 0005)。线性五步只描述正向推进;
// 异常/终止状态(审批驳回/已拒绝/已作废)不属于线性步骤,见 OFFER_TERMINAL_STATUSES / OFFER_REJECTED_STATUS。
export const OFFER_STATUS_STEPS = ['待 HR 确认', '审批中', '审批通过', '待入职', '已入职'] export const OFFER_STATUS_STEPS = ['待 HR 确认', '审批中', '审批通过', '待入职', '已入职']
// 终止态:候选人拒绝、发放前撤回/作废(均允许对同一候选人重开 Offer)。
export const OFFER_TERMINAL_STATUSES = ['已拒绝', '已作废']
// 回路态:审批被退回 HR,处于非终止、需重新推进。
export const OFFER_REJECTED_STATUS = '审批驳回'
// 进行中(active)状态:任一候选人至多一条处于这些状态的 Offer。
export const OFFER_ACTIVE_STATUSES = ['待 HR 确认', '审批中', '审批通过', '待入职']
export function offerWorkflow(offer = {}) { export function offerWorkflow(offer = {}) {
const steps = OFFER_STATUS_STEPS const steps = OFFER_STATUS_STEPS
const status = offer.status || '待 HR 确认' const status = offer.status || '待 HR 确认'
const currentIndex = Math.max(0, steps.indexOf(status)) const terminal = OFFER_TERMINAL_STATUSES.includes(status)
const rejected = status === OFFER_REJECTED_STATUS
const idx = steps.indexOf(status)
const currentIndex = OFFER_TERMINAL_STATUSES.includes(status)
? steps.length
: rejected
? Math.max(0, steps.indexOf('审批中'))
: Math.max(0, idx)
const next = const next =
status === '待 HR 确认' status === OFFER_REJECTED_STATUS
? { ? { title: '审批退回', desc: '审批被驳回,请补充审批材料后重新生成审批邮件。', action: '重新生成邮件' }
title: '生成审批邮件草稿', : status === '已拒绝'
desc: '补齐薪资、试用期、五险一金与入职时间,生成审批邮件草稿。', ? {
action: '生成邮件草稿', title: '候选人已拒绝',
} desc: '候选人拒绝 Offer,本流程已关闭;可对同一候选人重开 Offer。',
: status === '审批中' action: '已关闭',
? { title: '等待审批结果', desc: '审批邮件已生成,等待业务或用人部门确认。', action: '审批通过' } }
: status === '审批通过' : status === '已作废'
? { ? {
title: '发送 Offer 并跟进入职', title: 'Offer 已作废',
desc: '审批通过后向候选人发送 Offer,并跟进入职材料。', desc: 'Offer 已撤回/作废,本流程已关闭;可对同一候选人重开 Offer。',
action: '发送 Offer', action: '已关闭',
} }
: status === '待入职' : status === '待 HR 确认'
? { title: '确认候选人已入职', desc: '候选人已接受 Offer,到岗确认后即可完成流程。', action: '确认已入职' } ? {
: { title: '流程完成', desc: '候选人已入职,相关待办与看板数据应关闭。', action: '完成' } title: '生成审批邮件草稿',
return { steps, currentIndex, next, status } desc: '补齐薪资、试用期、五险一金与入职时间,生成审批邮件草稿。',
action: '生成邮件草稿',
}
: status === '审批中'
? { title: '等待审批结果', desc: '审批邮件已生成,等待业务或用人部门确认。', action: '审批通过' }
: status === '审批通过'
? {
title: '发送 Offer 并跟进入职',
desc: '审批通过后向候选人发送 Offer,并跟进入职材料。',
action: '发送 Offer',
}
: status === '待入职'
? {
title: '确认候选人已入职',
desc: '候选人已接受 Offer,到岗确认后即可完成流程。',
action: '确认已入职',
}
: { title: '流程完成', desc: '候选人已入职,相关待办与看板数据应关闭。', action: '完成' }
return { steps, currentIndex, next, status, terminal, rejected }
} }
export function offerWorkflowActionButtons(offer = {}) { export function offerWorkflowActionButtons(offer = {}) {
...@@ -42,47 +78,42 @@ export function offerWorkflowActionButtons(offer = {}) { ...@@ -42,47 +78,42 @@ export function offerWorkflowActionButtons(offer = {}) {
export function offerMetricsView(offers = [], candidates = []) { export function offerMetricsView(offers = [], candidates = []) {
const candidateIds = new Set(candidates.map((candidate) => candidate.id)) const candidateIds = new Set(candidates.map((candidate) => candidate.id))
const inScope = offers.filter((offer) => candidateIds.has(offer.candidateId)) const inScope = offers.filter((offer) => candidateIds.has(offer.candidateId))
// 总数只统计进行中 + 已入职,不把已拒绝/已作废的历史计入,使四张卡片口径一致。
const total = inScope.filter((offer) => !OFFER_TERMINAL_STATUSES.includes(offer.status)).length
const approved = inScope.filter((offer) => ['审批通过', '待入职', '已入职'].includes(offer.status)).length const approved = inScope.filter((offer) => ['审批通过', '待入职', '已入职'].includes(offer.status)).length
const sent = inScope.filter((offer) => ['待入职', '已入职'].includes(offer.status)).length const sent = inScope.filter((offer) => ['待入职', '已入职'].includes(offer.status)).length
const onboarding = inScope.filter((offer) => offer.status === '待入职').length const onboarding = inScope.filter((offer) => offer.status === '待入职').length
const onboarded = 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) => ['待 HR 确认', '审批中', '审批驳回'].includes(offer.status)).length
return { total: inScope.length, approved, sent, onboarding, onboarded, pending } return { total, approved, sent, onboarding, onboarded, pending }
} }
export function formatValidation(validation = []) { export function formatValidation(validation = []) {
return (validation || []).map((item) => `${item.field}${item.message}`).join(';') return (validation || []).map((item) => `${item.field}${item.message}`).join(';')
} }
export function formatOfferMaterials(materials = {}) { // Offer 审批材料唯一数据源。blockers 为缺失字段名列表(string[]),供 store / 页面复用。
const rows = (materials.items || []).map((item) => ({ // 风险(risk)作为材料一行展示给审批人参考,默认不作为硬门槛。
label: item.label, export function buildOfferApprovalMaterials(offer = {}, candidate = {}) {
value: item.value, const salary = offerSalarySummary(offer)
missing: item.missing,
}))
return {
title: materials.title,
rows,
blockers: (materials.blockers || []).map((item) => `${item.field}${item.message}`),
}
}
export function buildOfferApprovalMaterials(offer = {}) {
const fields = [ const fields = [
{ label: '候选人', value: offer.candidateName, required: true }, { label: '候选人', value: candidate.name, present: Boolean(candidate.name), required: true },
{ label: '岗位', value: offer.jobTitle, required: true }, { label: '岗位', value: candidate.jobTitle, present: Boolean(candidate.jobTitle), required: true },
{ label: '工作地点', value: offer.workLocation, required: true }, { label: '工作地点', value: offer.workLocation, present: Boolean(offer.workLocation), required: true },
{ label: '薪资', value: offerSalarySummary(offer), required: true }, { label: '薪资', value: salary, present: salary !== '待确认', required: true },
{ label: '入职日期', value: offer.startDate, required: true }, { label: '入职日期', value: offer.startDate, present: Boolean(offer.startDate), required: true },
{ label: '合同类型', value: offer.contractType || '劳动合同', required: true }, { label: '合同类型', value: offer.contractType || '劳动合同', present: true, required: true },
{ label: '风险', value: offer.risk || '正常', present: true, required: false },
] ]
const items = fields.map((item) => ({ const items = fields.map((item) => ({
label: item.label, label: item.label,
value: item.value || '待补充', value: item.present ? item.value : '待补充',
missing: item.required && !item.value, missing: item.required && !item.present,
})) }))
const blockers = items const blockers = items.filter((item) => item.missing).map((item) => item.label)
.filter((item) => item.missing)
.map((item) => ({ field: item.label, message: `${item.label}待补充` }))
return { title: 'Offer 审批材料', items, blockers } return { title: 'Offer 审批材料', items, blockers }
} }
export function offerBlockers(offer = {}, candidate = {}) {
return buildOfferApprovalMaterials(offer, candidate).blockers
}
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