import * as XLSX from "xlsx"; /** * 员工档案「导入/导出模板」唯一来源。 * * 表头文案、顺序、工作表名、样例行 100% 对齐用户拍板的《员工档案导入模板.xlsx》(28 列,含「微信号」), * 「导出当前结果 / 导出已选」与「下载导入模板」都从这里生成,两处口径不允许再各写一份。 * 两处重复表头(第 3 列与第 14 列都叫「*工号」、第 11 列与第 16 列都叫「岗位」)是模板 * 自身的写法,这里原样保留,不做「优化」,否则回导对不上列。 * * 取值来源说明(均为 GET /main/user/getUserPage 列表行实测字段,无需逐人查详情): * - 所属机构名称 ← 组织列表 groupId 对应节点名,回落 groupName * - 所属工厂名称 ← 人员 factoryId 经「工厂区域」字典翻译 * - 所属班组 ← 人员 teamId(逗号串)经「班组」字典翻译 * - 负责部门编码 ← 人员 deptIds 经组织列表节点 groupCode 翻译 * - 岗位序列/层级/性质 ← 人员 positionList 主岗位(positionSequenceName / positionLevelName / positionTypeName) * - 员工状态 ← status 数字码中文(1全职 2兼职 3实习 4正式 5试用 6离职) */ /** 文本归一:null/undefined → 空串,其余 trim */ function textOf(value) { if (value == null) return ""; return String(value).trim(); } /** 逗号串 / 数组 → 去空白后的字符串数组 */ function toIdList(value) { if (Array.isArray(value)) return value.map(textOf).filter(Boolean); return textOf(value) .split(/[,,]/) .map(textOf) .filter(Boolean); } /** 主岗位绑定项:primaryFlag=1 优先,其次第一条 */ function primaryBinding(row) { const list = (row && row.positionBindings) || []; return list.find((item) => Number(item.primaryFlag) === 1) || list[0] || null; } /** id 列表 → 名称列表;字典里查不到时保留原 id,保证信息不丢(不静默丢列) */ function mapNames(ids, dict) { return ids.map((id) => textOf(dict && dict[id]) || id); } /** * 「YYYY-MM-DD」→ Excel 日期单元格。 * 模板里 入职时间 / 生日 是真日期(序列号 40987),不是文本,这里同样写日期单元格, * 保证导出的文件后端能按日期解析。无值写空串,格式异常原样输出。 */ function toExcelDate(value) { const text = textOf(value); if (!text) return ""; // 后端个别接口把日期回成纯数字毫秒时间戳(如 558115200000),先按时间戳还原 if (/^\d{13}$/.test(text)) { const stamp = new Date(Number(text)); return Number.isNaN(stamp.getTime()) ? text : stamp; } const matched = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(text); if (!matched) return text; const date = new Date(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3])); return Number.isNaN(date.getTime()) ? text : date; } /** 列定义:header 为模板表头,value(row, index, ctx) 为该列取值 */ export const EMPLOYEE_TEMPLATE_COLUMNS = [ { header: "序号", value: (row, index) => index + 1 }, { header: "*所属机构名称", value: (row, index, ctx) => textOf(ctx.orgById?.[String(row.groupId)]?.name) || textOf(row.company), }, { header: "*工号", value: (row) => textOf(row.employeeNo) }, { header: "*姓名", value: (row) => textOf(row.name) }, { header: "*性别", value: (row) => textOf(row.gender) }, { header: "*手机号", value: (row) => textOf(row.phone) }, { header: "*员工状态", value: (row) => textOf(row.formStatus) }, { header: "所属工厂名称", value: (row, index, ctx) => textOf(ctx.factoryById?.[String(row.factoryId)]) || "", }, { header: "所属班组", value: (row, index, ctx) => mapNames(toIdList(row.teamIds), ctx.teamById).join(","), }, { header: "*隶属部门", value: (row) => textOf(row.deptName) }, { header: "岗位", value: (row) => textOf(row.position) }, { header: "负责部门编码(多个逗号分隔)", value: (row, index, ctx) => toIdList(row.deptIds) .map((id) => textOf(ctx.orgById?.[id]?.groupCode) || id) .join(","), }, { header: "*身份证号", value: (row) => textOf(row.idCard) }, /* 与第 3 列同名的模板列,原样保留 */ { header: "*工号", value: (row) => textOf(row.employeeNo) }, { header: "*岗位序列", value: (row) => textOf(primaryBinding(row)?.positionSequenceName) }, { header: "*岗位", value: (row) => textOf(row.position) }, { header: "*岗位性质", value: (row) => textOf(row.employmentTypeName) || textOf(row.employmentType), }, { header: "*岗位层级", value: (row) => textOf(row.level) }, { header: "邮箱", value: (row) => textOf(row.email) }, { header: "年龄", value: (row) => (row.age == null || row.age === "" ? "" : Number(row.age)), }, { header: "*入职时间", value: (row) => toExcelDate(row.hireDate) }, { header: "籍贯", value: (row) => textOf(row.nativePlace) }, { header: "工种", value: (row) => ((row.workTypes || []).map((item) => textOf(item.name) || textOf(item.code)).filter(Boolean)).join(","), }, { header: "专业", value: (row) => textOf(row.major) }, { header: "毕业院校", value: (row) => textOf(row.school) }, { header: "生日", value: (row) => toExcelDate(row.birthDate) }, { header: "微信号", value: (row) => textOf(row.wechat) }, { header: "备注:标*号为必填项", value: () => "" }, ]; /** 模板表头(顺序即列顺序,供比对/复用) */ export const EMPLOYEE_TEMPLATE_HEADERS = EMPLOYEE_TEMPLATE_COLUMNS.map( (column) => column.header, ); /** 模板工作表名:与《员工档案导入模板.xlsx》一致(Sheet1),保证导出文件与模板结构完全相同 */ export const EMPLOYEE_TEMPLATE_SHEET_NAME = "Sheet1"; /** * 模板自带的样例行(原文件的第 2 行,逐格原样复刻,含数字/日期单元格类型)。 * 只用于「下载导入模板」,导出真实数据时不带这一行。 * 空列写 null 而不是空串:SheetJS 会跳过 null,生成的空单元格与原模板一致。 */ export function buildEmployeeTemplateSampleRow() { return [ 1, "公司", 1212333, "曹孟德", "男", 18895956063, "全职", "万隆本部", "质检班组", null, "总经理", "G0018,G0019", "430904198506061566", null, null, null, null, null, "aa@qq.com", 37, /* 入职时间:原文件为日期序列号 40987 = 2012-03-19 */ new Date(2012, 2, 19), "湖南", null, 111, "湖南测试学院", /* 生日:原文件为日期序列号 25569 = 1970-01-01 */ new Date(1970, 0, 1), "test001", null, ]; } /** * 员工档案行 → 模板二维数组(首行为表头) * @param {Array} rows adaptEmployee 产出的员工行 * @param {object} ctx { orgById, factoryById, teamById } 字典(缺失时对应列退化为 id / 空) */ export function buildEmployeeTemplateSheet(rows, ctx = {}) { const header = EMPLOYEE_TEMPLATE_HEADERS.slice(); const body = (rows || []).map((row, index) => EMPLOYEE_TEMPLATE_COLUMNS.map((column) => column.value(row, index, ctx)), ); return { header, body }; } /** * 员工档案 → 模板格式 .xlsx * @param {Array} rows 员工行(模板下载传 []) * @param {object} ctx 字典 * @param {string} fileName 目标文件名 * @param {object} [options] * @param {boolean} [options.withSample=false] 是否带模板自带的样例行(第 2 行) * @returns {number} 写入的数据行数(不含表头/样例行) */ export function writeEmployeeTemplateXlsx(rows, ctx, fileName, options = {}) { const { header, body } = buildEmployeeTemplateSheet(rows, ctx); const aoa = [header]; if (options.withSample) aoa.push(buildEmployeeTemplateSampleRow()); aoa.push(...body); const sheet = XLSX.utils.aoa_to_sheet(aoa, { cellDates: true }); sheet["!cols"] = EMPLOYEE_TEMPLATE_COLUMNS.map((column) => ({ // 中文按 2 字宽估算,保证表头不被截断 wch: Math.max(10, column.header.length * 2), })); const book = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(book, sheet, EMPLOYEE_TEMPLATE_SHEET_NAME); XLSX.writeFile(book, fileName); return body.length; } /** * 「下载导入模板」:本地生成与《员工档案导入模板.xlsx》同款空白模板(表头 + 样例行)。 * 不再调后端 `POST /main/user/downLoadTemplate`(后端下发的是旧 22 列,与页面回导口径不一致)。 * @returns {string} 生成的文件名 */ export function downloadEmployeeTemplate(fileName = "员工档案导入模板.xlsx") { writeEmployeeTemplateXlsx([], {}, fileName, { withSample: true }); return fileName; }