import request from "@/utils/request"; import { downloadBlobResponse } from "@/utils/download"; import { adaptContractFlowSignNodes, contractFlowSignStatusLabel, contractSigningStatusLabel, contractStatusLabel, pickFlowSignStatus, } from "./contractStatus"; function unwrap(response) { const result = response?.data || {}; if (Number(result.code) === 0) return result.data; const error = new Error(result.message || "服务调用失败"); if (response?.__toastShown) error.__toastShown = true; return Promise.reject(error); } /** * 推荐培训(岗位层级配置 trainings):接口契约为 [{ trainingId, trainingName }],两者都必填。 * - toTrainingIds:回显用,下拉 value 是字符串化的 id; * - toTrainingNameMap:id → 名称快照,提交时按接口要求带上 trainingName。 */ function toTrainingIds(trainings) { return (Array.isArray(trainings) ? trainings : []) .map((item) => (item && typeof item === "object" ? (item.trainingId ?? item.id) : item)) .filter((value) => value !== null && value !== undefined && value !== "") .map((value) => String(value)); } function toTrainingNameMap(trainings) { return (Array.isArray(trainings) ? trainings : []).reduce((map, item) => { const id = item?.trainingId ?? item?.id; const name = String(item?.trainingName ?? "").trim(); if (id !== null && id !== undefined && id !== "" && name) { map[String(id)] = name; } return map; }, {}); } function compact(object) { return Object.keys(object).reduce((result, key) => { const value = object[key]; if (value !== "" && value !== null && value !== undefined) result[key] = value; return result; }, {}); } /** 大整数主键保持字符串,避免 Number 精度丢失(如雪花 ID) */ function keepBigIntId(value) { if (value === undefined || value === null || value === "") return undefined; const text = String(value).trim(); if (!/^-?\d+$/.test(text)) return value; // 超过安全整数必须用字符串提交,否则 1457 → 1500 if (!Number.isSafeInteger(Number(text))) return text; return Number(text); } /* 文件流下载统一走 utils/download 的 downloadBlobResponse: 文件名解码(filename= 里的 percent-encoding 不 decode 会乱码)+ JSON 业务错误拦截 */ const STATUS_TO_API = { 1: 1, 2: 0, 3: 2 }; const STATUS_FROM_API = { 0: 2, 1: 1, 2: 3 }; const SEQUENCE_CODES = { 管理: "M", 管理序列: "M", 专业: "P", 专业序列: "P", 技术: "T", 技术序列: "T", 操作: "O", 操作序列: "O", 运营: "O", 运营序列: "O", 营销: "S", 营销序列: "S", }; const SEQUENCE_NAMES = { M: "管理", P: "专业", T: "技术", O: "操作", S: "营销", }; export function sequenceCode(value = "") { const text = String(value).trim(); return ( SEQUENCE_CODES[text] || (/^[A-Za-z]/.test(text) ? text.charAt(0).toUpperCase() : text) ); } export function sequenceName(value = "") { const code = sequenceCode(value); return SEQUENCE_NAMES[code] || String(value).replace(/序列$/, "") || code; } function levelNumber(value, sequence = "") { const text = String(value ?? ""); const number = Number(text.match(/\d+/)?.[0]); if (number) return number; const chinese = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10, }; const matched = Object.keys(chinese).find((key) => text.includes(key)); return matched ? chinese[matched] : 1; } function apiLevel(value, sequence = "") { const code = sequenceCode(sequence); const text = String(value ?? "").trim(); if (/^[A-Za-z]+\d+$/i.test(text)) return text.toUpperCase(); return `${code}${levelNumber(text, code)}`; } /** 汇总岗位上的层级名字(positionLevels / positionLevel / levelConfigs.positionLevel) */ function collectPositionLevelNames(raw = {}) { const names = []; const add = (value) => { const text = String(value || "").trim(); if (text && !names.includes(text)) names.push(text); }; if (Array.isArray(raw.positionLevels)) { raw.positionLevels.forEach(add); } add(raw.positionLevel); if (Array.isArray(raw.levelConfigs)) { raw.levelConfigs.forEach((cfg) => add(cfg?.positionLevel)); } return names; } /** * 汇总岗位可选层级对象 [{id, name}] * id 从 levelConfigs[i].id / positionLevels[i].id 取,name 同 collectPositionLevelNames 规则 * 用于岗位序列/层级联动选择,提交时把选中的 id 作为 positionLevelId 传给后端 */ function collectPositionLevels(raw = {}) { const result = []; const seen = new Set(); const push = (id, name) => { const text = String(name || "").trim(); if (!text) return; const idText = id != null && id !== "" ? String(id) : ""; const key = `${idText}|${text}`; if (seen.has(key)) return; seen.add(key); result.push({ id: idText, name: text }); }; if (Array.isArray(raw.levelConfigs)) { raw.levelConfigs.forEach((cfg) => push(cfg?.id, cfg?.positionLevel)); } if (Array.isArray(raw.positionLevels)) { raw.positionLevels.forEach((item) => { if (item && typeof item === "object") push(item.id, item.positionLevel || item.level || item.name); else push(null, item); }); } if (raw.positionLevel) push(raw.levelConfigId ?? null, raw.positionLevel); return result; } /** * 职责文本 → 多条数组:按换行或分号拆分,**行内内容原样保留**。 * ★ 2026-10-28 去掉原先「剥掉行首序号」的那条正则(匹配 "开头空白 + 数字 + 顿号/点" 并删除): * 它会把用户手写的「1、初级职责」剥成「初级职责」,回填到弹框再保存就永久丢序号 * (提交和接口返回都是「1、初级职责」,页面却只剩「初级职责」)。 * 前端不做内容清洗,序号属于用户原文。 */ function splitText(value) { if (Array.isArray(value)) return value.filter(Boolean); return String(value || "") .split(/\r?\n|[;;]/) .map((item) => item.trim()) .filter(Boolean); } // 岗位序列层级配置 → 弹窗每层级字段 function adaptLevelConfig(cfg = {}) { const salary = cfg.salary || {}; // 后端 VO 的 promotions 既带 name 也带 id;前端 path 同时存 name 与 id, // 避免保存时再去 options 里按 name 二次解析(options 接口偶发漏项) const paths = (cfg.promotions || []) .map((item) => { const seqLevelName = (seq, lvl) => [seq, lvl].filter(Boolean).join(" · ") || ""; return { up: item.upwardPromotionPositionName || seqLevelName(item.upwardPromotionPositionSequence, item.upwardPromotionPositionLevel), upId: item.upwardPromotionPositionId ?? null, down: item.downwardDemotionPositionName || seqLevelName(item.downwardDemotionPositionSequence, item.downwardDemotionPositionLevel), downId: item.downwardDemotionPositionId ?? null, lateral: item.lateralRotationPositionName || seqLevelName(item.lateralRotationPositionSequence, item.lateralRotationPositionLevel), lateralId: item.lateralRotationPositionId ?? null, }; }) .filter((path) => path.up || path.down || path.lateral); const conditions = [ ...new Set( (cfg.promotions || []) .flatMap((item) => (item.conditions || []).map((condition) => condition.conditionName), ) .filter(Boolean), ), ]; const duties = splitText(salary.dutyContent); return { level: cfg.positionLevel || "", levelId: cfg.positionLevelId ?? null, salaryMin: Number(salary.salaryMin ?? 0), salaryMid: Number(salary.salaryMid ?? 0), salaryMax: Number(salary.salaryMax ?? 0), salaryTemplateId: salary.salaryTemplate || "", perfSchemeId: salary.performanceScheme || "", eduReq: salary.educationRequirement || "", genderReq: salary.genderRequirement || "不限", expReq: Number(salary.experienceYears ?? 0), ageRange: [Number(salary.ageMin ?? 18), Number(salary.ageMax ?? 60)], effectDate: salary.birthDateRequirement || "", workEnv: salary.workEnvironment || "", workShift: salary.workShift || "", skills: (cfg.skills || []).map((item) => ({ ...item, name: item.skillName || "", level: item.skillLevel || "", required: String(item.essentialStatus) === "1", matchRate: Number(item.matchRate ?? 0), code: item.skillCode || "", skillId: item.skillId, certOrg: item.certOrg || "", certRequired: Number(item.certRequired || 0), certificateId: item.certificateId, })), certificates: (cfg.certificates || []).map((item) => ({ ...item, name: item.certificateName || "", category: item.certificateType || "", certificateId: item.certificateId, })), // 推荐培训:接口按 trainingId/trainingName 回显(两者都必填),同 levelConfigs 口径 trainings: toTrainingIds(cfg.trainings), trainingNameById: toTrainingNameMap(cfg.trainings), promote: { paths: paths.length ? paths : [{ up: "", down: "", lateral: "" }], conditions, }, duties, /* 回填用原始 dutyContent(未经拆分/去分隔符),保证弹框里看到的和接口返回一字不差 */ dutyText: salary.dutyContent || duties.join("\n"), }; } export function adaptPosition(raw = {}) { const salary = raw.salary || {}; const relation = raw.departmentPosition || {}; const seq = sequenceCode(raw.positionSequence || raw.sequenceName); const promotions = raw.promotions || []; // 晋升路径改为多条:每条包含向上/向下/平级流动层级 // 同时保留后端返回的 *PositionId,保存时优先复用避免 options 二次解析失败 const paths = promotions .map((item) => { const seqLevelName = (seqField, lvlField, nameField) => nameField || [seqField, lvlField].filter(Boolean).join(" · ") || ""; return { up: seqLevelName(item.upwardPromotionPositionSequence, item.upwardPromotionPositionLevel, item.upwardPromotionPositionName), upId: item.upwardPromotionPositionId ?? null, down: seqLevelName(item.downwardDemotionPositionSequence, item.downwardDemotionPositionLevel, item.downwardDemotionPositionName), downId: item.downwardDemotionPositionId ?? null, lateral: seqLevelName(item.lateralRotationPositionSequence, item.lateralRotationPositionLevel, item.lateralRotationPositionName), lateralId: item.lateralRotationPositionId ?? null, }; }) .filter((path) => path.up || path.down || path.lateral); // 编辑时至少保留一条空路径(同时带 id 结构,避免 buildLevelConfig 缺字段) const pathsForForm = paths.length ? paths : [{ up: "", upId: null, down: "", downId: null, lateral: "", lateralId: null }]; const up = paths.map((item) => item.up).filter(Boolean); const down = paths.map((item) => item.down).filter(Boolean); const lateral = paths.map((item) => item.lateral).filter(Boolean); // 公共条件:优先取顶层 conditions,其次兼容旧的 promotions[].conditions const conditions = [ ...new Set( (Array.isArray(raw.conditions) ? raw.conditions : []) .map((condition) => typeof condition === "string" ? condition : condition.conditionName, ) .concat( promotions.flatMap((item) => (item.conditions || []).map( (condition) => condition.conditionName, ), ), ) .filter(Boolean), ), ]; const version = raw.documentVersions?.[0]?.versionNo || 1; // 更新/删除主键必须用 page、getById 返回的 id,且始终保持字符串防精度丢失 const primaryId = raw.id != null && raw.id !== "" ? raw.id : raw.positionId; const primaryIdText = primaryId == null || primaryId === "" ? primaryId : String(primaryId); return { ...raw, id: primaryIdText, positionId: raw.positionId != null && raw.positionId !== "" ? String(raw.positionId) : primaryIdText, code: raw.positionCode || "", positionCode: raw.positionCode || "", name: raw.positionName || "", legalId: raw.organizationId, legalName: raw.organizationName || "", deptId: raw.deptId, deptName: raw.deptName || "", seq, // 历史单层级字段;完整集合见 positionLevels(一岗可配置多层级,值为层级名字) level: apiLevel(raw.positionLevel, seq), positionLevel: raw.positionLevel || "", positionLevels: collectPositionLevelNames(raw), positionLevelOptions: collectPositionLevels(raw), positionSequence: raw.positionSequence || raw.sequenceName || "", sequenceId: raw.sequenceId || relation.sequenceId, positionSequenceId: raw.sequenceId || relation.sequenceId, type: raw.positionType || "", positionType: raw.positionType || "", staffing: Number( raw.establishmentCount ?? relation.establishmentCount ?? 0, ), actual: Number(raw.occupancyCount ?? relation.occupancyCount ?? 0), salaryMin: Number(salary.salaryMin ?? raw.salaryMin ?? 0), salaryMid: Number(salary.salaryMid ?? 0), salaryMax: Number(salary.salaryMax ?? raw.salaryMax ?? 0), salaryTemplateId: salary.salaryTemplate || "", perfSchemeId: salary.performanceScheme || "", status: STATUS_FROM_API[Number(raw.status)] || 2, revokeDate: raw.revokeDate || "", workShift: salary.workShift || "", workEnv: salary.workEnvironment || "", ageRange: [Number(salary.ageMin ?? 18), Number(salary.ageMax ?? 60)], genderReq: salary.genderRequirement || "不限", eduReq: salary.educationRequirement || "", expReq: Number(salary.experienceYears ?? 0), effectDate: salary.birthDateRequirement || "", version, duties: splitText(salary.dutyContent), /* 职责任务弹框回填直接用原始文本:splitText 会按「;」拆条, 拿 join("\n") 回填相当于擅自把用户的分号改成换行(2026-10-28) */ dutyText: salary.dutyContent || "", skills: (raw.skills || []).map((item) => ({ ...item, name: item.skillName || "", level: item.skillLevel || "", required: String(item.essentialStatus) === "1", matchRate: Number(item.matchRate ?? 0), code: item.skillCode || "", certOrg: item.certOrg || "", certRequired: Number(item.certRequired || 0), })), certificates: (raw.certificates || []).map((item) => ({ ...item, name: item.certificateName || "", category: item.certificateType || "", })), // 推荐培训:接口按 trainingId/trainingName 返回(两者都必填),下拉 value 用 id, // 名称另存 trainingNameById,提交时按接口要求带 trainingName(名称快照) trainings: toTrainingIds(raw.trainings), trainingNameById: toTrainingNameMap(raw.trainings), promote: { paths: pathsForForm, up, down, lateral, conditions }, promotions, employees: (raw.activeEmployees || []).map((item) => ({ ...item, entryDate: item.joinDate || "", performance: item.performance || "-", skillRate: Number(item.skillRate ?? 0), })), history: (raw.changeHistory || []).map((item) => ({ ...item, time: item.createTime || "", title: item.changeTitle || item.changeTypeName || "岗位变更", content: item.changeSummary || "", })), updatedAt: String(raw.lastChangeTime || raw.createTime || "").slice(0, 10), // 岗位序列下每层级的独立配置(接口 levelConfigs) levels: (Array.isArray(raw.levelConfigs) ? raw.levelConfigs : []).map( (cfg) => adaptLevelConfig(cfg), ), }; } // 从层级配置或表单顶层字段解析岗位职责文本(dutyContent 必填,允许空串) function resolveDutyContent(level = {}, fallback = "") { const normalize = (text) => String(text || "") .split("\n") .map((item) => item.trim()) .filter(Boolean) .join("\n"); const fromText = normalize(level.dutyText); if (fromText) return fromText; if (Array.isArray(level.duties) && level.duties.length) { const fromDuties = level.duties.filter(Boolean).join("\n"); if (fromDuties) return fromDuties; } return normalize(fallback); } // 弹窗每层级字段 → 接口「岗位序列层级配置参数」 // 文档:positionLevel、positionSequence、salary(含 dutyContent) 必填 function buildLevelConfig(level = {}, positionList = [], positionSequence = "") { // ===== 晋升路径暂时下线(2026-09-29):不再向接口提交 promotions 参数,恢复时取消本段注释 ===== // const targetId = (name) => // keepBigIntId(positionTarget(positionList, name)?.id) || null; // const conditions = (level.promote?.conditions || []) // .map((conditionName) => // typeof conditionName === "string" // ? conditionName // : conditionName?.conditionName, // ) // .filter(Boolean) // .map((conditionName) => ({ conditionName })); // // 优先用 path 上已有的 id(adaptLevelConfig 从后端 VO 保留), // // 找不到再去 options 里按 name 二次解析(兼容手动编辑 / 旧数据) // const resolveTargetId = (pathId, pathName) => // keepBigIntId(pathId) || keepBigIntId(positionTarget(positionList, pathName)?.id) || null; // let promotions = (level.promote?.paths || []) // .map((path) => // compact({ // upwardPromotionPositionId: resolveTargetId(path.upId, path.up), // upwardPromotionPositionName: path?.up, // downwardDemotionPositionId: resolveTargetId(path.downId, path.down), // downwardDemotionPositionName: path?.down, // lateralRotationPositionId: resolveTargetId(path.lateralId, path.lateral), // lateralRotationPositionName: path?.lateral, // conditions, // }), // ) // .filter( // (item) => // item.upwardPromotionPositionName || // item.downwardDemotionPositionName || // item.lateralRotationPositionName, // ); // // 无路径但有条件时仍提交一条,避免晋升条件丢失 // if (!promotions.length && conditions.length) { // promotions = [{ conditions }]; // } // ===== 晋升路径暂时下线结束 ===== const salary = compact({ salaryMin: Number(level.salaryMin || 0), salaryMid: Number(level.salaryMid || 0), salaryMax: Number(level.salaryMax || 0), salaryTemplate: String(level.salaryTemplateId || ""), // 绩效方案:表单已临时隐藏,暂不提交该字段;恢复时取消下一行注释 // performanceScheme: String(level.perfSchemeId || ""), ageMin: Number(level.ageRange?.[0] ?? 18), ageMax: Number(level.ageRange?.[1] ?? 60), birthDateRequirement: level.effectDate || undefined, educationRequirement: level.eduReq || "", experienceYears: Number(level.expReq || 0), genderRequirement: level.genderReq || "不限", workEnvironment: level.workEnv || "", workShift: level.workShift || "", industryExperience: level.industryExperience || "", professionalAbility: level.professionalAbility || "", otherRequirement: level.otherRequirement || "", }); // dutyContent 为必填字段,即使空串也必须提交(不可被 compact 去掉) salary.dutyContent = resolveDutyContent(level, level.fallbackDutyContent); const skills = (level.skills || []) .filter((item) => item.name) .map((item, index) => compact({ skillId: item.skillId || undefined, // skillCode: item.code || item.skillCode || `SKILL-${index + 1}`, skillName: item.name, skillLevel: item.level || item.skillLevel || "", // 文档:1-必备、0-非必备、2-加分 essentialStatus: item.required ? "1" : "0", certRequired: Number(item.certRequired || 0), certOrg: item.certOrg || "", certificateId: item.certificateId || undefined, coreSkill: item.coreSkill, skillWeight: item.skillWeight, affectsPostEligibility: item.affectsPostEligibility, minimumGuaranteeCount: item.minimumGuaranteeCount, }), ); const certificates = (level.certificates || []) .filter((item) => item.name) .map((item, index) => compact({ certificateId: item.certificateId || undefined, certificateName: item.name, certificateType: item.category || item.certificateType || "", sortNo: index + 1, }), ); /* 推荐培训:接口要求 trainingId + trainingName 同时必填(保存时固化名称快照)。 名称取表单里随下拉选项/回显数据维护的 trainingNameById,取不到就不提交名称(不编造, 由后端按缺必填项报错暴露问题)。 */ const trainingNameById = level.trainingNameById || {}; const trainings = (level.trainings || []) .map((item) => item && typeof item === "object" ? (item.trainingId ?? item.id) : item, ) .map((value) => keepBigIntId(value)) .filter((value) => value !== undefined && value !== null && value !== "") .map((trainingId) => ({ trainingId, trainingName: String(trainingNameById[String(trainingId)] ?? "").trim() || undefined, })); const sequenceText = String( positionSequence || level.positionSequence || "", ).trim(); const config = { positionLevel: level.level || level.positionLevel || "", salary, }; // 层级主键:关联 t_hr_position_sequence_level.id(文档「岗位序列层级配置」positionLevelId) const levelId = keepBigIntId(level.levelId); if (levelId) config.positionLevelId = levelId; // 层级配置同样需要岗位序列,否则后端会报 positionSequence is required if (sequenceText) config.positionSequence = sequenceText; if (skills.length) config.skills = skills; if (certificates.length) config.certificates = certificates; if (trainings.length) config.trainings = trainings; // 晋升路径暂时下线,不再提交 promotions;恢复时取消下一行注释 // if (promotions.length) config.promotions = promotions; return config; } // 无 levels 时,用表单顶层字段兜底成一条层级配置(兼容旧数据) function fallbackLevelFromForm(form = {}) { return { level: form.positionLevel || apiLevel(form.level, form.seq) || "", positionLevel: form.positionLevel || apiLevel(form.level, form.seq) || "", salaryMin: form.salaryMin, salaryMid: form.salaryMid, salaryMax: form.salaryMax, salaryTemplateId: form.salaryTemplateId, perfSchemeId: form.perfSchemeId, eduReq: form.eduReq, genderReq: form.genderReq, expReq: form.expReq, ageRange: form.ageRange, effectDate: form.effectDate, workEnv: form.workEnv, workShift: form.workShift, skills: form.skills, certificates: form.certificates, trainings: form.trainings, /* 推荐培训名称快照(提交时接口要求 trainingId + trainingName 都必填) */ trainingNameById: form.trainingNameById, promote: form.promote, duties: form.duties, dutyText: form.dutyText || (Array.isArray(form.duties) ? form.duties.join("\n") : ""), }; } /** * 序列化为「完整岗位档案新增/修改参数」 * 文档约定:业务数据以 levelConfigs 为准;顶层 salary/skills/certificates/promotions 为历史兼容字段,新建修改请勿依赖 */ function serializePosition(form, positionList = []) { const levels = Array.isArray(form.levels) && form.levels.length ? form.levels : [fallbackLevelFromForm(form)]; const plannedCount = Number( form.plannedRecruitmentCount ?? form.headcount ?? 0, ); const rawId = form.id; const positionSequence = String( form.positionSequence || form.sequence || sequenceName(form.seq) || "", ).trim(); const payload = compact({ id: keepBigIntId(rawId), deptId: keepBigIntId(form.deptId), organizationId: keepBigIntId(form.legalId), positionCode: form.positionCode || form.code, positionName: form.name, positionType: form.type || "", status: STATUS_TO_API[form.status] ?? 0, // 待撤销(前端 status=3 → API=2) 必须带计划撤销日期 revokeDate: form.status === 3 ? form.revokeDate || undefined : undefined, // 文档:plannedRecruitmentCount minimum 1 plannedRecruitmentCount: plannedCount >= 1 ? plannedCount : undefined, }); // 岗位序列为必填:不可被 compact 去掉空串后导致后端报 positionSequence is required payload.positionSequence = positionSequence; // 部门岗位关联仅含 establishmentCount;档案保存不写入 Main 编制表,仅作快照可选提交 if (form.staffing !== undefined && form.staffing !== null && form.staffing !== "") { payload.departmentPosition = { establishmentCount: Number(form.staffing || 0), }; } const dutyContent = resolveDutyContent({ dutyText: form.dutyText, duties: form.duties, }); const targets = [ ...(positionList || []), ...(form.promoteTargetOptions || []), ]; payload.levelConfigs = levels.map((level) => buildLevelConfig( { ...level, dutyText: resolveDutyContent(level, dutyContent), duties: Array.isArray(level.duties) && level.duties.length ? level.duties : form.duties, fallbackDutyContent: dutyContent, }, targets, positionSequence, ), ); // 后端接收顶层 salary.dutyContent,同时与当前层级配置保持一致 payload.salary = { ...(payload.levelConfigs[0]?.salary || {}), dutyContent: payload.levelConfigs[0]?.salary?.dutyContent || dutyContent, }; return payload; } /** 晋升通道地图:List«晋升通道分组» */ // 岗位管理 export const getPositionDashboard = async () => unwrap(await request.get("/hr/position/dashboard")); export const getPositionPage = async (data = {}) => unwrap(await request.post("/hr/position/page", compact(data))); export const getPositionById = async (id) => adaptPosition( await unwrap( await request.get( `/hr/position/getById/${encodeURIComponent(String(keepBigIntId(id) ?? id))}`, ), ), ); // ========== 部门岗位关联表(/fm/tmaindepartmentposition)级联查询 ========== // deptId 入参均为入职部门 ID;响应统一 { code, data: [], message } /** 查询部门岗位序列(入职部门 → 岗位序列) */ export const getDepartmentPositionSequences = async (deptId) => unwrap( await request.get("/fm/tmaindepartmentposition/sequence/list", { params: compact({ deptId: keepBigIntId(deptId) }), }), ); /** 查询序列下岗位(入职部门 + 岗位序列 → 岗位) */ export const getDepartmentPositions = async ({ deptId, positionSequenceId }) => unwrap( await request.get("/fm/tmaindepartmentposition/position/list", { params: compact({ deptId: keepBigIntId(deptId), positionSequenceId: keepBigIntId(positionSequenceId), }), }), ); /** 查询岗位性质(入职部门 + 岗位 + 岗位序列 → 岗位性质数组,调用方取第一条) */ export const getDepartmentPositionType = async ({ deptId, positionId, positionSequenceId, }) => unwrap( await request.get("/fm/tmaindepartmentposition/positionType/list", { params: compact({ deptId: keepBigIntId(deptId), positionId: keepBigIntId(positionId), positionSequenceId: keepBigIntId(positionSequenceId), }), }), ); /** 查询岗位层级(入职部门 + 岗位 + 岗位序列 + 岗位性质编码 → 岗位层级) */ export const getDepartmentPositionLevels = async ({ deptId, positionId, positionSequenceId, positionType, }) => unwrap( await request.get("/fm/tmaindepartmentposition/positionLevel/list", { params: compact({ deptId: keepBigIntId(deptId), positionId: keepBigIntId(positionId), positionSequenceId: keepBigIntId(positionSequenceId), positionType, }), }), ); export const savePosition = async (form, list) => unwrap( await request.post("/hr/position/save", serializePosition(form, list)), ); export const updatePosition = async (form, list) => unwrap( await request.put("/hr/position/update", serializePosition(form, list)), ); export const updatePositionStatus = async (data) => unwrap( await request.put("/hr/position/updateStatus", { ...data, id: keepBigIntId(data?.id) ?? data?.id, }), ); export const deletePosition = async (id) => unwrap( await request.get( `/hr/position/delete/${encodeURIComponent(String(keepBigIntId(id) ?? id))}`, ), ); export const synchronizePositionOccupancy = async () => unwrap(await request.post("/hr/position/occupancy/synchronize")); export const getPositionChangeHistory = async (id) => unwrap( await request.get( `/hr/position/${encodeURIComponent(String(keepBigIntId(id) ?? id))}/changeHistory`, ), ); export const getPromotionTargets = async (type, params = {}) => unwrap( await request.get(`/hr/positionSequence/promotion-levels/${type}`, { params: compact(params), }), ); /** 流动岗位序列层级下拉 → 扁平选项(序列 · 层级) */ export function adaptPromotionLevelOptions(list = []) { const options = []; (Array.isArray(list) ? list : []).forEach((seq) => { const sequenceName = seq.sequence || ""; const sequenceId = seq.id; (seq.levelArrayList || []).forEach((level) => { const levelText = level.level || level.levelName || ""; if (!levelText) return; if (level.status != null && Number(level.status) === 0) return; const name = sequenceName ? `${sequenceName} · ${levelText}` : levelText; options.push({ id: level.id ?? `${sequenceId || "seq"}-${levelText}`, name, positionName: name, positionSequence: sequenceName, positionLevel: levelText, sequenceId, levelId: level.id, levelGrade: Number(level.levelGrade) || 0, }); }); }); return options; } export const generatePositionDocument = async (id) => unwrap(await request.post(`/hr/position/${id}/document`)); export async function downloadPositionDocument(id) { downloadBlobResponse( await request.get(`/hr/position/document/${id}/download`, { responseType: "blob", }), "岗位说明书.pdf", ); } export async function downloadPositionImportTemplate() { downloadBlobResponse( await request.get("/hr/position/import/template", { responseType: "blob" }), "岗位导入模板.xlsx", ); } export async function importPositions(file) { const data = new FormData(); data.append("file", file); return unwrap( await request.post("/hr/position/import", data, { headers: { "Content-Type": "multipart/form-data" }, }), ); } // 晋升(岗位晋升通道:地图 / 导出 / 申请分页;审批流由 BPM 负责,评审类接口已下线) export async function exportPromotionChannels() { downloadBlobResponse( await request.post( "/hr/promotion/channels/export", {}, { responseType: "blob" }, ), "晋升通道说明书.xlsx", ); } // 岗位序列 export const getPositionSequenceList = async (params = {}) => { // 查询岗位序列列表:支持按所属机构过滤(groupId) // 返回 List<岗位序列列表> { id, sequence, status, groupId, levelList:[{level,status}] } const list = unwrap( await request.get("/hr/positionSequence/getPositionSequenceList", { params: compact(params), }), ); return (Array.isArray(list) ? list : []).map((item) => ({ id: item.id, sequence: item.sequence || "", status: item.status, /* 序列归属机构:后端可能返回 groupId 或 legalId,供左侧按机构过滤 */ groupId: item.groupId ?? item.legalId ?? null, levelList: (item.levelList || []).map((level) => { // 仅保留数字主键;勿用层级文案(P1/T4)冒充 id,否则人员档案无法提交 positionLevelId const rawId = level.id != null ? String(level.id).trim() : ""; const id = /^-?\d+$/.test(rawId) ? rawId : ""; return { id, label: level.level || level.levelName || "", // 岗位层级文本(如 P1/T4):岗位申请 positionLevel 需为该序列启用层级文本 value: level.level || level.levelName || "", levelGrade: Number(level.levelGrade) || 0, status: level.status, }; }), })); }; export const getPositionSequenceLevels = async (sequence) => unwrap( await request.get("/hr/positionSequence/levels", { params: { sequence }, }), ); export const getPositionSequencePage = async (data = {}) => unwrap(await request.post("/hr/positionSequence/page", compact(data))); export const savePositionSequence = async (data) => unwrap(await request.post("/hr/positionSequence/save", data)); export const updatePositionSequence = async (data) => unwrap(await request.put("/hr/positionSequence/update", data)); export const isPositionSequenceInUse = async (id) => unwrap(await request.get(`/hr/positionSequence/${id}/in-use`)); export const savePositionSequenceLevels = async (data) => unwrap(await request.post("/hr/positionSequence/levels/save", data)); // 劳动合同模板 export const getContractTemplatePage = async (data = {}) => unwrap(await request.get("/hr/contracts/templates", { params: compact(data) })); export const getContractTemplateById = async (id) => unwrap(await request.get(`/hr/contracts/templates/${id}`)); export const saveContractTemplate = async (data) => unwrap(await request.post("/hr/contracts/templates", data)); export const updateContractTemplate = async (data) => unwrap(await request.put(`/hr/contracts/templates/${data.id}`, data)); export const deleteContractTemplate = async (id) => unwrap(await request.get(`/hr/contracts/templates/delete/${id}`)); export const disableContractTemplate = async (id) => unwrap(await request.post(`/hr/contracts/templates/${id}/disable`)); export const enableContractTemplate = async (id) => unwrap(await request.post(`/hr/contracts/templates/${id}/enable`)); export const submitContractTemplate = async (id) => unwrap(await request.post(`/hr/contracts/templates/${id}/submit`)); export const getContractTemplateVersions = async (id) => unwrap(await request.get(`/hr/contracts/templates/${id}/versions`)); export const createContractTemplateVersion = async (id, data) => unwrap(await request.post(`/hr/contracts/templates/${id}/versions`, data)); export { CONTRACT_FLOW_SIGN_STATUS_LABELS, CONTRACT_FLOW_SIGN_STATUS_OPTIONS, CONTRACT_SIGNING_STATUS_LABELS, CONTRACT_SIGNING_STATUS_OPTIONS, CONTRACT_STATUS_LABELS, CONTRACT_STATUS_OPTIONS, adaptContractFlowSignNodes, contractFlowSignStatusLabel, contractSigningStatusLabel, contractStatusLabel, pickFlowSignStatus, } from "./contractStatus"; // 劳动合同管理 export const getContractPage = async (data = {}) => unwrap(await request.get("/hr/contracts/page", { params: compact(data) })); /** * 导出劳动合同台账 POST /hr/contracts/export * ★ 复用分页查询的组织范围与**全部业务筛选条件**;pageNum / size 不传(文档:不限制导出范围)。 * 接口直接返回 .xlsx 文件流,故 responseType=blob + 放宽 timeout; * downloadBlob 会拦截「HTTP 200 + JSON 业务错误」,避免把错误 JSON 存成 xlsx。 * @param {{keyword?:string, contractType?:string, contractStatus?:string, departmentId?:*, legalEntityId?:*, * positionId?:*, signingMethod?:string, signingStatus?:string, startDate?:string, endDate?:string, * expiringOnly?:boolean}} params */ export const exportContractLedger = async (params = {}) => { const payload = compact({ keyword: params.keyword || undefined, contractType: params.contractType || undefined, contractStatus: params.contractStatus || undefined, departmentId: keepBigIntId(params.departmentId), legalEntityId: keepBigIntId(params.legalEntityId), positionId: keepBigIntId(params.positionId), signingMethod: params.signingMethod || undefined, signingStatus: params.signingStatus || undefined, startDate: params.startDate ? String(params.startDate).slice(0, 10) : undefined, endDate: params.endDate ? String(params.endDate).slice(0, 10) : undefined, // 仅勾选「即将到期」时才传 true(不传 = 不限制) expiringOnly: params.expiringOnly === true ? true : undefined, }); await downloadBlobResponse( await request.post("/hr/contracts/export", payload, { responseType: "blob", timeout: 120000, }), `劳动合同台账_${new Date().toISOString().slice(0, 10)}.xlsx`, ); }; /** 新建劳动合同 POST /hr/contracts/save */ export const CONTRACT_APPROVAL_BUSINESS_KEY = "hr_contract_approval"; /** 劳动合同「解除/终止」审批的业务标识(与合同审批区分开) */ export const CONTRACT_TERMINATION_APPROVAL_BUSINESS_KEY = "hr_contract_termination_approval"; /** * 劳动合同「变更」审批的业务标识(2026-10-17 后端确认): * 流程为 —— 先 POST /hr/contracts/{id}/changes 保存变更草稿(返回变更单主键), * 再以该业务标识 + **变更单主键** 调 /bpm/process-instance/create 发起审批; * 审批终态通过后才把变更字段应用到合同上。 */ export const CONTRACT_CHANGE_APPROVAL_BUSINESS_KEY = "hr_contract_change_approval"; export const REGULARIZATION_APPROVAL_BUSINESS_KEY = "main_regularization_approval"; export const saveContract = async (data) => unwrap(await request.post("/hr/contracts/save", data)); /** @deprecated 使用 saveContract,接口已改为 /hr/contracts/save */ /** 修改劳动合同草稿 PUT /hr/contracts/{id},参数同新建 + id */ export const updateContract = async (id, data) => unwrap(await request.put(`/hr/contracts/${id}`, compact({ ...data, id }))); export const getContractById = async (id) => unwrap(await request.get(`/hr/contracts/${id}`)); export const matchContractTemplate = async (data) => unwrap(await request.post("/hr/contracts/template-match", data)); export const changeContract = async (id, data) => unwrap(await request.post(`/hr/contracts/${id}/changes`, data)); /** 查询合同变更历史 GET /hr/contracts/{id}/changes(含 DRAFT 草稿,afterSnapshot 可回显变更后值) */ export const getContractChanges = async (id) => unwrap(await request.get(`/hr/contracts/${id}/changes`)); export const renewContract = async (id, data) => unwrap(await request.post(`/hr/contracts/${id}/renew`, data)); export const terminateContract = async (id, data) => unwrap(await request.post(`/hr/contracts/${id}/termination`, data)); export const archiveContractOffline = async (id, data) => unwrap(await request.post(`/hr/contracts/${id}/offline/archive`, data)); export const voidContract = async (id, data) => unwrap(await request.post(`/hr/contracts/${id}/void`, data)); /* 作废三类「未提交审批」的单据草稿(2026-10-20 契约确认): 变更草稿 / 续签草稿 / 解除终止草稿各自挂在原合同下,id 是原合同主键,第二个路径参数是草稿主键。 ★ 变更、解除的作废接口只有路径参数、无请求体;续签作废收「劳动合同作废参数」{ reason }。 ★ 不要再用 /hr/contracts/{id}/void 传草稿主键顶替合同主键。 */ export const voidContractChange = async (id, changeId) => unwrap(await request.post(`/hr/contracts/${id}/changes/${changeId}/void`)); export const voidContractRenewal = async (id, renewalContractId, data) => unwrap(await request.post(`/hr/contracts/${id}/renewals/${renewalContractId}/void`, data)); export const voidContractTermination = async (id, terminationId) => unwrap(await request.post(`/hr/contracts/${id}/terminations/${terminationId}/void`)); export const getContractUnrenewedReport = async (data = {}) => unwrap(await request.get("/hr/contracts/reports/unrenewed", { params: compact(data) })); /** * 查询员工合同流程状态 GET /hr/contracts/employee/{employeeId}/status * 返回当前员工合同所处流程节点: * - 无合同:返回 null / 空 * - 草稿(已保存未提交):{ contractId, statusCode: 'DRAFT', ... } * - 审批中 / 待签署等:{ contractId, statusCode: 'PENDING_APPROVAL', flowNode, ... } */ export const getEmployeeContractStatus = async (employeeId) => unwrap(await request.get(`/hr/contracts/employee/${employeeId}/status`)); /** 合同类型 code → 中文标签 */ const CONTRACT_TYPE_LABELS = { FIXED: "固定期限劳动合同", NON_FIXED: "无固定期限劳动合同", TASK: "以完成一定任务为期限", DISPATCH: "劳务派遣合同", INTERN: "实习协议", CONFIDENTIAL: "保密协议", NON_COMPETE: "竞业限制协议", APPRENTICE: "学徒合同", OTHER: "其他", }; export function contractTypeLabel(code) { return CONTRACT_TYPE_LABELS[String(code || "").toUpperCase()] || String(code || ""); } /* 签署方式不再本地翻译:接口已返回 signingMethodName,直接展示 */ function fmtDate(v) { if (!v) return ""; const d = new Date(v); if (isNaN(d.getTime())) return String(v); const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function fmtDateTime(v) { if (!v) return ""; const d = new Date(v); if (isNaN(d.getTime())) return String(v); const pad = (n) => String(n).padStart(2, "0"); return `${fmtDate(v)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } /** 合同附件文件主键 */ function collectAttachmentFileIds(item = {}) { const fromIds = String(item.attachmentIds || "").split(",").map((s) => s.trim()).filter(Boolean); if (fromIds.length) return fromIds; const files = Array.isArray(item.files) ? item.files : []; const attach = files.filter((f) => ["ATTACHMENT", "SCAN", "ARCHIVE"].includes(f.filePurpose)); const source = attach.length ? attach : files; const fromFiles = source.map((f) => f.fileId || f.id).filter((id) => id != null && id !== "").map(String); if (fromFiles.length) return fromFiles; const extra = [item.offlineFileId, item.fileId].filter((id) => id != null && id !== ""); return extra.map(String); } /** * 试用期展示文案:合同详情回显字段是 probationMonths(月数) * 0 / 空 → "无试用期";大于 0 → "N个月"(如 3个月) */ export function probationMonthsText(value) { const months = Number(value); if (!Number.isFinite(months) || months <= 0) return "无试用期"; return `${months}个月`; } /** * 合同后端 DTO → 前端展示对象 * 合同管理列表、合同详情、入职签署合同节点共用 */ export function adaptContract(item = {}) { const startDate = fmtDate(item.startDate); const endDate = fmtDate(item.endDate); const termText = item.contractTerm || (item.contractTermMonths != null && item.contractTermMonths !== "" ? `${item.contractTermMonths}个月` : ""); const processInstanceId = item.processInstanceId || item.approvalInstanceId || item.bpmProcessInstanceId || item.processInstance || item.processInstanceIdStr || ""; const approvalInstanceId = item.approvalInstanceId || item.processInstanceId || ""; return { ...item, employeeNo: item.employeeCode || item.employeeNo || "", employeeCode: item.employeeCode || item.employeeNo || "", legalEntity: item.legalEntityName || "", department: item.departmentName || "", contractTypeCode: item.contractType || "", /* ★ 接口已补中文字段(contractTypeName / contractStatusName / signingStatusName / signingMethodName),列表与详情直接展示接口值,不再本地枚举翻译 */ contractType: item.contractTypeName || "", signMethod: item.signingMethodName || "", signingStatus: item.signingStatus || "", signingStatusLabel: item.signingStatusName || "", flowSignStatus: pickFlowSignStatus(item), flowSignStatusLabel: contractFlowSignStatusLabel(pickFlowSignStatus(item)), flowSignNodes: adaptContractFlowSignNodes(item), status: item.contractStatusName || "", statusCode: item.contractStatus || "", processInstanceId, approvalInstanceId, salary: item.salaryStandard, signDate: fmtDate(item.signingDate), startDate, endDate, contractTerm: termText, remainingDays: item.remainingDays === null || item.remainingDays === undefined || item.remainingDays === "" ? null : Number(item.remainingDays), probationMonths: item.probationMonths, /* 详情展示用文案:0/空 = 无试用期,>0 = N个月 */ probationText: probationMonthsText(item.probationMonths), createdAt: fmtDateTime(item.createTime), probationEnd: fmtDate(item.probationEndDate), attachmentFileIds: collectAttachmentFileIds(item), level: item.positionLevel || item.level || "", positionSequence: item.positionSequence || item.sequenceName || "", positionSequenceId: item.positionSequenceId || item.sequenceId || "", positionLevel: item.positionLevel || item.level || "", positionLevelId: item.positionLevelId || "", positionType: item.positionType || "", /* 名称只取真正的名称字段:employmentType 是岗位性质字典编码,当名称用会显示成 "1" */ positionTypeName: item.positionTypeName || "", employmentType: item.employmentType || "", manager: item.managerName || item.manager || "", /* 合同变更状态:字段是 changeApprovalStatus / changeApprovalStatusName(2026-10-17 抓包确认)。 ★ 只有发生过变更的合同才返回该字段,没变更的合同没有 —— 字段缺失 = 未发生变更(用户口径), 不是接口异常。给了中文名就用中文名,只给编码时原样展示编码(不编造文案)。 */ changeStatusCode: item.changeApprovalStatus || "", changeStatusLabel: item.changeApprovalStatusName || "", /* 变更单主键:发起变更审批(hr_contract_change_approval)时作为 businessId */ changeId: item.changeId || "", /* 变更审批流程实例:变更审批中的行查看「变更审批记录」用 */ changeProcessInstanceId: item.changeProcessInstanceId || "", /* 最近一次续签状态:DRAFT/PENDING_APPROVAL/PENDING_SIGN/SIGNED_WAIT_EFFECTIVE/ EFFECTIVE/EXPIRING/EXPIRED/RENEWED/CHANGED/CANCELLED;从未发起时为空(2026-10-17 抓包) */ renewalStatusCode: item.renewalStatus || "", renewalStatusLabel: item.renewalStatusName || "", /* 最近一次解除终止审批状态:DRAFT/PENDING_APPROVAL/SIGNING/EFFECTIVE/REJECTED/CANCELLED; 从未发起时为空 */ terminationStatusCode: item.terminationApprovalStatus || "", terminationStatusLabel: item.terminationApprovalStatusName || "", /* 续签生成的新合同主键(若列表行返回):续签审批的 businessId; 字段名以后端实际返回为准,没返回时前端明确提示,不拿原合同 id 顶替 */ renewalContractId: item.renewalContractId || "", /* 续签审批流程实例:续签审批中的行查看「审批记录」用(列表未返回时回落到新合同详情) */ renewalProcessInstanceId: item.renewalProcessInstanceId || "", /* 解除终止单主键(2026-10-19 契约确认):解除审批(hr_contract_termination_approval) 的 businessId;编辑解除草稿时也用它原地修改 */ terminationId: item.terminationId || "", /* 解除审批流程实例:解除审批中的行查看「审批记录」用 */ terminationProcessInstanceId: item.terminationProcessInstanceId || "", }; } // 招聘需求 export const getRecruitDemandPage = async (data = {}) => unwrap(await request.get("/recruit/demands", { params: compact(data) })); export const submitRecruitDemand = async (data) => unwrap(await request.post("/recruit/demands", compact(data))); export const getRecruitDemandById = async (id) => unwrap(await request.get(`/recruit/demands/${id}`)); // 岗位申请 export const createPositionApplication = async (data) => unwrap(await request.post("/hr/position/apply", data)); export const getPositionApplicationPage = async (data = {}) => unwrap(await request.post("/hr/position/apply/page", compact(data))); export const getPositionApplicationById = async (id) => unwrap(await request.get(`/hr/position/apply/${id}`)); export const updatePositionApplication = async (id, data) => unwrap(await request.put(`/hr/position/apply/${id}`, data)); export const deletePositionApplication = async (id) => unwrap(await request.delete(`/hr/position/apply/${id}`)); export const submitPositionApplication = async (id) => unwrap(await request.post(`/hr/position/apply/${id}/submit`)); // 岗位申请列表项:后端列表字段 → 页面字段(列表不含职责/薪资/技能,由详情接口补齐) export function adaptPositionApplication(raw = {}) { return { ...raw, id: raw.id, code: raw.positionCode || "", documentCode: raw.documentCode || "", positionName: raw.positionName || "", organizationId: raw.organizationId, organizationName: raw.organizationName || "", deptId: raw.deptId, deptName: raw.deptName || "", // 岗位层级为序列层级文本(如 P1/T4),保持字符串展示,字段与后端 positionLevel 对齐 positionLevel: raw.positionLevel || "", positionSequence: raw.positionSequence || "", type: raw.positionType || "", headcount: Number(raw.plannedRecruitmentCount ?? 0), applicant: raw.applicantName || "", status: raw.status || "DRAFT", updatedAt: raw.updateTime || raw.createTime || "", reviewer: raw.reviewerName || "", reviewTime: raw.reviewTime || "", processInstanceId: raw.processInstanceId || raw.approvalInstanceId || "", approvalInstanceId: raw.approvalInstanceId || raw.processInstanceId || "", effectivePositionId: raw.effectivePositionId, }; } // 岗位申请详情:在列表字段基础上补职责、薪资区间、技能要求与审批记录 export function adaptPositionApplicationDetail(raw = {}) { const salary = raw.salary || {}; const duties = splitText(salary.dutyContent); return { ...adaptPositionApplication(raw), duties, /* 展示用原始 dutyContent,避免 join("\n") 把用户写的「;」改成换行 */ dutiesText: salary.dutyContent || duties.join("\n"), salaryMin: Number(salary.salaryMin ?? 0), salaryMid: Number(salary.salaryMid ?? 0), salaryMax: Number(salary.salaryMax ?? 0), expYears: Number(salary.experienceYears ?? 0), education: salary.educationRequirement || "", industryExp: salary.industryExperience || "", professionalAbility: salary.professionalAbility || "", otherRequirement: salary.otherRequirement || "", skills: (raw.skills || []).map((item) => ({ ...item, name: item.skillName || "", level: item.skillLevel || "", required: String(item.essentialStatus) === "1", })), certificates: (raw.certificates || []).map((item) => ({ ...item, name: item.certificateName || "", category: item.certificateType || "", })), reviewOpinion: raw.reviewOpinion || "", operationLogs: raw.operationLogs || [], }; } // 岗位申请表单 → 提交参数:基本信息 + 定薪定责 + 技能/证书要求 export function serializePositionApplication(form) { const duties = (form.dutiesText || "") .split("\n") .map((item) => item.trim()) .filter(Boolean); const salary = compact({ dutyContent: duties.join("\n"), // 薪资字段统一数字类型 salaryMin: Number(form.salaryMin || 0), salaryMid: Number(form.salaryMid || 0), salaryMax: Number(form.salaryMax || 0), experienceYears: Number(form.expYears || 0), educationRequirement: form.education || "", industryExperience: form.industryExp || "", professionalAbility: form.professionalAbility || "", otherRequirement: form.otherRequirement || "", }); const payload = { organizationId: form.organizationId, deptId: form.deptId, positionName: form.positionName, positionLevel: form.positionLevel ? String(form.positionLevel) : "", positionSequence: form.positionSequence || "", positionType: form.type || "", plannedRecruitmentCount: Number(form.headcount || 0), }; if (Object.keys(salary).length) payload.salary = salary; const skills = (form.skills || []) .filter((item) => item.name) .map((item) => ({ skillName: item.name, skillLevel: item.level, essentialStatus: item.required ? "1" : "2", })); if (skills.length) payload.skills = skills; const certificates = (form.certificates || []) .filter((item) => item.name) .map((item, index) => ({ certificateName: item.name, certificateType: item.category, sortNo: index + 1, })); if (certificates.length) payload.certificates = certificates; if (form.id) payload.id = form.id; return compact(payload); }