1. 为什么我还在用ThinkPHP+Layui做ERP,而不是前后端分离
先说结论:如果你要做的是一套企业内部的ERP管理系统,预算有限、交付周期紧、维护的人可能就一两个,那ThinkPHP+Layui这套组合到今天依然能打。
很多人一听到ThinkPHP就觉得老,一听到Layui就想起它停止维护的新闻。但ERP这种业务系统,核心诉求从来不是技术栈够不够新,而是业务逻辑能不能跑通、权限控不控得住、报表算不算得对。我这两年用ThinkPHP 6 + Layui 2.8落地过几套ERP,从仓库出入库到生产工单再到成本核算,总体感受是:开发效率确实高,踩坑也确实不少,但每个坑基本都有解。
这套组合适合谁?适合那些要快速交付、业务变动频繁、部署环境不可控(比如客户服务器还是PHP 7.3)的项目。也适合刚入行不久、想完整走一遍“数据库设计→接口→后台界面→权限控制”全流程的人。你要真做个千万级并发的SaaS,那确实不该选它;但90%企业的内部管理系统,并发撑死几十个人同时用,ThinkPHP完全扛得住。
Layui这边的优势更实在:它对后端开发者几乎没有门槛,不需要会Vue、Webpack那一套,下载一个静态资源文件丢进去就能用。table组件的自动渲染配合ThinkPHP的分页接口,十分钟就能出一个带搜索、分页、排序的列表页。对于ERP这种满是表单、表格、弹窗的系统,Layui的组件覆盖度非常高。
我的建议是:别纠结“技术落不落后”,先想清楚你这个系统要解决什么问题、谁来维护、业务方能不能说清楚需求。这三件事比选什么框架重要一百倍。
2. 整体架构与数据库设计,决定了后面80%的坑
2.1 项目目录规划与分层思路
用ThinkPHP 6做ERP,我建议按模块(应用)来划分,而不是把所有的控制器堆在app/controller下面。我常用的目录结构大概是这样的:
app/ ├─ admin/ 后台管理端(ERP主界面) │ ├─ controller/ │ ├─ model/ │ └─ view/ ├─ api/ 对外接口(对接PDA、小程序、第三方系统) ├─ common.php 公共函数 ├─ middleware.php 中间件注册 └─ provider.phpadmin负责后台页面渲染和表单提交,api负责输出JSON给手持终端或者别的系统调用。为什么要拆?因为ERP后期极大概率要对接硬件设备——PDA扫码枪、电子秤、立库系统——这些设备不会打开你的Layui页面去操作,它们只认接口。你要是把接口和后台页面混在一起,后期对接会非常痛苦。
另一个关键点是模型层的设计。ThinkPHP的Model不只是操作数据库的类,我习惯把业务规则也写在模型里。比如“出库单审核”这个动作,它不是一个简单的update语句,而是一串事务操作:
// 出库单审核 public function audit($orderId) { Db::startTrans(); try { // 1. 更新出库单状态 // 2. 扣减库存台账 // 3. 写入库存流水 // 4. 若关联生产工单,回写工单状态 Db::commit(); } catch (\Throwable $e) { Db::rollback(); // 记录日志 } }这种把业务动作封装在模型层的方式,能让控制器保持很薄,后期加字段、改逻辑也只在模型里面动,不至于一个控制器几百行连看都不敢看。
2.2 数据库设计:从“能用”到“扛造”
ERP系统的表结构设计,我总结了一个套路:基础资料表、业务单据表、流水表、关联关系表,各司其职。
基础资料表就是物料表、客户表、供应商表、仓库表、BOM表这类。这类表的特征是相对稳定,但字段特别多。以物料表为例,除了编码、名称、规格、单位这些基础字段,还有默认仓库、默认供应商、采购提前期、安全库存、计价方式(移动平均/全月平均)这些跟业务相关的字段。
业务单据表是核心,包括采购订单、采购入库单、生产工单、生产领料单、成品入库单、销售订单、销售出库单、盘点单、调拨单等。这类表要遵守几个原则:单号唯一且可读,状态字段独立(草稿/已审核/已执行/已关闭),表头表体分离(主表和明细表分开)。
流水表是我的习惯做法,每张业务单据审核后必须写一条库存流水:
id, 单据类型, 单据编号, 物料ID, 仓库ID, 变动数量, 变动前库存, 变动后库存, 操作人, 操作时间这样后期要查“某个物料某段时间的出入库情况”、要对账、要排查成本差异,直接查流水表就够了,不用去翻业务单据。很多ERP跑着跑着数据对不上,就是因为缺了流水表这张底账。
关联关系表则是处理多对多关系,比如BOM的子项明细、物料与替代料的对应关系、角色与菜单的对应关系。
3. 权限系统:从RBAC到按钮级控制的一次到位设计
3.1 用户-角色-菜单的三层结构
做过几个后台系统的人都清楚,权限设计最忌讳的是“先做个简单的,后面再补”。因为权限模型一旦定下来,所有菜单、接口、页面都得按它来开发,返工成本极高。我建议第一次做就直接上完整的RBAC:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。
菜单表是关键,我习惯用树形结构存储,节点字段包括:
id, parent_id, title, icon, href, component, status, sort, typetype区分目录、菜单、按钮三级。目录和菜单控制左侧导航栏显示什么,按钮控制页面里每个操作的可见性。比如“采购订单”这个菜单下,“新增”“审核”“导出”“删除”这些按钮各自是一条权限记录,角色勾选了哪个按钮,用户登录后才看得到哪个按钮。
前端实现上,Layui的左侧菜单是动态生成的。登录后从后端拿当前用户的菜单树,渲染成侧边栏。按钮权限我用的是自定义指令的方式——在Layui的table工具栏里,根据当前用户权限数组筛选显示哪些按钮,或者用一个类名加data-permission属性来控制:
<button class="layui-btn layui-btn-sm">#[ApiPermission('purchase/order/audit')] public function audit() { // 审核逻辑 }中间件里拦截请求,解析当前路由对应的权限标识,再去判断当前登录用户是否拥有该权限。这里有个小技巧:权限标识我直接用“控制器/方法”的路径字符串,比如purchase/order/audit,这样后面要维护权限列表的时候,代码和数据库能一一对应,不会出现“数据库里有一条权限但不知道对应哪个接口”的情况。
实际开发中还有一个高频问题:权限改了之后用户那边不生效。我一般是在用户登录时把权限列表缓存到session里,并且提供“刷新权限缓存”的按钮,让管理员在后台一键清除所有在线用户的权限缓存。
4. Layui前端的几个核心实操点
4.1 table组件与ThinkPHP分页接口的无缝对接
Layui的table组件默认请求方式、参数名、返回格式跟ThinkPHP的分页类是有差异的,需要做个适配。ThinkPHP的paginate()返回的数据结构里,数据在data字段下,总条数在total字段下,而Layui table默认接受的是code、msg、count、data这种结构。
最简单的做法是统一封装一个JSON返回方法:
public function tableJson($list, $count) { return json([ 'code' => 0, 'msg' => '', 'count' => $count, 'data' => $list ]); }这样前端就不用做额外转换:
table.render({ elem: '#orderTable', url: '/admin/purchase/order/list', page: true, cols: [[ { field: 'order_no', title: '单号', width: 180 }, { field: 'vendor_name', title: '供应商', width: 200 }, { field: 'total_amount', title: '金额', width: 120 }, { field: 'status_text', title: '状态', width: 100, templet: '#statusTpl' } ]] });需要注意一个点:日期时间等格式化操作不要在后端拼好字符串传出来,那样后续维护很麻烦,直接传给前端,在cols配置里用templet做一个单元格模板去格式化。
4.2 select动态赋值的正确姿势
搜索热词里有“layui select动态赋值”,这确实是很多人卡壳的地方。Layui的select是经过美化渲染的自定义组件,直接给原生的select设置value或者用jQuery去val(),界面上不会变,必须调用form.render('select')来重新渲染。
我封装了一个通用函数来处理:
function setSelectVal(filter, val) { // filter是lay-filter的值,val是要选中的值(支持数组,用于多选) if (Array.isArray(val)) { $('select[name="' + filter + '"]').val(val); } else { $('select[name="' + filter + '"]').val(val); } form.render('select'); }还有另一种情况——动态填充select的选项。比如选了供应商之后,自动加载该供应商下的采购员:
form.on('select(vendor)', function(data){ var vendorId = data.value; $.get('/admin/purchase/getBuyers', { vendor_id: vendorId }, function(res){ var html = '<option value="">请选择</option>'; res.data.forEach(function(item){ html += '<option value="' + item.id + '">' + item.name + '</option>'; }); $('#buyer').html(html); form.render('select'); }); });这里贴一个容易踩的坑:如果select的option是根据前面的表单内容联动加载的,而后端返回空数据时没有清空当前select的旧选项,用户就会看到“上一个供应商的采购员还留在下拉列表里”的bug。正确做法是每次请求前先把旧选项清掉,再渲染新的。
4.3 弹窗表单与表单回显
ERP里的单据录入基本都是弹窗完成的。Layui的layer.open配合iframe或者内容模板都行,我习惯用iframe方式,因为表单页通常是独立的一整个页面(尤其是采购订单这种带明细表的表单,一个页面要处理主表和子表的增删改查)。
弹窗打开时如果需要传参(比如编辑某条记录),在url后面拼参数,表单页面用PHP接收并回填:
$id = $this->request->get('id'); if ($id) { $order = PurchaseOrder::find($id); $this->assign('order', $order); }表单页面渲染的时候,主表字段直接value赋值,明细表用table组件加载该单据的明细数据。提交的时候把主表字段和明细数据打包成一个JSON对象,用ajax提交到后端,后端在事务里同时写主表和明细表。
5. ERP核心业务流程的落地
5.1 采购入库:从采购单到仓库台账
整个ERP里最基础也是最重要的流程是“采购入库”。业务流程大概是:采购员创建采购订单(草稿)→ 管理员审核 → 货到了之后仓库在采购订单的基础上做入库操作 → 生成采购入库单,扣减在途数量,增加可用库存 → 更新物料的最近采购价。
这里有一个需要特别注意的点:入库单的金额和数量会影响库存成本。如果用移动平均法计价,每次入库都要重新计算一次移动平均单价:
新成本单价 = (原库存金额 + 本次入库金额) / (原库存数量 + 本次入库数量)这个计算必须在数据库事务里完成,而且要用行级锁锁住物料行,否则并发入库时会出现库存数量和金额对不上的情况。我实际遇到过:两个人同时给同一个物料做入库,因为没锁,最后库存数量对了,但金额差了,查了好久才反应过来是并发导致的计算竞态。
5.2 生产领料与成品入库:工单驱动
有生产的ERP会比纯进销存复杂一个量级。核心是“生产工单”这个概念。销售订单来了之后,计划员创建生产工单,工单里有要生产的成品、数量、计划开工时间和完工时间。工单审核后,系统根据BOM自动生成“应领料清单”。
仓管员按应领料清单发料,允许替换材料(走替代料流程),但最终实际领料数量会跟BOM标准用量有差异。这个差异就叫“用料差异”,月底成本核算时要把差异金额分摊到对应工单的成品成本里。
生产完工后做成品入库,这时候有几个数要对上:工单的计划数量、实际完工数量、良品数量、不良品数量。不良品如果可以做返修,就走返修工单;不能返修的直接报废,走报废出库,把成本从工单里转出去。
5.3 销售出库与应收:把数据闭环走完
销售流程相对采购要简单一些:销售订单 → 审核 → 出库 → 生成应收单 → 收款核销。但这里也有一个容易漏掉的功能——信用额度控制。客户在下单时,系统要判断“该客户未收款金额 + 本次订单金额”是否超过授信额度,超过了就要走特批流程,否则业务员就会无限制给客户下单,最后应收一大堆却收不回来钱。
这个功能很多ERP实际是启用了的,但开发时经常被忽视,等业务跑起来了再补,就得去翻所有下单入口,非常痛苦。
6. 成本核算跑不通?十有八九是这些原因
热搜词里有“成本ERP数据没有跑通原因分析”,我看了很有共鸣。成本模块是ERP里最难啃的骨头,数据跑不通多半是以下几个原因。
6.1 基础资料不完整
物料表里没有维护计价方式、默认仓库、安全库存,BOM表没有维护材料的默认工序,供应商表里没有维护默认税率。这些基础资料缺一项,成本计算的时候就会出现“找不到成本单价”“找不到税率”之类的报错。我建议在上线准备阶段就要做一次基础资料的完整性检查报表,把所有缺关键字段的物料、供应商、BOM全列出来,逐条整改。
6.2 计价方式选择不当
中小企业最常见的两个计价方式是移动平均法和全月平均法。移动平均法适合采购价格波动不大、出入库频繁的场景;全月平均法适合价格波动较大、月底统一核算成本的场景。但很多企业选计价方式的时候根本不看自己的业务特征,选了之后又不愿意改数据,导致成本数据总是“看起来不太对”。
我的经验是:如果企业没有专职的成本会计,就默认用移动平均法,因为不用等到月底才能看到成本数,随时都能查。只有企业明确提出来要月底统一核算,才用全月平均法。
6.3 库存流水缺失或重复
成本计算依赖库存流水。如果出入库操作没走统一接口,而是直接在数据库里改了库存表,流水就断了。反过来说,如果一张入库单被审核两次,流水就重复了。我在开发时给每张业务单据加了“唯一事务ID”,写入流水前先查这个事务ID有没有已经处理过,有就直接跳过。
6.4 费用分摊规则没定义清楚
采购运费、报关费、加工费这些费用如果不在单据里录入,月末成本核算时就无账可分。我建议在采购入库单里加一个“费用分摊”字段,允许用户填运费和杂费,入库时自动把费用金额加到入库成本里。这样成本才是真实的。
7. 常见问题排查与避坑速查表
下面这个表格是我做ERP项目时踩坑经验的总结,基本覆盖了大部分“莫名其妙出问题”的情况:
| 现象 | 排查思路 | 解决方案 |
|---|---|---|
| Layui表格一直loading不显示 | 检查接口返回JSON格式是否匹配 | 统一返回code/msg/count/data结构 |
| select动态赋值无效 | 没有调用form.render('select') | 赋值后必须重新渲染 |
| 分页点击无效 | 参数名或URL配置错误 | 检查page:true和url配置 |
| 数据删不掉或删了没反应 | 外键约束/关联删除未处理 | 用ThinkPHP模型关联配合事务 |
| 权限改了不生效 | 用户Session缓存了旧权限 | 提供“清理权限缓存”按钮 |
| 库存对不上 | 流水丢失或重复 | 加唯一事务ID防重 |
| 成本金额异常 | 移动平均价算错了 | 检查并发锁和事务边界 |
| 二级域名部署后图片打不开 | 静态资源路径用了绝对地址 | 改用相对路径或配置APP_URL |
7.1 ThinkPHP关联删除的正确实操
热搜词里还有一个“ThinkPHP 关联删除”,这也是我早期踩过的坑。ThinkPHP模型支持定义关联,比如订单模型关联订单明细:
class PurchaseOrder extends Model { public function items() { return $this->hasMany(PurchaseOrderItem::class, 'order_id'); } }但如果你直接调用delete()删除订单主表,关联的明细表是不会自动删的,必须在模型事件里处理。我推荐用模型事件的方式,在本质上做“后置删除”:
protected static function onAfterDelete($order) { $order->items()->delete(); // 同时清理相关流水 StockFlow::where('source_type', 'purchase_order') ->where('source_id', $order->id) ->delete(); }注意必须是onAfterDelete,不要用onBeforeDelete,因为如果后续还有其他关联操作(比如删除库存流水)依赖主表记录存在,先删了主表会导致这些操作报错。
7.2 Layui Tabs刷新页面丢失选中状态的解决
ERP系统左侧菜单点击后是在Tabs里打开页面的。默认情况下,菜单重复点击时会重复添加tab,或者刷新后tab全部丢失回到首页。我通常用一个全局变量存储已打开的tabs:
// tabs管理 var tabsArray = []; function addTab(title, url) { var existing = tabsArray.find(function(item){ return item.url === url; }); if (existing) { element.tabChange('mainTabs', existing.id); } else { var id = 'tab_' + Date.now(); element.tabAdd('mainTabs', { title: title, content: '<iframe src="' + url + '" frameborder="0" style="width:100%;height:100%;"></iframe>', id: id }); tabsArray.push({id: id, title: title, url: url}); } element.tabChange('mainTabs', id); }刷新页面后要从URL参数中恢复当前激活的tab,或者提供一个“恢复会话”的接口,把用户上次打开的页面重新构建出来。
7.3 ThinkPHP部署到二级域名的配置要点
打开配置文件config/app.php,把host设置成对应的域名,还有就是URL重写规则要调整。伪静态规则里要把二级域名的路径排除掉,否则会出现“控制器不存在”的报错。
另外要注意:二级域名部署后,接口请求的CORS跨域问题会冒出来。Layui的table组件默认是ajax请求,如果页面在一个域名、接口在另一个域名,就会出现跨域问题。最简单的解决方法是后端加一个跨域中间件,允许指定域名跨域访问。
8. 项目上线后的实战心得与二次开发建议
8.1 多套系统对接的经验
ERP很少是孤立存在的——企业里可能还有MES、WMS、财务软件、电商平台,热搜词里提到了“益模与ERP系统对接方案”,其实就是这一类问题。
对接方案我建议走“接口中间层”,不要让ERP系统直接去请求第三方系统,也不要让第三方系统直接连ERP数据库。中间层负责统一接口协议、做数据格式转换、处理重试和日志记录。这样做的好处是:任何一边的系统升级、换供应商,中间层只需要改对应适配器,不影响整体链路。
接口协议统一用JSON+REST风格,鉴权统一用token(把token放在header里,设置有效期,对接方定时刷新)。对接过程中要注意处理“重复请求”的问题——第三方系统重发同一笔单据时,要能通过唯一键识别出来已处理过,直接返回成功,而不是重复生成一笔单。
8.2 从“能用”到“好用”的几个细节
系统上线后,用户反馈最多的往往不是功能缺失,而是细节不好用。比如列表页的默认排序乱、搜索条件不能保存、导入Excel经常报格式错误、导出大数据量时卡死浏览器。
几个值得优化的方向:
- 列表页记住用户上次的查询条件和每页显示条数,存到localStorage里,下次进入自动带出。
- Excel导入做成模板下载+后端严格校验的模式,先把错误行整理好给用户,告诉用户哪一行哪一列错了,而不是导入了个寂寞。
- 导出功能不要在前端拼接,让后端生成文件后返回下载链接,避免大数据量导出卡死浏览器。
8.3 安全加固:ERP最容易忽略的几件事
ERP系统里有企业最核心的经营数据,安全不做等于裸奔。我最在意的是这几件事:
- 接口鉴权不能只依赖前端传用户ID,一定要服务端从session/token里拿当前登录用户的身份,后台每个写操作都要做操作日志。
- ThinkPHP框架的漏洞披露要及时关注并升级,尤其是那些SQL注入、文件上传类的漏洞,我每次收到官方安全公告都会评估一次当前系统的受影响程度。
- 备份策略要落地,不仅是数据库备份,还包括上传的文件(很多企业ERP里的审批附件、产品图片都在服务器本地,一旦磁盘坏了全没了)。备份要异地多份,并且定期做恢复演练,别等到真出事才发现备份文件是损坏的。
8.4 个人体会:几个可以继续扩展的方向
这套ERP做下来,如果业务稳定了,我建议可以往几个方向扩展:
- 移动端审批:把采购订单审核、销售订单审核、请假审批这些高频动作做成H5页面,让老板在手机上就能批单。
- 看板与报表:把库存预警、应收账款账龄分析、销售毛利分析做成独立看板页面,用图表展示(Layui里可以集成ECharts)。
- 消息通知:审核通过/驳回、库存不足、到期应收这些节点加消息通知(邮件+企业微信/钉钉机器人),减少业务人员反复刷新页面的时间。
我在实际项目中还发现,ERP系统上线最难的不是开发,而是让业务人员真正用它替代原来的Excel表格。关键在于几个点:系统速度和稳定性能做到“打开不卡、操作不报错”;录入页面尽量简洁,能下拉选择的绝不手输;报表口径跟财务对账一致。这三点解决了,业务人员就会慢慢从Excel切到系统上来。
另外分享一个小技巧:上线初期不要一次性开放所有功能,先挑一个痛点最明显的模块(比如库存查询或出入库录单)重点打磨,等用户养成习惯了,再逐步铺开其他模块。这样推广阻力会小很多,开发团队也能集中精力把核心路径做扎实。