|
|
@@ -0,0 +1,220 @@
|
|
|
+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;
|
|
|
+}
|
|
|
+
|