Commit 542a4008 authored by 李文光's avatar 李文光

feat: 优化 JD 流程并展示岗位创建时间

parent 1b8f3ddd
......@@ -89,10 +89,12 @@ def workspace_has_data(session: Session, owner: str) -> bool:
def read_state(session: Session, owner: str = "local") -> dict[str, Any]:
jobs = [
parse_json(item.data, {})
for item in session.scalars(select(Job).where(Job.owner_id == owner).order_by(Job.created_at, Job.id))
]
jobs = []
for item in session.scalars(select(Job).where(Job.owner_id == owner).order_by(Job.created_at, Job.id)):
data = parse_json(item.data, {})
if not data.get("createdAt"):
data = {**data, "createdAt": data.get("created_at") or item.created_at}
jobs.append(data)
candidates = [
parse_json(item.data, {})
for item in session.scalars(
......@@ -143,8 +145,8 @@ def replace_state(session: Session, owner: str, input_state: dict[str, Any], imp
for job in state["jobs"]:
job_id = job.get("id") or _id("job")
job = {**job, "id": job_id}
created_at = job.get("createdAt") or job.get("created_at") or state["updatedAt"]
job = {**job, "id": job_id, "createdAt": created_at}
session.add(
Job(
id=job_id,
......
......@@ -23,7 +23,8 @@ from collections.abc import Callable
from typing import Any
# ---------------------------------------------------------------------------
# 题单(question-bank v2.2;研发/技术岗额外插入 9a 技术栈问)
# 题单(question-bank v2.3;技术岗额外插入 9a 技术栈问,
# 且技术向推荐答案只对 tech_role=True 展示)
# ---------------------------------------------------------------------------
# 每条可选答案:label 展示给用户,value 入库,fields 是选中时写入 job 的键值。
......@@ -31,8 +32,8 @@ from typing import Any
_Option = dict[str, Any]
def _opt(label: str, value: str, fields: dict[str, Any] | None = None) -> _Option:
return {"label": label, "value": value, "fields": fields or {}}
def _opt(label: str, value: str, fields: dict[str, Any] | None = None, **extra: Any) -> _Option:
return {"label": label, "value": value, "fields": fields or {}, **extra}
# 每个问题的定义。parse_text(s, job) 可自由输入做字段推断,用于开放式回答。
......@@ -126,7 +127,7 @@ QUESTION_BANK: list[dict[str, Any]] = [
"负责核心模块的设计与编码;参加代码评审与方案评审;"
"参与联调与上线,负责线上问题值班与处置"
),
}),
}, tech_only=True),
_opt("业务岗:拓客/方案/谈判/交付/客户经营", "sales", {
"responsibilities": (
"负责客户开拓与需求对接;输出方案与商务谈判;推进招投标与回款;"
......@@ -224,12 +225,15 @@ QUESTION_BANK: list[dict[str, Any]] = [
"options": [
_opt("技术栈能上手 + 独立交付", "tech-lifeline", {
"mustHave": "核心技能能直接上手;端到端独立交付能力",
}),
}, tech_only=True),
_opt("业务理解 + 客户资源", "business-lifeline", {
"mustHave": "业务理解力;客户资源或成交能力",
}),
_opt("线上问题处置 + 抗压", "ops-lifeline", {
"mustHave": "线上问题定位与处置经验;抗压推进能力",
}, tech_only=True),
_opt("可迁移行业经验 + 稳定成交/交付", "field-lifeline", {
"mustHave": "目标行业可迁移经验;稳定的成交或交付记录",
}),
],
"free_text": True,
......@@ -246,7 +250,7 @@ QUESTION_BANK: list[dict[str, Any]] = [
"options": [
_opt("简历看项目 + 面试做设计题", "resume+design", {
"verification": "简历看真实项目落地;面试给真实场景现场设计追问。",
}),
}, tech_only=True),
_opt("简历看关键词 + 面试问案例", "resume+case", {
"verification": "简历看相关经历关键词;面试问经典案例 STAR 追问。",
}),
......@@ -291,16 +295,25 @@ QUESTION_BANK: list[dict[str, Any]] = [
"options": [
_opt("纯 CRUD / 只会调轮子", "crud", {
"knockout": "纯 CRUD 无架构封装;只会调轮子不懂原理",
}),
}, tech_only=True),
_opt("无线上问题经验", "no-ops", {
"knockout": "纯功能开发,无生产环境排查处置经验",
}),
}, tech_only=True),
_opt("技术炫技、无业务 sense", "show-off", {
"knockout": "技术炫技,缺少业务落地能力",
}),
}, tech_only=True),
_opt("关键词全命中但无成果/无深度", "shallow", {
"knockout": "简历关键词全命中但缺真实成果与深度",
}),
_opt("客户资源看着多,但成交/回款无证据", "no-proof", {
"knockout": "客户资源或线索多,但缺少实际成交、回款与复购证据",
}),
_opt("只会跟单/陪跑,推不动决策链", "no-close", {
"knockout": "只会跟单与关系维护,缺少推进决策链和临门成交能力",
}),
_opt("只能低价成交,无方案/谈判溢价能力", "price-only", {
"knockout": "成交依赖低价或政策,缺少方案价值与商务谈判能力",
}),
],
"free_text": True,
"parse_text": lambda text: {"knockout": text.strip()[:200]},
......@@ -319,6 +332,10 @@ QUESTION_BANK: list[dict[str, Any]] = [
_opt("3 个月,能否在指导下独立完成一次迭代", "3m-iterate", {
"probationPeriod": "3个月",
"probationCriteria": "在指导下独立完成一次需求迭代",
}, tech_only=True),
_opt("3 个月,能否独立拿下首个客户/首单", "3m-first-deal", {
"probationPeriod": "3个月",
"probationCriteria": "独立跟进并成交首个目标客户,或跑通首个项目交付",
}),
_opt("6 个月,看是否能独立交付关键任务", "6m-deliver", {
"probationPeriod": "6个月",
......@@ -365,7 +382,9 @@ def _completed() -> dict[str, Any]:
return {"complete": True}
def _next_question(question_ids: list[str], job: dict[str, Any], answered: list[str] | None = None) -> dict[str, Any] | None:
def _next_question(
question_ids: list[str], job: dict[str, Any], answered: list[str] | None = None, tech_role: bool = False
) -> dict[str, Any] | None:
"""按 id 顺序找到第一个需要问的问题(返回渲染数据),都答过则完成。
会跳过已作答(answered)或满足 skip_when 的问题。
......@@ -379,7 +398,7 @@ def _next_question(question_ids: list[str], job: dict[str, Any], answered: list[
continue
if q.get("skip_when") and q["skip_when"](job):
continue
return _render(q)
return _render(q, tech_role=tech_role)
return _completed()
......@@ -402,10 +421,11 @@ def _question_ids(tech_role: bool) -> list[str]:
return ids
def _render(q: dict[str, Any]) -> dict[str, Any]:
def _render(q: dict[str, Any], *, tech_role: bool = False) -> dict[str, Any]:
options = [
{"label": o["label"], "value": o["value"], "fields": o.get("fields", {})}
for o in q.get("options", [])
if not (o.get("tech_only") and not tech_role)
]
return {
"question_id": q["id"],
......@@ -465,7 +485,7 @@ def start(state: dict[str, Any] | None, job: dict[str, Any]) -> dict[str, Any]:
tech_role = bool(state.get("tech_role", _guess_tech_role(job)))
job = _start_job(job or {})
ids = _question_ids(tech_role)
nxt = _next_question(ids, job, answered=[])
nxt = _next_question(ids, job, answered=[], tech_role=tech_role)
# 记录这道题是「从哪一题开始问」的进度
return {
"session_id": state.get("session_id") or "",
......@@ -497,7 +517,7 @@ def advance(state: dict[str, Any], answer: dict[str, Any]) -> dict[str, Any]:
answered.append(qid)
ids = state.get("question_ids") or _question_ids(bool(state.get("tech_role")))
nxt = _next_question(ids, job, answered=answered)
nxt = _next_question(ids, job, answered=answered, tech_role=bool(state.get("tech_role")))
if isinstance(nxt, dict) and nxt.get("complete"):
return {
......
......@@ -2,7 +2,8 @@ import json
import re
from typing import Any
import httpx
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from backend.app.config import get_settings
from backend.app.services.qwenpaw_client import qwenpaw_jd_draft
......@@ -159,6 +160,32 @@ def parse_json_from_text(text: str) -> dict[str, Any]:
raise ValueError("模型未返回可解析 JSON") from None
def _message_text(message: Any) -> str:
content = message.content
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
str(part.get("text") or part.get("content") or "") if isinstance(part, dict) else str(part)
for part in content
)
return str(content or "")
async def _chat_json(settings: Any, system: str, user: str, *, temperature: float = 0) -> dict[str, Any]:
"""用 langchain-openai 的 ChatOpenAI 走 OpenAI 兼容接口,固定 JSON 模式。"""
chat = ChatOpenAI(
model=settings.effective_llm_model,
api_key=settings.effective_llm_api_key,
base_url=settings.effective_llm_base_url,
temperature=temperature,
timeout=60,
max_retries=1,
).bind(response_format={"type": "json_object"})
response = await chat.ainvoke([SystemMessage(content=system), HumanMessage(content=user)])
return parse_json_from_text(_message_text(response))
def sanitize_resume_for_llm(text: str, candidate: dict[str, Any]) -> str:
sanitized = str(text or "")
for value in [candidate.get("name"), candidate.get("phone"), candidate.get("email"), candidate.get("city"), candidate.get("resumeName")]:
......@@ -204,24 +231,12 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
f"候选人脱敏结构:{json.dumps({**safe_candidate, 'resumeText': None}, ensure_ascii=False, indent=2)}\n\n"
f"脱敏简历正文:\n{(safe_candidate.get('resumeText') or '')[:16000]}"
)
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
f"{settings.effective_llm_base_url}/chat/completions",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.effective_llm_api_key}"},
json={
"model": settings.effective_llm_model,
"temperature": 0,
"messages": [
{"role": "system", "content": "你是企业招聘系统中的资深HR初筛分析助手。只返回JSON,不要输出Markdown。"},
{"role": "user", "content": user},
],
"response_format": {"type": "json_object"},
},
parsed = await _chat_json(
settings,
"你是企业招聘系统中的资深HR初筛分析助手。只返回JSON,不要输出Markdown。",
user,
temperature=0,
)
data = response.json()
if response.status_code >= 400:
raise RuntimeError(data.get("error", {}).get("message") or "LLM 分析失败")
parsed = parse_json_from_text(data.get("choices", [{}])[0].get("message", {}).get("content") or "{}")
return {**parsed, "reportVersion": "ai-evaluation-v2", "provider": f"llm:{settings.effective_llm_model}"}
......@@ -326,24 +341,13 @@ jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHav
- 整体语气专业、具体、可执行,让候选人读完知道"来了做什么、需要什么"。
岗位信息:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}"""
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
f"{settings.effective_llm_base_url}/chat/completions",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.effective_llm_api_key}"},
json={
"model": settings.effective_llm_model,
"temperature": 0.35,
"messages": [
{"role": "system", "content": "你是企业招聘系统中的资深招聘JD顾问。只返回JSON,不要输出Markdown。"},
{"role": "user", "content": user},
],
"response_format": {"type": "json_object"},
},
parsed = await _chat_json(
settings,
"你是企业招聘系统中的资深招聘JD顾问。只返回JSON,不要输出Markdown。",
user,
temperature=0.35,
)
data = response.json()
if response.status_code >= 400:
raise RuntimeError(data.get("error", {}).get("message") or "JD 生成失败")
return {**parse_json_from_text(data.get("choices", [{}])[0].get("message", {}).get("content") or "{}"), "provider": f"llm:{settings.effective_llm_model}"}
return {**parsed, "provider": f"llm:{settings.effective_llm_model}"}
# 对内字段:用于内部访谈/寻源,严禁随岗位信息发送到对外 LLM 或写进对外 JD。
......
......@@ -16,6 +16,8 @@ dependencies = [
"pydantic-settings>=2.4,<3",
"python-multipart>=0.0.9,<1",
"httpx>=0.27,<1",
"langchain-core>=1.0,<2",
"langchain-openai>=1.0,<2",
"pypdf>=5.1,<6",
"python-docx>=1.1,<2"
]
......
......@@ -95,4 +95,6 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__":
import sys
sys.argv.append("--reload")
raise SystemExit(main())
......@@ -112,6 +112,58 @@ def test_grill_start_and_advance(client):
assert state["progress"]["percent"] == 100
def test_grill_sales_role_uses_business_options(client):
"""非技术岗(销售经理)不出现技术向推荐答案,试用期/排除信号用业务岗口径。"""
start = client.post(
"/api/jd/grill/start",
json={"job": {"title": "销售经理", "department": "市场部"}, "tech_role": False},
)
assert start.status_code == 200
s = start.json()
assert s["total_steps"] == 13
assert "tech_stack" not in s["question_ids"]
answers = {
"entry_path": "new",
"input_form": "one-sentence",
"internal_digest": "must-hire",
"positioning": "backfill",
"responsibilities": "sales",
"hard_gate": "bachelor-plus",
"sensitive": "none",
"salary_location": "shenzhen-onsite",
"lifeline": "business-lifeline",
"verification": "resume+case",
"knockout": "no-proof",
"probation": "3m-first-deal",
"noncompete": "no",
}
state = s
asked = []
while not state.get("complete"):
qid = state["next"]["question_id"]
asked.append(qid)
option_values = [opt["value"] for opt in state["next"]["options"]]
if qid == "knockout":
assert "crud" not in option_values
assert "no-proof" in option_values
if qid == "probation":
assert "3m-iterate" not in option_values
assert "3m-first-deal" in option_values
resp = client.post(
"/api/jd/grill/advance",
json={"state": state, "answer": {"question_id": qid, "type": "option", "option": answers[qid]}},
)
assert resp.status_code == 200
state = resp.json()
assert state["complete"] is True
assert len(asked) == 13
assert "tech_stack" not in asked
assert state["job"]["knockout"] == "客户资源或线索多,但缺少实际成交、回款与复购证据"
assert state["job"]["probationCriteria"] == "独立跟进并成交首个目标客户,或跑通首个项目交付"
def test_grill_skips_existing_fields(client):
"""老岗位迭代:已有 jd/mustHave/knockout/education 的题自动跳过,只问变化点。"""
start = client.post(
......
......@@ -23,6 +23,7 @@ def test_state_api_roundtrip(client):
loaded = client.get("/api/state").json()
assert loaded["meta"]["persisted"] is True
assert loaded["jobs"][0]["title"] == "高级前端工程师"
assert loaded["jobs"][0]["createdAt"]
assert loaded["candidates"][0]["name"] == "张三"
assert loaded["tasks"][0]["title"] == "安排面试"
......
......@@ -92,10 +92,12 @@ const isStaleCandidate = (candidate) => flow.value.staleCandidates.some((item) =
const hasJdContent = computed(() =>
Boolean(job.value?.jd || job.value?.responsibilities || job.value?.requirements || job.value?.mustHave)
)
const jdPendingConfirm = computed(() => [JD_STATUS.DRAFT, JD_STATUS.PENDING_CONFIRM].includes(job.value?.jdStatus))
const jdConfirmed = computed(() => job.value?.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED)
const jdGenerationBusy = computed(() => Boolean(store.jdGenerateOpen || store.jdGrillOpen))
const generateJdLabel = computed(() => (hasJdContent.value ? '重新生成草稿' : '生成首版 JD 草稿'))
const jdActionHint = computed(() => {
if (!hasJdContent.value) return '还没有 JD:先生成首版草稿,再进入用人确认。'
if (!hasJdContent.value) return '还没有 JD:顶部主按钮会直接生成首版草稿,也可以在这里手动调整后再生成。'
return '已有 JD:小幅调整岗位要求用“迭代”;需要按当前全部信息重写时用“重新生成”。两者都会生成新的待确认草稿。'
})
......@@ -153,7 +155,7 @@ const submitApproval = async () => {
ensureTask('跟进JD审批结果', '岗位管理', job.value.title, '中', '今日')
store.persist('JD已提交审批')
showToast('JD 已提交审批,已生成跟进待办')
activeStep.value = 2
activeStep.value = 3
}
const confirmMatchRules = () => {
......@@ -166,6 +168,11 @@ const confirmMatchRules = () => {
const approveJd = async () => {
if (!job.value) return
if (job.value.jdStatus !== JD_STATUS.APPROVING) {
showToast('请先在「用人确认」提交审批', 'warning')
activeStep.value = 2
return
}
try {
await ElMessageBox.confirm('审批通过后 JD 将正式生效,并进入发布流程。确认生效?', 'JD 生效', {
confirmButtonText: '确认生效',
......@@ -365,6 +372,10 @@ const mainAction = async () => {
const tab = action.value.tab
if (tab === 'flow') {
activeTab.value = 'flow'
if (!hasJdContent.value) {
await generateJd('generate')
return
}
if (Number.isInteger(action.value.step)) activeStep.value = action.value.step
await nextTick()
document.querySelector('.workflow-panel')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
......@@ -689,24 +700,23 @@ const mainAction = async () => {
</div>
<div class="step-pane-actions">
<el-button
v-if="job.jdStatus === JD_STATUS.PENDING_CONFIRM"
v-if="jdPendingConfirm"
size="small"
type="primary"
:disabled="!hasJdContent"
@click="confirmOwner"
>
确认 JD 内容
</el-button>
<el-button
v-if="job.jdStatus !== JD_STATUS.APPROVING"
size="small"
:disabled="job.approvalStatus !== APPROVAL_STATUS.DEPARTMENT_CONFIRMED"
@click="submitApproval"
>
<el-button v-if="jdPendingConfirm" size="small" :disabled="!jdConfirmed" @click="submitApproval">
提交审批
</el-button>
<el-tag v-if="job.jdStatus === JD_STATUS.APPROVING" type="warning" size="small">审批进行中</el-tag>
</div>
</div>
<div v-if="jdPendingConfirm && !jdConfirmed" class="step-action-hint">
{{ hasJdContent ? '先点击「确认 JD 内容」,确认后即可提交审批。' : '先生成 JD 草稿,再回来确认内容。' }}
</div>
<div class="step-fields">
<div>
<span>审批状态</span><strong>{{ job.approvalStatus || APPROVAL_STATUS.NOT_STARTED }}</strong>
......@@ -728,13 +738,20 @@ const mainAction = async () => {
<span>审批通过后 JD 生效,并同步招聘渠道</span>
</div>
<div class="step-pane-actions">
<el-button v-if="job.jdStatus !== JD_STATUS.EFFECTIVE" size="small" type="primary" @click="approveJd"
<el-button v-if="job.jdStatus === JD_STATUS.APPROVING" size="small" type="primary" @click="approveJd"
>审批通过并生效</el-button
>
<el-tag v-if="job.jdStatus === JD_STATUS.EFFECTIVE" size="small" type="success">JD 已生效</el-tag>
<el-button size="small" @click="openChannelDialog">同步发布渠道</el-button>
<el-button size="small" :disabled="!hasJdContent" @click="openJdMarkdown">查看 Markdown</el-button>
</div>
</div>
<div
v-if="job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE"
class="step-action-hint"
>
先在「用人确认」确认 JD 内容并提交审批,审批中才能在这里通过并生效。
</div>
<div class="step-fields">
<div>
<span>JD 状态</span><strong>{{ job.jdStatus || JD_STATUS.DRAFT }}</strong>
......@@ -811,10 +828,14 @@ const mainAction = async () => {
</div>
<div class="actions">
<el-button @click="openChannelDialog">同步发布渠道</el-button>
<el-button v-if="job.jdStatus !== JD_STATUS.EFFECTIVE" type="primary" @click="approveJd"
<el-button v-if="job.jdStatus === JD_STATUS.APPROVING" type="primary" @click="approveJd"
>审批通过并生效</el-button
>
<el-tag v-if="job.jdStatus === JD_STATUS.EFFECTIVE" type="success">JD 已生效</el-tag>
</div>
<p v-if="job.jdStatus !== JD_STATUS.APPROVING && job.jdStatus !== JD_STATUS.EFFECTIVE" class="publish-hint">
先在「JD 流程」完成用人确认并提交审批,才能在这里审批生效。
</p>
</div>
<div class="publish-box">
......@@ -1319,6 +1340,12 @@ const mainAction = async () => {
}
}
.step-action-hint {
margin: -4px 0 12px;
color: var(--muted);
font-size: 12px;
}
.step-fields {
display: grid;
grid-template-columns: repeat(2, 1fr);
......@@ -1511,6 +1538,12 @@ const mainAction = async () => {
.actions {
margin-top: 12px;
.publish-hint {
margin: 10px 0 0;
color: var(--muted);
font-size: 12px;
}
}
}
......
......@@ -40,7 +40,7 @@ const form = reactive({
owner: 'Charie',
salaryRange: '',
priority: '中',
status: '招聘中',
status: JOB_STATUS.PENDING_CONFIRM,
jdStatus: '草稿',
jdVersion: 'v1 草稿',
jd: '',
......@@ -211,6 +211,7 @@ const submit = async (saveMode = 'update') => {
const nextJob = normalizeJob({
...(existing.value || {}),
id: existing.value?.id || uid('job'),
createdAt: existing.value?.createdAt || new Date().toISOString(),
title: form.title,
department: form.department,
category: form.category || '默认岗位',
......
......@@ -104,6 +104,20 @@ const openingsFor = (job) => {
return diff > 0 ? `还差 ${diff} 人` : '目标已完成'
}
const formatCreatedAt = (value) => {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime())
? '—'
: date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// 主按钮:进入岗位详情并定位到对应 Tab
const openDetail = (job, tab = 'overview') => {
router.push({ path: `/jobs/${job.id}`, query: tab && tab !== 'overview' ? { tab } : {} })
......@@ -133,6 +147,7 @@ const duplicateJob = (job) => {
...job,
id: uid('job'),
title: `${job.title}(副本)`,
createdAt: new Date().toISOString(),
status: '草稿',
jdStatus: JD_STATUS.DRAFT,
approvalStatus: '未发起',
......@@ -285,6 +300,7 @@ const deleteJob = async (job) => {
{{ jobStateBadge(`JD · ${job.jdStatus || '草稿'}`).text }}
</span>
</div>
<div class="job-created-at">创建于 {{ formatCreatedAt(job.createdAt) }}</div>
</div>
<div class="job-list-progress">
<div>
......@@ -563,6 +579,12 @@ const deleteJob = async (job) => {
display: flex;
gap: 6px;
}
.job-created-at {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
}
.job-state-badge {
......
import { extractKeywords } from './keywords.js'
import { jobCandidates } from './links.js'
import { analysisNeedsRefresh, quickMatchNeedsRefresh } from './analysis.js'
import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS, MATCH_RULE_STATUS } from './jobStatus.js'
import { JOB_STATUS, JD_STATUS, APPROVAL_STATUS, CHANNEL_STATUS } from './jobStatus.js'
export function buildJobCriteria(job = {}) {
const split = (value) =>
......@@ -66,6 +66,10 @@ export function jobPriority(job = {}) {
return job.priority === '高' ? 1 : 0
}
export function hasJdContent(job = {}) {
return Boolean(job.jd || job.responsibilities || job.requirements || job.mustHave)
}
export function jobActionFor(job = {}) {
if (job.status === JOB_STATUS.PAUSED) {
return {
......@@ -83,22 +87,28 @@ export function jobActionFor(job = {}) {
tab: 'overview',
}
}
if (!job.jd || job.jdStatus === JD_STATUS.DRAFT) {
if (!hasJdContent(job)) {
return {
title: '补齐岗位说明',
description: '先明确职责与要求,后续发布和匹配才会更准确。',
label: '完善 JD',
title: '生成 JD 草稿',
description: '先生成首版岗位说明和招聘标准,再进入用人确认。',
label: '生成 JD 草稿',
tab: 'flow',
step: 1,
}
}
if ([JD_STATUS.PENDING_CONFIRM, JD_STATUS.APPROVING].includes(job.jdStatus)) {
if ([JD_STATUS.DRAFT, JD_STATUS.PENDING_CONFIRM, JD_STATUS.APPROVING].includes(job.jdStatus)) {
const approving = job.jdStatus === JD_STATUS.APPROVING
const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
return {
title: '确认 JD 后发布',
description: '岗位说明已准备好,先确认内容,再提交审批并进入发布。',
label: job.jdStatus === JD_STATUS.PENDING_CONFIRM ? '去确认 JD' : '查看审批',
title: approving ? '确认 JD 生效' : confirmed ? '提交 JD 审批' : '确认 JD 后发布',
description: approving
? 'JD 已提交审批,审批通过后正式生效并进入发布。'
: confirmed
? '用人部门已确认 JD,下一步提交审批。'
: '草稿已生成,先确认内容,再提交审批并进入发布。',
label: approving ? '去确认生效' : confirmed ? '提交审批' : '去确认 JD',
tab: 'flow',
step: 2,
step: approving ? 3 : 2,
}
}
if (job.jdStatus === JD_STATUS.EFFECTIVE && job.channelStatus && job.channelStatus !== CHANNEL_STATUS.SYNCED) {
......@@ -164,41 +174,53 @@ export function jobWorkflow(job = {}, candidates = []) {
? 4
: 3
: status === JD_STATUS.APPROVING
? 2
? 3
: status === JD_STATUS.PENDING_CONFIRM
? 2
: status === JD_STATUS.DRAFT
? 1
? hasJdContent(job)
? 2
: 1
: 0
const next =
status === JD_STATUS.DRAFT
? { title: '完善 JD 草稿', desc: '先生成并检查岗位说明,再交给用人部门确认。', action: '生成 / 编辑 JD' }
: status === JD_STATUS.PENDING_CONFIRM
? {
let next
if (!hasJdContent(job)) {
next = { title: '生成 JD 草稿', desc: '先生成岗位说明,再交给用人部门确认。', action: '生成 JD 草稿' }
} else if (status === JD_STATUS.DRAFT) {
const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
next = confirmed
? { title: '提交 JD 审批', desc: '用人部门已确认 JD,下一步提交审批。', action: '提交审批' }
: { title: '用人部门确认 JD', desc: '草稿已生成,先确认内容,再提交审批。', action: '确认 JD 内容' }
} else if (status === JD_STATUS.PENDING_CONFIRM) {
const confirmed = job.approvalStatus === APPROVAL_STATUS.DEPARTMENT_CONFIRMED
next = confirmed
? { title: '提交 JD 审批', desc: '用人部门已确认 JD,下一步提交审批。', action: '提交审批' }
: {
title: '用人部门确认 JD',
desc: '确认岗位说明、职责和硬性条件,确认后才能提交审批。',
action: '确认 JD 内容',
}
: status === JD_STATUS.APPROVING
? { title: '跟进 JD 审批', desc: 'JD 已提交审批,审批通过后才能正式生效。', action: '查看审批状态' }
: status !== JD_STATUS.EFFECTIVE
? { title: '确认 JD 生效', desc: 'JD 未生效前,简历匹配只能作为草稿依据。', action: '确认生效' }
: staleCandidates.length
? {
} else if (status === JD_STATUS.APPROVING) {
next = { title: '确认 JD 生效', desc: 'JD 已提交审批,审批通过后正式生效并进入发布。', action: '去确认生效' }
} else if (status !== JD_STATUS.EFFECTIVE) {
next = { title: '确认 JD 生效', desc: 'JD 未生效前,简历匹配只能作为草稿依据。', action: '确认生效' }
} else if (staleCandidates.length) {
next = {
title: '重评关联候选人',
desc: `${staleCandidates.length} 位候选人的匹配/AI评估基于旧 JD,需要刷新。`,
action: '批量重新匹配 / AI重评',
}
: related.length
? {
} else if (related.length) {
next = {
title: '继续推进候选人',
desc: `当前岗位已关联 ${related.length} 位候选人,可从简历库推进面试或 Offer。`,
action: '查看候选人',
}
: {
} else {
next = {
title: '补充候选人',
desc: 'JD 已生效但暂无关联候选人,下一步上传简历或从渠道导入。',
action: '上传简历',
}
}
return { related, staleCandidates, steps, currentIndex, next }
}
......@@ -114,6 +114,7 @@ export function normalizeJob(job = {}) {
}))
return {
category: '默认岗位',
createdAt: job.createdAt || job.created_at || '',
salaryRange: '',
jdVersion: job.jdStatus === '已生效' ? 'v1 当前生效' : 'v1 草稿',
approvalStatus: '未发起',
......
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