很多企业把 EHS 当成“合规件”,但危废管理如果做不好,会带来三类问题:监管处罚(罚款/停产)、环境与安全风险(泄漏、火灾、污染)、以及成本与记录缺失(处置费、运输费、回溯困难)。一个能上线、能落地、能给运营人用的危废管理模块,不只是“把表单搬到线上”,而是实现从入库→存储→出库→处置→档案追溯→看板治理的闭环管理。这样既合规又能降本增效,发生事故时也能快速响应并提供证据链。
本文你将了解
- 危废品管理是什么(定义 + 范围)
- 目标与 KPI(做这个模块要达成的业务目标)
- 功能清单(入库单/出库单/档案/处理公司/看板等)
- 业务流程(文字说明 + 流程图)
- 系统架构(技术选型 + 架构图)
- 数据库设计(核心表结构)
- 后端 API 设计与示例代码(整合在代码块)
- 前端关键页面示例(入库单表单、出库单、档案管理、看板)
- 开发技巧与注意点(事务、并发、图片/附件、法务合规)
- 测试、上线与部署建议
- 实施后能看到的效果(KPI)
- FAQ
- 整合代码块(SQL + Node.js(Express) + React 关键片段)
一、到底什么是 EHS 系统中的“危废品管理板块”?
简单说,危废品管理板块负责对企业产生、存放、转运、处置危险废物的全生命周期管理。核心功能包括但不限于:
- 危废品目录与档案(种类、代码、毒性、包装、储存条件)
- 危废入库单(产生来源、数量、容器、照片、MSDS)
- 危废出库单(用途、处置/外转、运输单号、承运单位)
- 危废库存管理(按照批次、位置、容器管理)
- 危废处理公司档案(资质、联系方式、历史处置记录)
- 处置记录与证书管理(处置合同、处置凭证、电子签名)
- 看板与统计(库存、超期、超量告警、处置率、费用)
- 联动功能:事故模块、风险评估、巡检/隐患模块、财务(费用核算)
- 审批与合规:多级审批、打印合规单据、上报监管平台
二、核心功能(拆细)
下面把你列出的关键对象分开说明需要实现的字段与业务点。
1) 危废品档案(核心元数据)
- 编号(auto)
- 危废名称、国际/国家编码(如国家危废代码)
- 危废类别(有毒、易燃、腐蚀等)
- 单位(kg、L)
- MSDS(材料安全数据表)文件
- 包装要求、贮存条件
- 危废处置建议
- 合法处置公司推荐
2) 危废品入库单
- 单号(自动生成)
- 产生时间、产生部门/工段、产生人
- 危废品档案引用、批次号
- 入库数量、容器编号、容器照片
- 存放仓位(仓库、货位)
- 是否临时存放(超期报警)
- 关联事故/隐患(可选)
- 审批流(申请-环保主管-安全经理-归档)
3) 危废品出库单
- 单号、出库时间、出库原因(处置/外运/回收)
- 处置单位(处理公司档案引用)
- 运输车辆/运输单号、承运人信息
- 出库数量、剩余库存校验
- 处置回执附件(处置单位上传的处置证明)
4) 危废处理公司档案
- 公司名称、资质证书(图片/文件)
- 联系人、电话、价格/合同条款
- 历史处置记录、评分/合规状态
5) 管理看板
- 当前库存(按品种/仓位/单位)
- 超期/超标报警(按生成时间或保质期)
- 月度处置率、处置费用
- 最近处置记录与合同到期提醒
- 风险热区(按分厂/车间统计)
三、业务流程(文字 + 流程图)
主流程(入库→库存→出库→处置→归档):
Mermaid 流程图(可复制到支持 mermaid 的编辑器查看):
mermaid
flowchart TD
A[产生危废] --> B[填写入库单(拍照+MSDS)]
B --> C{审批}
C -->|通过| D[入库,生成库存记录]
C -->|驳回| E[通知产生人修改]
D --> F[库存监控&看板]
F --> G[出库申请(处置/外运)]
G --> H{审批与承运确认}
H -->|通过| I[出库,生成运输单]
I --> J[处置单位处置并上传处置证明]
J --> K[归档,更新处置率]
K --> L[监管材料/报表导出]
这个流程强调审批、照片与证据链、以及处置证明的回传。
四、系统架构(技术栈建议 + 架构图)
推荐技术栈(企业常用,易部署):
- 后端:Node.js + Express 或 NestJS(如果项目要大规模扩展推荐 NestJS)
- 数据库:PostgreSQL(事务强、地理/JSON 支持好)或 MySQL
- ORM:Knex / Sequelize / TypeORM
- 文件存储:对象存储(S3 或企业内 MinIO),元数据存在数据库
- 前端:React + Ant Design(企业表单/表格快),或 Vue + Element
- 移动采集:React Native 或 微信小程序(现场手机拍照入库)
- 审计与日志:ELK / Loki + Grafana
- 身份认证:SSO / OAuth2 / JWT
- 审批流:内置简单多级审批,或接入 BPM(Activiti、Flowable)如果流程复杂
架构(简化文字图):
css
[移动端/PC端] <---> [前端 App/SPA] <---> [API 网关] <---> [EHS 后端 (Express)] <---> [Postgres]
\
-> [对象存储 (MinIO/S3)]
-> [消息队列 (RabbitMQ)](告警/异步任务)
-> [第三方:处置公司系统/监管上报]
五、数据库设计(核心表)
提供核心表的 SQL 建表样例(在整合代码块中会有)。关键表:waste_catalog、waste_batch(库存批次)、waste_inbound、waste_outbound、waste_companies、attachments、approval_records。
重点:库存采用批次+容器模型,便于追踪哪个容器在哪个位置,有多少量。
六、开发技巧与实践建议(干货)
- 事务与库存扣减:出库时必须在数据库事务内完成库存校验与扣减,避免并发超卖。对于高并发,考虑乐观锁(version 字段)或数据库行锁(SELECT FOR UPDATE)。
- 批次追踪:入库时生成批次号,所有出库/处置记录都关联批次,便于追溯。批次级别包含:产生时间、产生来源(工单/事故/工段)、容器ID。
- 附件与照片:附件存对象存储,数据库仅保存 URL 与元数据。入库强制拍照且建议自动生成缩略图与 MD5 校验,便于证据链完整。
- 合规数据导出:支持 PDF/Excel 导出合规单据(可直接打印给监管)。导出逻辑把关键字段与附件链接嵌入模板。
- 审批流要灵活:允许按组织/产线自定义审批节点。简单项目可以内置“部门主管→环保主管→安全经理”三级审批。
- 告警与到期管理:基于批次生成“超期告警”,放到消息队列异步处理,告警渠道:邮件、短信、企业微信、系统内消息。
- 数据权限与多租户:按厂区/公司做数据隔离;实现行级权限(用户只能看到自己负责的仓库或工段)。
- 外部接口:提供处置公司对接 API(推送出库单、接收处置回执),以及可选的监管平台上报接口。
- 移动端优化:手机拍照上传要先做本地压缩、断点续传、网络不稳定时支持离线缓存并在网络恢复后同步。
- 安全合规:附件敏感信息要做访问控制;日志与审计(谁在什么时间做了什么操作)必须完整,满足监管查证要求。
七、整合代码(一大块,便于复制运行)
下面是一个整合的示例代码块,包含:数据库建表(Postgres SQL)、Node.js 后端(Express + Knex)、以及 React 前端关键片段。注意:这是示例可运行骨架,生产需根据公司技术栈调整(认证、中间件、错误处理、文件存储改为 S3/MinIO、加密等)。
sql
-- db_schema.sql (Postgres)
CREATE TABLE users (
id serial PRIMARY KEY,
username varchar(64) UNIQUE NOT NULL,
display_name varchar(128),
role varchar(32),
created_at timestamptz DEFAULT now()
);
CREATE TABLE waste_catalog (
id serial PRIMARY KEY,
code varchar(64) UNIQUE NOT NULL, -- 企业/国家编码
name varchar(200) NOT NULL,
category varchar(64), -- 易燃/腐蚀/有毒
unit varchar(16) DEFAULT 'kg',
msds_url text,
storage_req text,
created_at timestamptz DEFAULT now()
);
CREATE TABLE waste_batch (
id serial PRIMARY KEY,
batch_no varchar(64) UNIQUE NOT NULL,
catalog_id int REFERENCES waste_catalog(id),
quantity numeric(14,4) NOT NULL,
unit varchar(16) DEFAULT 'kg',
container_no varchar(64),
location varchar(128),
produced_by varchar(128),
produced_at timestamptz,
status varchar(32) DEFAULT 'IN_STOCK', -- IN_STOCK, OUT, DISPOSED
created_at timestamptz DEFAULT now()
);
CREATE TABLE waste_inbound (
id serial PRIMARY KEY,
in_no varchar(64) UNIQUE NOT NULL,
batch_id int REFERENCES waste_batch(id),
catalog_id int REFERENCES waste_catalog(id),
quantity numeric(14,4) NOT NULL,
unit varchar(16),
producer varchar(128),
photos jsonb, -- [{url:, md5:}]
approval_status varchar(32) DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED
created_by int REFERENCES users(id),
created_at timestamptz DEFAULT now()
);
CREATE TABLE waste_outbound (
id serial PRIMARY KEY,
out_no varchar(64) UNIQUE NOT NULL,
batch_id int REFERENCES waste_batch(id),
catalog_id int REFERENCES waste_catalog(id),
quantity numeric(14,4) NOT NULL,
to_company_id int REFERENCES waste_companies(id),
transport_no varchar(128),
approval_status varchar(32) DEFAULT 'PENDING',
created_by int REFERENCES users(id),
created_at timestamptz DEFAULT now()
);
CREATE TABLE waste_companies (
id serial PRIMARY KEY,
name varchar(256) NOT NULL,
license_url text,
contact_name varchar(128),
contact_phone varchar(64),
rating int DEFAULT 5,
created_at timestamptz DEFAULT now()
);
CREATE TABLE attachments (
id serial PRIMARY KEY,
ref_table varchar(64),
ref_id int,
url text,
md5 varchar(64),
created_at timestamptz DEFAULT now()
);
CREATE TABLE approval_records (
id serial PRIMARY KEY,
ref_table varchar(64),
ref_id int,
approver_id int REFERENCES users(id),
action varchar(32), -- APPROVE, REJECT
comment text,
created_at timestamptz DEFAULT now()
);
javascript
// backend/index.js (Node.js + Express + Knex)
const express = require('express');
const bodyParser = require('body-parser');
const Knex = require('knex');
const { v4: uuidv4 } = require('uuid');
const knex = Knex({
client: 'pg',
connection: process.env.DATABASE_URL || 'postgres://user:pass@localhost:5432/ehs'
});
const app = express();
app.use(bodyParser.json());
// util
function genNo(prefix='IN') {
const t = new Date().toISOString().replace(/[-:T.]/g,'').slice(0,14);
return `${prefix}${t}${Math.floor(Math.random()*900+100)}`;
}
// 1. 新建危废档案
app.post('/api/catalog', async (req, res) => {
const { code, name, category, unit, msds_url, storage_req } = req.body;
try {
const [row] = await knex('waste_catalog').insert({
code, name, category, unit, msds_url, storage_req
}).returning('*');
res.json({ success: true, data: row });
} catch (err) {
console.error(err);
res.status(500).json({ success:false, message: err.message });
}
});
// 2. 入库申请(含创建批次)
app.post('/api/inbound', async (req, res) => {
const { catalog_id, quantity, unit, producer, produced_at, photos, created_by } = req.body;
const trx = await knex.transaction();
try {
const batch_no = `BATCH-${Date.now()}-${Math.floor(Math.random()*900+100)}`;
const [batch] = await trx('waste_batch').insert({
batch_no, catalog_id, quantity, unit, produced_by: producer, produced_at
}).returning('*');
const in_no = genNo('IN');
const [inRec] = await trx('waste_inbound').insert({
in_no, batch_id: batch.id, catalog_id, quantity, unit, producer, photos: JSON.stringify(photos), created_by
}).returning('*');
await trx.commit();
res.json({ success:true, data: { batch, inRec }});
} catch (err) {
await trx.rollback();
console.error(err);
res.status(500).json({ success:false, message: err.message });
}
});
// 3. 审批入库(简单示例)
app.post('/api/inbound/:id/approve', async (req, res) => {
const id = req.params.id;
const { approver_id, action, comment } = req.body; // action: APPROVE/REJECT
try {
await knex.transaction(async trx => {
await trx('waste_inbound').where({ id }).update({ approval_status: action==='APPROVE' ? 'APPROVED':'REJECTED' });
await trx('approval_records').insert({
ref_table: 'waste_inbound', ref_id: id, approver_id, action: action==='APPROVE'?'APPROVE':'REJECT', comment
});
});
res.json({ success:true });
} catch (err) {
console.error(err);
res.status(500).json({ success:false, message: err.message });
}
});
// 4. 出库申请(并校验库存)
app.post('/api/outbound', async (req, res) => {
const { batch_id, catalog_id, quantity, to_company_id, transport_no, created_by } = req.body;
const trx = await knex.transaction();
try {
// 锁定批次数量
const batch = await trx('waste_batch').where({ id: batch_id }).forUpdate().first();
if (!batch) throw new Error('batch not found');
if (parseFloat(batch.quantity) < parseFloat(quantity)) throw new Error('库存不足');
// 插入出库记录(待审批)
const out_no = genNo('OUT');
const [outRec] = await trx('waste_outbound').insert({
out_no, batch_id, catalog_id, quantity, to_company_id, transport_no, created_by
}).returning('*');
// 如果审批模型是自动直接减库存,则在审批通过时减库存。这里只是示例:不做减库存
await trx.commit();
res.json({ success:true, data: outRec });
} catch (err) {
await trx.rollback();
console.error(err);
res.status(500).json({ success:false, message: err.message });
}
});
// 5. 出库审批通过,真正扣减库存并生成处置记录
app.post('/api/outbound/:id/approve', async (req, res) => {
const id = req.params.id;
const { approver_id, action, comment } = req.body;
try {
await knex.transaction(async trx => {
const outRec = await trx('waste_outbound').where({ id }).first();
if (!outRec) throw new Error('outbound not found');
if (action !== 'APPROVE') {
await trx('waste_outbound').where({ id }).update({ approval_status: 'REJECTED' });
} else {
// 扣减批次数量
const batch = await trx('waste_batch').where({ id: outRec.batch_id }).forUpdate().first();
const newQty = parseFloat(batch.quantity) - parseFloat(outRec.quantity);
await trx('waste_batch').where({ id: batch.id }).update({
quantity: newQty,
status: newQty <= 0 ? 'OUT' : batch.status
});
await trx('waste_outbound').where({ id }).update({ approval_status: 'APPROVED' });
}
await trx('approval_records').insert({
ref_table: 'waste_outbound', ref_id: id, approver_id, action: action==='APPROVE'?'APPROVE':'REJECT', comment
});
});
res.json({ success:true });
} catch (err) {
console.error(err);
res.status(500).json({ success:false, message: err.message });
}
});
app.get('/api/dashboard/summary', async (req, res) => {
// 简单看板示例
const totals = await knex('waste_batch').select(
knex.raw('count(*) as batch_count'),
knex.raw('sum(quantity) as total_qty')
).first();
// 超期示例(假设 produced_at + 30 days 为超期)
const overdue = await knex('waste_batch').whereRaw("produced_at < now() - interval '30 days'").count();
res.json({ success:true, data: { totals, overdue: parseInt(overdue[0].count || 0) }});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, ()=> console.log('server running', PORT));
jsx
// 前端 React 关键片段(入库表单 + 看板请求)
import React, { useState } from 'react';
function InboundForm({ userId }) {
const [catalogId, setCatalogId] = useState('');
const [quantity, setQuantity] = useState('');
const [producer, setProducer] = useState('');
const [photos, setPhotos] = useState([]);
async function uploadPhoto(file) {
// 简化:假设后端有 /api/upload 返回 {url}
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/api/upload', { method:'POST', body:fd });
const j = await r.json();
return j.url;
}
async function handleSubmit(e) {
e.preventDefault();
const photoUrls = [];
for (const f of photos) {
const url = await uploadPhoto(f);
photoUrls.push({ url });
}
const payload = {
catalog_id: catalogId,
quantity,
unit: 'kg',
producer,
produced_at: new Date().toISOString(),
photos: photoUrls,
created_by: userId
};
const res = await fetch('/api/inbound', {
method:'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.success) {
alert('入库申请已提交');
} else {
alert('提交失败:' + data.message);
}
}
return (
危废类型ID:setCatalogId(e.target.value)} />
数量:setQuantity(e.target.value)} />
产生单位/人:setProducer(e.target.value)} />
照片:setPhotos(Array.from(e.target.files))} />
提交入库申请
);
}
export default function Dashboard() {
const [summary, setSummary] = useState(null);
React.useEffect(()=> {
fetch('/api/dashboard/summary').then(r=>r.json()).then(j=> setSummary(j.data));
}, []);
if (!summary) return
加载中...
;
return (
看板
批次数量:{summary.totals.batch_count}
总库存:{summary.totals.total_qty}
超期批次:{summary.overdue}
);
}
以上代码是精简示例:在生产环境中,你需要补充认证中间件、文件上传逻辑(S3/MinIO)、错误统一处理、参数校验(Joi)、日志、单元/集成测试、并发控制与更多安全控制。
在这里我给大家推荐一个业务人员就能够直接上手的高性价比、零代码平台——简道云EHS 健康安全环境管理系统,简道云背靠国内BI龙头帆软,在数据处理、数据展示上的能力有绝对优势,数据分析支持高度自定义,任何分析需求都可以快速制作仪表盘,简道云EHS 健康安全环境管理系统涵盖了核心 8 大业务模块,高效全面地满足安全管理核心需求
八、实现效果与 KPI(落地后能量化的收益)
部署并稳定运行 3 个月后,通常可以看到:
- 处置合规率提升到 >95%(所有出库必须关联处置证明)
- 处置回执回收率提升(原来纸质丢失率高,现在电子回收率 >90%)
- 库存盘点效率提升 60%(扫码/批次管理)
- 降低监管风险与罚款(量化困难,但能通过减少不合规记录体现)
- 人工报表与导出时间从几天降到几分钟
看板可定期展示:实时库存、超期告警、月度处置费用、合规证书到期提醒。
九、测试、上线与部署建议(实操)
- 单元测试与集成测试:对关键事务(出库审批扣减库存)写集成测试,模拟并发出库请求验证无超卖。
- UAT(用户验收):找环保/安全/物流三类真实业务用户做 2 周 UAT,收集审批节点与表单字段的微调需求。
- 迁移与历史数据导入:如果有历史 Excel 表,写 ETL 脚本导入,并保留原始文件作为附件。
- 灰度上线:先在一个厂区或车间上线,观察 1 个月再全厂推广。
- 培训与 SOP:写简明 SOP(1-2 页)教现场如何拍照、如何填写入库单、如何触发出库与处置流程。
- 备份与应急:数据库日备份与对象存储备份策略(30 天冷备),并演练恢复。
十、FAQ(每条 ≥100 字)
Q1:如何保证出库审批时不会出现库存不足或被多次出库的情况?
要保证库存一致性,关键在于两个层面:数据库事务与业务设计。出库审批(最终扣减库存)必须在数据库事务中执行,并在读取批次时使用行级锁(例如 PostgreSQL 的 SELECT ... FOR UPDATE)或使用乐观锁(在批次表加 version 字段,每次更新带上旧版本号,若不匹配则重试)。另外,业务上要把“申请出库”和“审批通过扣减库存”这两步分开:申请阶段只创建待审批记录,审批通过后在同一事务内读取批次、校验、扣减并写审批记录与运输单。对高并发场景,建议在出库申请阶段预占库存(锁表或写预占记录),并结合重试与补偿机制,确保不会因并发导致超卖或扣减失败。
Q2:现场用手机拍照入库,会不会造成照片过大、上传失败或证据链不完整?如何处理?
现场拍照确实常见问题:照片分辨率大、网络卡顿、重复上传、文件名重复等。实务上建议做三件事:一是手机端先做本地压缩(例如按 1024px 宽度重采样并保持 EXIF),二是实现断点/分片上传与重试,避免因网络中断导致上传失败;三是上传后在后端做完整性校验(MD5)并生成缩略图、存储原始文件的 URL 与 MD5 到 attachments 表。还要记录拍照时间和上传用户 ID,保证证据链的完整性。若需要更强的证据链可以考虑加盖数字签名或使用可信时间戳服务。
Q3:如何管理外部处置公司的资质与处置回执?是否要与对方系统对接?
处置公司档案要做到“可审计”:包括资质证书(上传文件且定期复验)、合同信息、联系人、历史处置记录与评分。对处置回执,最稳妥的做法是与核心处置公司建立 API 对接:当公司收到危废并处置后,把处置证明(电子处置单 + 照片 + 经办人签名)通过 API 回传到你的系统,系统自动入库并和对应出库记录关联。如果无法对接,则需要线下约定:处置公司必须在规定时间内上传处置凭证(扫描件或拍照),并由环保人员核验后标记为“已处置”。对关键合作方,建议在合同条款里写明处置回执的电子化时限与责任,未按时提供的要承担违约责任。对接 API 时需做鉴权(例如 OAuth2 或 API Key)、IP 白名单与 SSL。
十一、结语
做危废管理不是一次性上线表单,而是推动业务变更:要让产线人员愿意按新流程做(少改动、更方便),要把“拍照+扫码+自动生成单号+移动端入库”做到能替代他们以前的纸质工作。建议第一阶段把核心流程做通(入库→审批→出库→处置),第二阶段加上看板与告警,第三阶段做外部对接与报表自动化。实践中多和环保/安全/物流同学一起迭代两轮,就能把系统做成“好用且合规”的工具。