很多企业把 EHS 当成“合规件”,但危废管理如果做不好,会带来三类问题:监管处罚(罚款/停产)、环境与安全风险(泄漏、火灾、污染)、以及成本与记录缺失(处置费、运输费、回溯困难)。一个能上线、能落地、能给运营人用的危废管理模块,不只是“把表单搬到线上”,而是实现从入库→存储→出库→处置→档案追溯→看板治理的闭环管理。这样既合规又能降本增效,发生事故时也能快速响应并提供证据链。
本文你将了解
简单说,危废品管理板块负责对企业产生、存放、转运、处置危险废物的全生命周期管理。核心功能包括但不限于:
下面把你列出的关键对象分开说明需要实现的字段与业务点。




主流程(入库→库存→出库→处置→归档):
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[监管材料/报表导出]这个流程强调审批、照片与证据链、以及处置证明的回传。
推荐技术栈(企业常用,易部署):
架构(简化文字图):
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。
重点:库存采用批次+容器模型,便于追踪哪个容器在哪个位置,有多少量。
下面是一个整合的示例代码块,包含:数据库建表(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 (
<form onSubmit={handleSubmit}>
<label>危废类型ID:<input value={catalogId} onChange={e=>setCatalogId(e.target.value)} /></label><br/>
<label>数量:<input value={quantity} onChange={e=>setQuantity(e.target.value)} /></label><br/>
<label>产生单位/人:<input value={producer} onChange={e=>setProducer(e.target.value)} /></label><br/>
<label>照片:<input type="file" multiple onChange={e=>setPhotos(Array.from(e.target.files))} /></label><br/>
<button type="submit">提交入库申请</button>
</form>
);
}
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 <div>加载中...</div>;
return (
<div>
<h3>看板</h3>
<div>批次数量:{summary.totals.batch_count}</div>
<div>总库存:{summary.totals.total_qty}</div>
<div>超期批次:{summary.overdue}</div>
</div>
);
}以上代码是精简示例:在生产环境中,你需要补充认证中间件、文件上传逻辑(S3/MinIO)、错误统一处理、参数校验(Joi)、日志、单元/集成测试、并发控制与更多安全控制。
在这里我给大家推荐一个业务人员就能够直接上手的高性价比、零代码平台——简道云EHS 健康安全环境管理系统,简道云背靠国内BI龙头帆软,在数据处理、数据展示上的能力有绝对优势,数据分析支持高度自定义,任何分析需求都可以快速制作仪表盘,简道云EHS 健康安全环境管理系统涵盖了核心 8 大业务模块,高效全面地满足安全管理核心需求
部署并稳定运行 3 个月后,通常可以看到:
看板可定期展示:实时库存、超期告警、月度处置费用、合规证书到期提醒。
Q1:如何保证出库审批时不会出现库存不足或被多次出库的情况?
要保证库存一致性,关键在于两个层面:数据库事务与业务设计。出库审批(最终扣减库存)必须在数据库事务中执行,并在读取批次时使用行级锁(例如 PostgreSQL 的 SELECT ... FOR UPDATE)或使用乐观锁(在批次表加 version 字段,每次更新带上旧版本号,若不匹配则重试)。另外,业务上要把“申请出库”和“审批通过扣减库存”这两步分开:申请阶段只创建待审批记录,审批通过后在同一事务内读取批次、校验、扣减并写审批记录与运输单。对高并发场景,建议在出库申请阶段预占库存(锁表或写预占记录),并结合重试与补偿机制,确保不会因并发导致超卖或扣减失败。
Q2:现场用手机拍照入库,会不会造成照片过大、上传失败或证据链不完整?如何处理?
现场拍照确实常见问题:照片分辨率大、网络卡顿、重复上传、文件名重复等。实务上建议做三件事:一是手机端先做本地压缩(例如按 1024px 宽度重采样并保持 EXIF),二是实现断点/分片上传与重试,避免因网络中断导致上传失败;三是上传后在后端做完整性校验(MD5)并生成缩略图、存储原始文件的 URL 与 MD5 到 attachments 表。还要记录拍照时间和上传用户 ID,保证证据链的完整性。若需要更强的证据链可以考虑加盖数字签名或使用可信时间戳服务。
Q3:如何管理外部处置公司的资质与处置回执?是否要与对方系统对接?
处置公司档案要做到“可审计”:包括资质证书(上传文件且定期复验)、合同信息、联系人、历史处置记录与评分。对处置回执,最稳妥的做法是与核心处置公司建立 API 对接:当公司收到危废并处置后,把处置证明(电子处置单 + 照片 + 经办人签名)通过 API 回传到你的系统,系统自动入库并和对应出库记录关联。如果无法对接,则需要线下约定:处置公司必须在规定时间内上传处置凭证(扫描件或拍照),并由环保人员核验后标记为“已处置”。对关键合作方,建议在合同条款里写明处置回执的电子化时限与责任,未按时提供的要承担违约责任。对接 API 时需做鉴权(例如 OAuth2 或 API Key)、IP 白名单与 SSL。
做危废管理不是一次性上线表单,而是推动业务变更:要让产线人员愿意按新流程做(少改动、更方便),要把“拍照+扫码+自动生成单号+移动端入库”做到能替代他们以前的纸质工作。建议第一阶段把核心流程做通(入库→审批→出库→处置),第二阶段加上看板与告警,第三阶段做外部对接与报表自动化。实践中多和环保/安全/物流同学一起迭代两轮,就能把系统做成“好用且合规”的工具。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。