企业做 EHS 不只是贴标签、写报告,最务实的一块就是“健康管理”。员工健康直接影响生产效率、事故率、合规风险和用工成本。把健康管理做成系统化、数据化、可落地的模块,能把体检、档案、劳保用品管理、异常预警、统计看板连成闭环——这对中大型企业尤其有价值。下面这篇文章会把“怎么搭建”讲得尽量接地气、有干货,并给出架构图、流程图、数据库设计、后端 API、前端示例和落地实现建议,代码做成一个相对完整的参考实现,方便直接拿去改造。
本文你将了解
- EHS 健康管理板块是什么?
- 总体架构图(技术选型、模块划分)
- 主要功能清单
- 各功能业务流程(流程图+要点)
- 数据库设计(关键表结构与字段说明)
- 后端实现
- 前端实现
- 开发技巧与工程化建议(权限、数据隐私、接口、定时任务、报表)
- 实现效果(交付验收点、KPI、展示截图建议)
- 部署、运维与扩展(备份、审计、第三方对接)
- FAQ
注:本文示例所用方案模板:简道云EHS健康安全环境管理系统,给大家示例的是一些通用的功能和模块,都是支持自定义修改的,你可以根据自己的需求修改里面的功能。
一、EHS 健康管理板块是什么?
简单说,健康管理板块是 EHS 系统里专门负责“员工健康生命周期管理”的子系统。它把员工从入职、体检、健康档案、职业病监测、到劳保用品的发放与归档、再到健康看板、异常告警、合规报表都纳进来,形成闭环。目标是:让健康数据可查、可追溯、可提醒、可统计,从而降低职业健康风险、减少工伤、提升合规效率与管理可视化。
企业价值点(可量化示例):
- 降低因职业病/急性病导致的停工率(目标:年降低 10%)
- 员工体检管理自动化,减少人工 60% 的跟进工作量
- PPE(劳保用品)领用可追溯,减少浪费或不合规发放
- 健康看板支持月度/季度汇报,方便 EHS/HR 决策
二、总体架构图(技术选型与模块划分)
下面给出一个常见、可扩展的架构示意,采用前后端分离 + REST API + 定时任务 + 报表服务。
mermaid
graph TD
User[用户(EHS 管理员 / 现场管理员 / 员工)] -->|Web/Mobile| Frontend[React / Mobile App]
Frontend --> API[API 网关 / 后端服务(Node.js / Spring Boot)]
API --> DB[(主库 PostgreSQL)]
API --> FileStore[(对象存储:S3 / MinIO)]
API --> Auth[(认证/授权:OAuth2 / JWT)]
API --> JobScheduler[(定时任务:Cron / BullMQ)]
API --> ES[(ElasticSearch,用于日志/模糊检索)]
API --> BI[(报表/看板服务,Grafana 或 内置)]
subgraph Integrations
API --> HR[HR 系统]
API --> MES[生产系统/门禁/工牌]
API --> Lab[体检中心接口]
end
技术选型建议(可替换)
- 后端:Node.js(Express/Koa)或 Java Spring Boot。示例用 Node.js。
- DB:PostgreSQL(事务好、JSON 支持好),备份策略必备。
- 对象存储:S3/MinIO 保存体检报告 PDF、影像。
- Redis:用于缓存 & 分布式锁 & 定时队列(如 BullMQ)。
- 前端:React + Ant Design / Element UI(企业内常用),移动端优先 PWA 或小程序。
- 报表/看板:内置(图表库)或接 Grafana。
- 接口安全:JWT + RBAC(角色权限)。
3. 主要功能清单(必备项 + 推荐项)
必备模块:
- 员工健康档案(基础信息 + 历史病史 + 过敏史 + 既往职业病史)
- 员工体检结果(导入/上传体检单 PDF、关键指标结构化)
- 劳保用品资料库(物料主数据:手套、口罩、安全鞋规格等)
- 劳保用品入库单(库存入库记录)
- 劳保用品领用单(出库/领用记录,带审批流)
- 健康管理看板(体检合格率、异常率、PPE 库存/领用统计)
- 异常预警与工单(体检异常、忘领 PPE、超期体检提醒)
- 权限管理(EHS 管理员、HR、仓库、现场监督、普通员工)
推荐模块(加强版):
- 体检中心接口对接(自动导入体检结果)
- 门禁/考勤/班次关联(对高危岗位做重点关注)
- 职业健康风险评估(岗位+暴露因子)
- 移动端扫码领用(PPE 领用更便捷)
- 定期体检计划 & 自动催办
四、各功能业务流程(流程图 + 要点)
下面以“体检入库并预警”、“PPE 入库与领用”两个主流程为例说明。
1.员工体检结果导入与异常预警流程
mermaid
flowchart TD
A[HR/员工触发体检] --> B[体检机构上传体检报告]
B --> C[自动导入 / 手工上传 PDF]
C --> D[解析关键指标(如血压、肺功能等)]
D --> E{是否异常?}
E -- 是 --> F[标注异常、生成异常工单、发送通知给 EHS/HR/员工]
E -- 否 --> G[更新员工健康档案]
F --> H[跟踪复查/记录]
要点:
- 自动解析:可采用 OCR + 规则提取关键指标,初期可以人工结构化导入以保证准确率。
- 异常定义:由 EHS/医院确认的阈值表驱动(每项指标阈值可配置)。
- 既往异常:要能在员工档案中看到历史趋势,支持图表对比。
2.劳保用品入库与领用流程
mermaid
flowchart TD
A[仓库录入劳保用品入库单] --> B[系统生成库存记录]
B --> C[员工/主管发起领用申请]
C --> D[仓库/主管审批]
D --> E[出库并记录领用单,关单]
E --> F[更新看板(库存、领用统计)]
要点:
- 领用需绑定岗位和用途,避免“随便领用”。
- 领用支持批量(多人)和扫码核销两种模式。
- 领用记录需与员工档案绑定,便于追踪。
五、数据库设计(核心表结构与说明)
下面给出核心表的 SQL 建表参考(以 PostgreSQL 为例)。表字段会兼顾审计和扩展性。
sql
-- 员工基本信息
CREATE TABLE employees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employee_no VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(128) NOT NULL,
gender VARCHAR(16),
birthday DATE,
department VARCHAR(128),
position VARCHAR(128),
hire_date DATE,
mobile VARCHAR(32),
email VARCHAR(128),
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
);
-- 员工健康档案(结构化+JSON)
CREATE TABLE health_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employee_id UUID REFERENCES employees(id),
medical_history TEXT, -- 既往史,结构化文本或 JSON
allergies TEXT,
occupational_exposure TEXT,
attachments JSONB, -- 存 PDF/图像的存储路径与元数据
last_updated TIMESTAMP DEFAULT now()
);
-- 体检结果(每次体检一条)
CREATE TABLE exam_results (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employee_id UUID REFERENCES employees(id),
exam_date DATE,
exam_type VARCHAR(64), -- 入职/年度/离职/专项
indicators JSONB, -- {"blood_pressure":"120/80","bmi":23.5, ...}
summary TEXT,
report_path TEXT, -- 对象存储地址
abnormal_flags JSONB, -- {"blood_pressure":true}
created_at TIMESTAMP DEFAULT now()
);
-- 劳保用品资料库
CREATE TABLE ppe_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(128),
spec VARCHAR(128),
unit VARCHAR(32),
safety_standards TEXT,
created_at TIMESTAMP DEFAULT now()
);
-- 劳保库存(实时)
CREATE TABLE ppe_stock (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ppe_id UUID REFERENCES ppe_catalog(id),
batch_no VARCHAR(64),
qty INT,
location VARCHAR(128),
in_date DATE,
expire_date DATE,
created_at TIMESTAMP DEFAULT now()
);
-- 劳保领用单
CREATE TABLE ppe_issuances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issuance_no VARCHAR(64) UNIQUE,
employee_id UUID REFERENCES employees(id),
ppe_id UUID REFERENCES ppe_catalog(id),
qty INT,
issued_by VARCHAR(128),
issued_at TIMESTAMP DEFAULT now(),
purpose TEXT,
approval_status VARCHAR(32) DEFAULT 'pending', -- pending/approved/rejected
approval_info JSONB
);
-- 异常工单
CREATE TABLE health_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
employee_id UUID REFERENCES employees(id),
source VARCHAR(64), -- exam/manager/auto
alert_type VARCHAR(64),
content TEXT,
status VARCHAR(32) DEFAULT 'open', -- open/closed
created_at TIMESTAMP DEFAULT now(),
resolved_at TIMESTAMP
);
这些表可以满足常见场景,JSONB 用来保持灵活性(例如指标变化、不同医院返回结构不同),后续可逐步结构化。
六、后端实现(示例:Node.js + Express + Sequelize)
下面给出一个整合的示例代码,包含模型、部分路由与关键业务逻辑(导入体检、PPE 领用、看板统计)。这是一个精简但可运行的参考实现,用作项目启动模板。
环境:Node.js 18+,Postgres,Sequelize ORM,Express
js
// app.js (入口)
const express = require('express');
const bodyParser = require('body-parser');
const { sequelize } = require('./models');
const routes = require('./routes');
const app = express();
app.use(bodyParser.json());
app.use('/api', routes);
const PORT = process.env.PORT || 3000;
sequelize.authenticate().then(()=> {
console.log('DB connected');
app.listen(PORT, ()=> console.log(`Server running ${PORT}`));
}).catch(err => console.error(err));
js
// models/index.js
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize(process.env.DATABASE_URL || 'postgres://user:pass@localhost:5432/ehs', {
logging:false
});
const Employee = sequelize.define('employee', {
id: { type: DataTypes.UUID, primaryKey:true, defaultValue: Sequelize.literal('gen_random_uuid()') },
employee_no: { type: DataTypes.STRING, unique:true },
name: DataTypes.STRING,
department: DataTypes.STRING,
position: DataTypes.STRING,
});
const HealthProfile = sequelize.define('health_profile', {
id: { type: DataTypes.UUID, primaryKey:true, defaultValue: Sequelize.literal('gen_random_uuid()') },
medical_history: DataTypes.TEXT,
allergies: DataTypes.TEXT,
attachments: DataTypes.JSONB
});
const ExamResult = sequelize.define('exam_result', {
id: { type: DataTypes.UUID, primaryKey:true, defaultValue: Sequelize.literal('gen_random_uuid()') },
exam_date: DataTypes.DATE,
exam_type: DataTypes.STRING,
indicators: DataTypes.JSONB,
report_path: DataTypes.STRING,
abnormal_flags: DataTypes.JSONB
});
const PpeCatalog = sequelize.define('ppe_catalog', {
id: { type: DataTypes.UUID, primaryKey:true, defaultValue: Sequelize.literal('gen_random_uuid()') },
sku: { type: DataTypes.STRING, unique:true },
name: DataTypes.STRING,
spec: DataTypes.STRING,
unit: DataTypes.STRING
});
const PpeIssuance = sequelize.define('ppe_issuance', {
id: { type: DataTypes.UUID, primaryKey:true, defaultValue: Sequelize.literal('gen_random_uuid()') },
issuance_no: DataTypes.STRING,
qty: DataTypes.INTEGER,
purpose: DataTypes.TEXT,
approval_status: { type: DataTypes.STRING, defaultValue:'pending' }
});
const HealthAlert = sequelize.define('health_alert', {
id: { type: DataTypes.UUID, primaryKey:true, defaultValue: Sequelize.literal('gen_random_uuid()') },
source: DataTypes.STRING,
alert_type: DataTypes.STRING,
content: DataTypes.TEXT,
status: { type: DataTypes.STRING, defaultValue:'open' }
});
// 关系
Employee.hasOne(HealthProfile, { foreignKey:'employee_id' });
Employee.hasMany(ExamResult, { foreignKey:'employee_id' });
Employee.hasMany(PpeIssuance, { foreignKey:'employee_id' });
Employee.hasMany(HealthAlert, { foreignKey:'employee_id' });
PpeCatalog.hasMany(PpeIssuance, { foreignKey:'ppe_id' });
module.exports = { sequelize, Employee, HealthProfile, ExamResult, PpeCatalog, PpeIssuance, HealthAlert };
js
// routes/index.js
const express = require('express');
const router = express.Router();
const { Employee, ExamResult, PpeCatalog, PpeIssuance, HealthAlert, sequelize } = require('../models');
// 创建员工(示例)
router.post('/employees', async (req, res) => {
const emp = await Employee.create(req.body);
res.json(emp);
});
// 导入体检结果(简化)
router.post('/exam/import', async (req, res) => {
// body: { employee_no, exam_date, exam_type, indicators, report_path }
const t = await sequelize.transaction();
try {
const { employee_no, exam_date, exam_type, indicators, report_path } = req.body;
const emp = await Employee.findOne({ where:{ employee_no }});
if(!emp) return res.status(404).json({error:'员工不存在'});
// 简单异常判断规则示例(血压)
const abnormal_flags = {};
if(indicators && indicators.blood_pressure){
const [sys,dia] = indicators.blood_pressure.split('/').map(Number);
if(sys>140 || dia>90) abnormal_flags.blood_pressure = true;
}
const exam = await ExamResult.create({ employee_id: emp.id, exam_date, exam_type, indicators, report_path, abnormal_flags }, { transaction: t });
if(Object.keys(abnormal_flags).length>0){
await HealthAlert.create({ employee_id: emp.id, source:'exam', alert_type:'exam_abnormal', content:`检测到异常指标: ${JSON.stringify(abnormal_flags)}` }, { transaction:t });
// 这里可触发通知:邮件/短信/站内消息
}
await t.commit();
res.json({ ok:true, exam });
} catch(err){
await t.rollback();
console.error(err);
res.status(500).json({ error: err.message });
}
});
// PPE 领用申请(创建)
router.post('/ppe/apply', async (req, res) => {
// body: { employee_no, sku, qty, purpose }
const { employee_no, sku, qty, purpose } = req.body;
const emp = await Employee.findOne({ where:{ employee_no }});
if(!emp) return res.status(404).json({error:'员工不存在'});
const ppe = await PpeCatalog.findOne({ where:{ sku }});
if(!ppe) return res.status(404).json({error:'物品不存在'});
const iss = await PpeIssuance.create({ issuance_no: `ISS-${Date.now()}`, employee_id: emp.id, ppe_id: ppe.id, qty, purpose, approval_status:'pending' });
// 可触发审批流
res.json(iss);
});
// 统计接口(看板)示例:体检异常人数、近 30 天 PPE 领用量
router.get('/dashboard/summary', async (req, res) => {
const totalEmployees = await Employee.count();
const abnormalCount = await HealthAlert.count({ where:{ alert_type:'exam_abnormal', status:'open' }});
const recentPpe = await PpeIssuance.findAll({
attributes:[
'ppe_id',
[sequelize.fn('sum', sequelize.col('qty')), 'total_qty']
],
group:['ppe_id'],
limit:10
});
res.json({ totalEmployees, abnormalCount, recentPpe });
});
module.exports = router;
说明与落地要点
示例里异常判断非常基础,正式系统需要把阈值配置化、可按岗位/年龄/性别定制。导入体检更现实的做法是:体检机构通过 API 或 SFTP 把结构化数据推过来,或由人工审核导入。OCR 只能作为辅助。PPE 出库在审批通过后需要与库存服务交互(减库存)并做事务。
七、前端实现(示例:React + Ant Design)
下面给出两个关键页面的示例:体检导入/查看、健康看板。代码为精简版,实际要结合项目脚手架来集成。
jsx
// components/ExamUpload.jsx
import React, {useState} from 'react';
import { Upload, Button, Input, DatePicker, message } from 'antd';
import axios from 'axios';
export default function ExamUpload() {
const [employeeNo, setEmployeeNo] = useState('');
const [examDate, setExamDate] = useState(null);
const [uploading, setUploading] = useState(false);
const [file, setFile] = useState(null);
const handleUpload = async () => {
if(!employeeNo || !examDate || !file) return message.warn('请补充信息');
setUploading(true);
try{
// 示例:先上传文件到后端(或直传 S3),然后提交结构化指标(这里简化)
const fd = new FormData();
fd.append('file', file);
const r1 = await axios.post('/api/files/upload', fd);
await axios.post('/api/exam/import', {
employee_no: employeeNo, exam_date: examDate.format('YYYY-MM-DD'),
exam_type: '年度', indicators: {}, report_path: r1.data.path
});
message.success('导入成功');
}catch(err){ message.error('导入失败'); console.error(err); }
setUploading(false);
};
return (
setEmployeeNo(e.target.value)} />
setExamDate(d)} />
{ setFile(f); return false; }}>
上传体检单 PDF
导入体检
);
}
jsx
// components/Dashboard.jsx
import React, {useEffect, useState} from 'react';
import axios from 'axios';
import { Card, Statistic, Table } from 'antd';
import { Line } from 'recharts';
export default function Dashboard() {
const [data, setData] = useState({});
useEffect(()=>{ axios.get('/api/dashboard/summary').then(r=>setData(r.data)); },[]);
return (
);
}
前端要点
- 表格、表单要支持 CSV 导入/导出(给 HR 或体检单位)。
- 报表页支持过滤(按部门、按岗位、按时间)和导出 PDF/Excel。
- 移动端优先:领用扫码、查看体检报告、推送提醒。
八、开发技巧与工程化建议(实战派)
- 阈值规则引擎:不要把阈值写死在代码里,做成可配置的规则表(按年龄/性别/岗位不同),可以在线维护。
- 文件存储与审计:体检报告/影像应上传到对象存储,保存 metadata(上传人/时间/签名),并做权限控制(谁能查看)。
- 数据隐私合规:健康数据敏感,访问需最小化权限,日志审计,数据脱敏(导出时)。遵守本地法规(如中国的个人信息保护)。
- 通知与催办:使用队列(Redis + Bull)处理通知(短信/邮件/站内)和定时检查(如体检到期提醒)。
- 审批流:领用、异常处理都要支持审批节点(可配置),避免硬编码审批流程。
- 接口设计:RESTful + 分页 + 筛选;对于大数据量查询使用异步任务或 ES 做检索。
- 测试:重点测试流程连通:体检导入 → 异常 → 工单 → 复查。写 E2E 测试。
- 性能:看板数据建议做预计算(每日夜间汇总)或缓存,避免实时复杂查询。
- 可扩展性:把“指标解析器”做成插件式,未来对接不同体检机构只需写适配器。
- 权限控制:RBAC + 细粒度字段级权限(例如普通主管不能看到某些敏感字段)。
九、实现效果(交付验收点与 KPI)
落地后建议检验的几个交付点:
- 员工体检记录可查询,体检报告可下载并能看到历史趋势(图表)。
- 体检异常自动生成工单并能看到处理状态、指定责任人、复查记录。
- PPE 库存与领用可追溯,支持批量导入/扫码领用,领用审批通过率/库存周转可统计。
- 看板能够展示关键指标(体检合格率、异常率、PPE 库存预警)。
- 与 HR 的接口联通(员工同步)并能定期自动导入体检结果或收发体检任务通知。
验收 KPI(建议):
- 入职体检资料上传合格率 >= 98%(系统能存档)
- 异常工单 48 小时内响应率 >= 95%
- PPE 领用流程电子化率 >= 90%
- EHS 管理员手动工单减少至少 50%
十、部署、运维与扩展建议
- 备份策略:数据库每日冷备份、binlog 增量备份,文件存储多区域冗余。
- 审计与合规:所有健康数据访问做审计日志,定期导出给合规团队。
- 高可用:生产环境至少 2 个 app 实例 + 数据库主备或集群,Redis 主从。
- 权限与 SSO:推荐接入企业统一身份 SSO,减少密码管理风险。
- 第三方对接:体检机构、考勤、HR、MES。建议做接入手册,给第三方提供标准接口(JSON/CSV)。
- 扩展场景:职业病评估、职业健康证管理、现场门禁异常联动(如高风险岗位体检异常自动限制上岗)。
十一、FAQ(每个不少于 100 字)
FAQ 1:体检结果来自不同医院、格式差异大,如何可靠地导入并判断异常?
很多企业遇到的痛点就是体检机构给的数据格式不统一:有的直接给 PDF,有的给 Excel 或结构化接口。实战建议是走两条路:一是优先接入体检机构的结构化 API(或约定 CSV 模板),实现自动化导入;二是对于仍然是 PDF 的机构,先做人工+半自动化(OCR 提取关键指标)并由 EHS/医务人员审核后入库。异常判定不要把阈值写死在代码里,应做成可配置规则(rule table),可以按性别、年龄段、岗位不同阈值,同时支持人工复核机制。最稳妥的方式是“自动判定 + 人工确认”,这样既高效又减少误报、漏报风险。
FAQ 2:员工健康数据很敏感,怎么保证隐私又能方便管理?
健康数据属于高度敏感信息,因此系统设计时必须把“最小权限原则”放第一位:只有经过授权的角色(如 EHS 管理员、授权医务人员)可以查看完整报告,普通主管只能看到是否合格/是否有异常的结论性字段。对外导出时默认进行脱敏(姓名可替换为工号/或部分掩码),并记录所有访问日志。传输和存储均使用 TLS + 对象存储加密(SSE)。同时建议建立数据保留策略(例如体检报告保存期限、敏感字段加密保存等),并在员工入职手册中说明数据使用范围、获得员工同意,满足法律合规要求。
FAQ 3:PPE(劳保用品)经常乱领、库存不准,系统如何落地减少浪费?
落地上要把“场景”想清楚。首先,把领用流程线上化,所有领用要有申请、审批、出库三步并记录用途与岗位。其次,启用“扫码领用”或“发放台账”,领用时绑定员工工号和目的,仓库发放时核验并签字确认,现场主管审批异常用量。系统引入周期性库存盘点(盘点单模块),并与采购结合,当库存低于安全库存自动触发采购预警。最后,可以把领用和考勤/岗位绑定:对于高耗材岗位按班次或周期自动分配,减少随意领用。通过流程+扫码+审批三管齐下,能显著减少浪费。
收尾与可交付清单(给实施团队)
- 技术交付:代码仓库 + SQL 建表脚本 + 部署文档。
- 业务交付:字段与流程定义文档(体检字段、异常阈值、审批流程) + 接口文档(HR/体检机构/门禁)。
- 运维交付:备份策略、灾备方案、监控告警清单。
- 培训与变更:EHS、HR、仓库用户操作手册与 1-2 次线上培训。