☰
ThinkPHP+Layui进销存系统:部署、二次开发与库存管理实战解析
2026/10/27 1:21:09 网站建设 项目流程

简介:点可云进销存系统是一套面向中小企业的开源企业资源计划源码包,基于ThinkPHP与Layui开发,覆盖采购、销售、零售、多仓库管理、财务核算及报表分析等核心供应链环节,适合需要搭建企业级管理系统的开发者与运维人员。压缩包共一千七百余个文件,约二十兆字节,源码以PHP业务逻辑、JavaScript交互、HTML页面、CSS样式及DAT数据文件为主,并附带SQL数据库脚本、安装说明、更新日志和部署批处理文件,便于搭建与二次开发。目前已有110人学习下载,目录结构清晰,完整串联从采购订单、供应商管理、库存盘点、销售开票到资金对账的业务流程,内置超详细报表模块,可生成采购、销售、零售、仓库和资金等多维统计,直接辅助运营决策。通过研读源码,可深入理解ThinkPHP与Layui的应用组合,掌握进销存、ERP系统从数据建模到模块划分的工程化思路,适合毕业设计、技术培训或企业内部管理系统建设参考。

1. 点可云进销存系统:ThinkPHP + Layui 搭建的企业库存与财务一体化方案

进销存系统是中小企业的刚需,但市面上的 SaaS 产品要么按月收费、数据不在自己手里,要么功能臃肿、光权限配置就能劝退一半人。点可云进销存系统走的是另一条路:基于 ThinkPHP 6 + Layui 构建,把采购、销售、零售、多仓库、财务和报表全部打包在一套开源代码里,部署到自己的服务器上,数据完全可控。对于需要私有化部署、又不想从零开发的企业应用项目来说,这套系统的性价比很高。

它适合三类人:一是做传统贸易、需要管多仓库库存的老板;二是接企业定制单的 PHP 开发者,拿它当基础脚手架改业务逻辑;三是高校或培训机构做 ERP 教学演示的教师。系统代码结构清晰,菜单权限、数据字典、操作日志这些企业应用该有的底座都有,更重要的是报表模块做到了按日、按月、按商品、按往来单位多维统计,省掉了大量 SQL 手写工作。接下来我会从技术底座拆到业务模块,最后落到部署踩坑和二次开发技巧。

2. 技术底座拆解:ThinkPHP 6 分层与 Layui 组件化的前后端配合逻辑

2.1 为什么是 ThinkPHP + Layui,而不是前后端分离

先聊选型。thinkphp 在国内中小企业项目中占有率一直不低,它的 ActiveRecord 模型、中间件机制和自带的分页/验证器,让 CRUD 开发效率远高于手写原生 PHP。这套系统用的是 ThinkPHP 6.x,属于 LTS 版本,PHP 7.2+ 即可运行,对服务器要求不高,阿里云 2 核 4G 的机器跑起来毫无压力。对比 Laravel,ThinkPHP 的部署成本更低,不需要 composer install 时拉一堆扩展包,生产环境出错概率小。

Layui 这边则是经典的模块化前端框架。它的亮点不是组件多好看,而是表单、表格、弹层这些后台管理最常用的东西,全封装成了 jQuery 插件,直接在 HTML 里写layui.use(['table','form'])就能用,没有 Webpack、Vite 那套心智负担。进销存系统有大量动态表格——采购单明细、销售单明细、库存流水——Layui 的 table 模块用url属性指向后端接口,再配cols定义列,数据渲染和分页就自动完成了,开发效率确实高。

这套系统选择前后端不分离,对实际部署是加分项:打包后不需要配 Nginx 反向代理到 Node 服务,也不用处理跨域 Cookie 问题,一个域名、一份 PHP 代码,直接开箱跑。我实际部署过不少这种单体应用,上线速度比前后端分离快太多了,特别是客户端急着要验收的时候。

2.2 项目目录结构与核心配置文件

拿到代码包后,第一件事是看目录结构。系统沿用了 ThinkPHP 6 的标准骨架,但有几个目录需要重点注意。假设你的站点根目录是/www/wwwroot/erp,结构大致是这样:

/www/wwwroot/erp ├── app/ │ ├── admin/ # 后台管理模块(主业务) │ │ ├── controller/ # 控制器,按业务拆:Purchase、Sales、Inventory... │ │ ├── model/ # 模型层,继承 BaseModel │ │ └── view/ # 模板文件,Layui 页面 │ ├── common/ # 公共模块:登录、权限、工具类 │ └── index.php # 入口绑定 ├── config/ │ ├── database.php # 数据库连接配置 │ └── app.php # 应用调试、时区、默认模块 ├── public/ # Web 根目录(Nginx 指向这里) │ ├── static/ # Layui 的 css/js 文件 │ └── index.php # 前端入口 ├── route/ # 路由配置 └── extend/ # 自定义扩展类(比如 Excel 导出)

先说config/database.php。数据库连接是整套系统的命根子,我习惯把'debug' => true先打开跑通流程,确认无误后再关掉,避免 SQL 报错直接暴露在页面上。系统默认数据库是 MySQL 5.7+,字符集用utf8mb4,不然商品名称里有特殊符号会存不进去。config/app.php里有个'default_module' => 'admin'参数,决定了访问根域名时自动跳转后台登录页。

然后是路由。ThinkPHP 6 默认是控制器/方法的 PATHINFO 模式,比如访问采购单列表,URL 是/admin/purchase/lists.html。这套系统没有做复杂路由重写,保持默认即可。但有一点要注意:route/route.php里可能有自定义的伪静态规则,如果你用 Nginx 部署,务必在server块里加一句配置:

location / { if (!-e $request_filename) { rewrite ^/public/(.*)$ /public/index.php?s=/$1 last; } }

不加这段配置的话,所有二级页面都会 404——这是我第一次部署时踩的第一个坑,后面会展开说。

2.3 后台权限与数据字典的设计思路

进销存系统的权限控制,不是简单的「登录才能看」,而是需要精细到按钮级别。比如仓管员能看到采购单和库存,但看不到财务成本和利润。点可云的权限设计是经典的 RBAC(基于角色的访问控制):一张管理员表关联角色,角色关联菜单和操作节点。菜单表里的pid字段实现树形层级,icon字段控制侧边栏图标。

实际操作中,登录后系统会查两次数据库:第一次拿当前用户的角色 ID,第二次用角色 ID 查权限菜单列表,存入 Session。前端渲染侧边栏时,Layui.menu会按层级关系生成菜单树;后端控制器里则有个基类BaseController,每个业务方法的开头都会调checkAuth()校验操作权限。这种方法的好处是权限判定集中在基类里,业务控制器只管写逻辑,不用重复写权限检查。

checkAuth()的核心逻辑是这样的:

protected function checkAuth() { $admin = session('admin_info'); $module = app('http')->getName(); // 当前模块名 $controller = request()->controller(); // 当前控制器 $action = request()->action(); // 当前方法 $url = $module . '/' . $controller . '/' . $action; // 超管不受限制,直接放行 if ($admin['is_super'] == 1) { return true; } // 角色关联的权限节点列表(已经存入 session) $accessList = session('access_list'); if (in_array($url, $accessList)) { return true; } else { // 记录非法访问日志 Log::write('权限拒绝:' . $url . ',操作人:' . $admin['username']); $this->error('无操作权限,请联系管理员'); } }

这段代码有三个参数值得关注:is_super是超级管理员标记,通常是账号 ID 为 1 的那个人,跳过所有权限校验;access_list是登录时查出来的权限节点数组,注意它是按模块/控制器/方法三层拼接的字符串数组;Log::write会把非法访问写进日志表,这个对排查「谁动了成本数据」很有用。数据字典方面,系统在app/admin/model/Dict.php里维护了一个字典表,像「单位类型」「结算方式」「仓库类型」这类可配置选项都不写死在代码里,而是在后台字典管理里维护,前端下拉选项直接查字典接口。

3. 进销存核心业务模块详解:采购、销售、零售与多仓联动

3.1 采购入库到应付账款:单据流与库存流如何同步

采购模块是最能体现进销存系统设计功力的地方。操作流程是:先建采购单,选供应商、选择采购仓库,添加采购明细(商品、数量、单价、折扣),确认后审核,系统会同时完成两件事——增加仓库库存、生成一笔应付账款。这套系统的关键设计是把「单据」和「流水」分开存:采购单主表存业务头信息,采购单明细表存商品行,库存变动表单独记录每次出入库的增量。

为什么要分开?因为业务上允许「反审核」。比如采购单录错了数量,仓管员在审核状态下是不能直接改的,必须先反审核让单据回到草稿状态,修改后再重新审核。审核动作本身要触发库存回滚和重新增加,如果直接把数量和库存存在一张表里,反审核时会很难追溯差异。仓储日志表stock_log里有一条关键字段change_type,值 1 是采购入库、2 是销售出库、3 是盘点调整,所有库存报表都是从这个日志表聚合出来的。

采购入库的核心代码在app/admin/controller/Purchase.php里的approve方法,我把关键段落贴出来:

public function approve() { $id = input('post.id/d'); // 采购单ID $purchase = PurchaseModel::find($id); // 状态校验:只有未审核的单据才能审核 if ($purchase['status'] != 0) { $this->error('只有未审核的单据才能审核'); } // 事务开启,确保库存和应付账款要么同时成功、要么同时回滚 Db::startTrans(); try { // 遍历采购明细,逐个增加库存 $items = PurchaseItemModel::where('purchase_id', $id)->select(); foreach ($items as $item) { $this->addStock([ 'warehouse_id' => $purchase['warehouse_id'], 'goods_id' => $item['goods_id'], 'quantity' => $item['quantity'], 'change_type' => 1 ]); } // 写入应付账款流水 AccountPayableModel::create([ 'supplier_id' => $purchase['supplier_id'], 'purchase_id' => $id, 'amount' => $purchase['total_amount'], 'status' => 0, // 0未付 1部分付款 2已结清 ]); PurchaseModel::update(['status' => 1, 'approve_time' => time()], ['id' => $id]); Db::commit(); $this->success('审核成功'); } catch (\Exception $e) { Db::rollback(); $this->error('审核失败:' . $e->getMessage()); } }

这段逻辑有三个点值得说。第一,Db::startTrans()和Db::rollback()成对出现,这才是真正保证库存和应付账款一致性的关键;如果去掉事务,库存加成功了、应付账款写入SQL失败,账实就不符了。第二,addStock方法是公共的,销售出库、盘点调整也调它,所以库存变动入口统一,方便后续对账。第三,total_amount在创建采购单时已经计算好存进了主表,审核时直接用,效率高但有一个隐患——如果商品单价在单据创建后被修改,主表总金额不会自动同步,后面排错时我会讲这个坑。

3.2 销售出库与应收款核销:零售 POS 与仓库扣减的差异处理

销售模块可以分两条线:普通销售出库和零售出库。普通销售面向企业客户,流程是选客户 → 选商品 → 填数量单价 → 审核后扣库存、生成应收款;零售则更像超市收银,直接扫码枪扫商品条码,选收银员、选支付方式(现金/微信/支付宝),确认后立即扣库存并收款,不产生应收账款。

两条线的核心差异在价格策略。普通销售可以针对不同客户设置不同的销售价,比如大客户打 9 折、批发客户走批发价;零售则必须用固定的零售价,扫码枪扫完就直接出价格,不能再弹窗改价。系统在商品表里设计了多个价格字段:purchase_price(进货价)、wholesale_price(批发价)、retail_price(零售价)和member_price(会员价)。普通销售开单时默认带出批发价,零售收银台带出零售价。

扣库存的逻辑两者也不一样。普通销售审核时,系统要记录应收款挂账,后续在财务模块做收款核销;零售则是即时收款的「钱货两清」,直接把收款单和销售单绑定。零售收银的实现里有段doRetail代码,我一般建议二次开发时重点看这段:

public function doRetail() { $barcode = input('post.barcode'); $quantity = input('post.quantity/d'); $goods = GoodsModel::where('barcode', $barcode)->find(); if (!$goods) { $this->error('未找到该条码对应的商品'); } if ($goods['stock'] < $quantity) { $this->error('库存不足,当前库存:' . $goods['stock']); } $session = session('retail_cart'); // 购物车操作:同一商品累加数量 if (isset($session[$goods['id']])) { $session[$goods['id']]['quantity'] += $quantity; } else { $session[$goods['id']] = [ 'goods_id' => $goods['id'], 'goods_name'=> $goods['name'], 'price' => $goods['retail_price'], 'quantity' => $quantity ]; } session('retail_cart', $session); $this->success('已加入购物车'); }

这段代码有两个边界问题值得注意。一个是库存判断用的是主表stock字段的实时值,如果在高并发场景下多个收银台同时卖同一款商品,这里会有超卖风险;系统的解决方案是扣库存时再用UPDATE ... WHERE stock >= quantity做条件更新,第二次判定会执行两次查询。另一个是价格快照——加入购物车时把retail_price直接存进 Session,但如果此时后台改了零售价,购物车里的价格不会更新,必须清空购物车重新扫。自己改的时候,建议加上「结算时重新读取最新价格」的逻辑。

零售与普通销售另一个差异是库存汇总维度。普通销售单审核时,系统会校验单据统计的仓库是否匹配商品所在仓库;零售单则默认从「默认零售出库仓库」出货——这个仓库是在系统设置里配置的。所以如果仓库数量多,零售收银的库存报表只统计指定的出库仓,别拿总库存去核对零售流水,会差出一截。

3.3 多仓库管理:调拨单、库存锁定与实际可用库存计算

多仓库是这个系统相对花心思的部分。它支持两种仓库类型:实体仓库(一号仓、二号仓)和虚拟仓库(比如「损坏品仓」用于隔离残次品)。多仓库管理的核心不是「多建几个仓库字段」,而是三件事:仓库间调拨要有独立的单据流程、出入库时要指定仓库、报表统计要按仓库维度聚合。

调拨流程是:先建调拨单,选择调出仓库和调入仓库,添加调动商品明细,审核后系统会生成两条库存流水——调出仓扣减一条、调入仓增加一条。这里有一个容易懵的点:调拨单审核前后,所有库存都是「在途状态」,没有独立字段标记。如果你需要管在途库存,就得自己加扩展表。系统原生的逻辑很简单,调拨审核就是一个事务里的两次addStock调用,一个change_type为 3(调出)、一个为 4(调入)。

实际可用库存的计算,是进销存系统最容易被账实不符打击的地方。系统在查询库存列表时,用了这样一个思路:

// 查询实际库存:当前库存= 入库总量 - 出库总量 $list = Db::name('stock_log') ->field('goods_id, warehouse_id, SUM(CASE WHEN type=1 THEN quantity ELSE 0 END) as in_total, SUM(CASE WHEN type=2 THEN quantity ELSE 0 END) as out_total') ->where('is_delete', 0) ->group('goods_id, warehouse_id') ->select();

这段聚合查询有两个坑。第一,type=1是入库、type=2是出库,但是注意盘点调整、调拨产生的流水 type 值不是这两个,如果漏掉了盘点类型,实际库存会算错。系统在盘点模块里用的 type=5 和 type=6,我自己把代码改成枚举常量后,就不容易再记混了。第二,is_delete=0这个条件是软删除过滤——如果业务上允许删除历史单据,那删掉的单据不会影响库存流水,因为关联的流水也做了软删除;但如果有人直接操作数据库把流水 delete 了,库存报表就会对不上。生产环境务必禁止手删stock_log表数据,这是血泪教训。

多仓的报表还有一个独特的「库存分布」概念:系统库存列表页默认按商品汇总,总和所有仓库的数量,但点击某个商品可以展开看每个仓库各自的库存数。这个功能对应的就是上面那段 group by 仓库的聚合逻辑。在goods_stock视图模型中,每个商品在每个仓库会有一条独立记录;如果你发现某些商品在列表页没有按仓库分开展示,大概率是初始化数据时没有跑仓库库存初始化脚本。

4. 财务模块:应收应付、成本核算与利润报表的连动设计

4.1 应收账款与收款核销:核销是「两清」而不是「一锤子」

财务模块是进销存系统最容易做崩的地方。简单粗暴的做法是:销售单审核后,直接把金额累加到客户欠款字段里;客户付款后,再把这个字段减掉。这种设计的问题是只能算个总数,根本查不清哪笔销售单没回款。点可云的方案是「销售单 + 收款单双表模式」:销售单审核后生成应收记录,收款时创建收款单,现在两个单的关系是「核销」——一张收款单可以核销多张销售单,一张销售单也可以被分多次收款。

核销页面里有个经典组件:左侧是客户列表,右侧是待核销的应收单据列表,点「生成收款单」后,会弹窗让你输入本次收款金额,然后选择要核销哪些销售单。系统在后台Recount.shou逻辑里,会循环要核销的销售单 ID,把本次收款金额按顺序分配,直到收款金额分配完或销售单全部核销完。具体代码逻辑是这样的:

public function writeOff() { $customer_id = input('post.customer_id/d'); $amount = input('post.amount/f'); // 本次收款金额 $sale_ids = input('post.sale_ids/a'); // 选中核销的销售单ID数组 $leftAmount = $amount; // 剩余待分配金额 Db::startTrans(); try { foreach ($sale_ids as $sale_id) { if ($leftAmount <= 0) break; // 钱分完了就停 $receivable = ReceivableModel::where('sale_id', $sale_id)->lock(true)->find(); $needMoney = $receivable['total_amount'] - $receivable['received_amount']; $paidMoney = min($needMoney, $leftAmount); // 本次核销金额 ReceivableModel::where('id', $receivable['id']) ->inc('received_amount', $paidMoney) ->update(); // 更新应收单状态 $newReceived = $receivable['received_amount'] + $paidMoney; if ($newReceived >= $receivable['total_amount']) { ReceivableModel::where('id', $receivable['id']) ->update(['status' => 2]); // 2已结清 } else { ReceivableModel::where('id', $receivable['id']) ->update(['status' => 1]); // 1部分收款 } $leftAmount -= $paidMoney; } Db::commit(); $this->success('核销成功,剩余未分配金额:' . $leftAmount); } catch (\Exception $e) { Db::rollback(); $this->error('核销失败:' . $e->getMessage()); } }

这里要特别讲lock(true)这个参数。它是 ThinkPHP 的悲观锁语法,执行时会生成SELECT ... FOR UPDATE,MySQL InnoDB 会在这一行上加锁。多人在后台同时给同一个客户做收款核销时,没有这行锁就可能出现「超收」——钱比欠款还多。加了锁之后,第二个核销操作会等待第一个事务提交后再执行,代价是并发稍低,但对 ERP 场景来说,数据准确比并发重要得多。另外注意inc('received_amount', $paidMoney)不是直接set一个新值,而是自增,这样在多线程下也安全。

4.2 采购成本与销售毛利:移动加权平均法的落地实现

利润报表是进销存系统里最受老板关注的页面,也是最容易算错的地方。核心问题在于「成本」怎么算。这套系统用的是移动加权平均法:每次采购入库时,重新计算商品的平均成本,公式是(原库存金额 + 本次采购金额)÷(原库存数量 + 本次采购数量);每次销售出库时,用当前平均成本作为出库成本。这个算法不要求先进先出,实现简单,对大多数中小企业足够用。

移动加权成本的更新逻辑在GoodsModel的一个方法里,每次库存变动时都会被调用:

public function updateAvgCost($goods_id, $newQuantity, $newCost) { $goods = GoodsModel::find($goods_id); // 原库存金额 + 新入库金额 $totalAmount = $goods['stock'] * $goods['cost_price'] + $newQuantity * $newCost; // 原库存数量 + 新入库数量 $totalQuantity = $goods['stock'] + $newQuantity; // 计算新的加权平均成本(保留4位小数,避免精度误差累积) $avgCost = round($totalAmount / $totalQuantity, 4); GoodsModel::where('id', $goods_id)->update([ 'cost_price' => $avgCost, 'stock' => $totalQuantity, // 注意:库存金额不作为字段存储,每次实时计算 ]); }

这个方法有个前置条件——必须在采购审核的同一事务里调用。如果采购单审核成功但成本更新失败,会导致后续销售毛利全部算错。所以采购审核那段代码里,updateAvgCost的调用放在addStock后面、提交事务之前。

销售毛利的计算方式则是在销售报表模块里用 SQL 联表查询:销售明细表关联商品表,出库成本取当前商品的cost_price快照,所以每一行销售明细里会存两个关键字段:sale_price(卖价)和cost_price(出库时的成本)。毛利 = 销售收入 - 出库成本,毛利率 = 毛利 ÷ 销售收入 × 100%。这里有一个非常重要的坑:销售单审核时,系统会把商品当时的成本快照进销售明细表——注意是「快照」。如果审核后采购价格变动,历史销售的毛利不会被影响,这符合会计核算原则,但如果你去商品表看到的是最新成本,拿它手算历史毛利,一定会对不上。用系统的利润报表页看数据就对了,别自己重新算。

4.3 财务日报 vs 经营日报:两个报表各管什么

系统报表菜单里有两个名字很像的报表,很多人容易点混。财务日报统计的是当天发生的收付款流水(收款、付款、预收、预付),数据来源于收款单和付款单;经营日报统计的是当天的销售业绩和毛利,数据来源于销售单。两者的时间口径也不同:财务日报按「实际收付款时间」统计,经营日报按「销售单审核时间」统计。举例来说,昨天审核的销售单,今天客户才转账,那这笔金额只出现在今天的财务日报里,但出现在昨天的经营日报里。所以老板和对账会计看报表的时候,如果发现两个表的数字对不上,先看口径是不是混了。

经营日报的聚合 SQL 是报表模块里比较有代表性的:

SELECT DATE(approve_time) AS business_date, SUM(total_amount) AS sale_amount, SUM(total_cost) AS sale_cost, SUM(total_amount - total_cost) AS gross_profit, (SUM(total_amount - total_cost) / SUM(total_amount)) * 100 AS profit_rate FROM cdk_sale WHERE status = 1 AND approve_time >= '2025-01-01 00:00:00' AND approve_time < '2025-01-02 00:00:00' GROUP BY DATE(approve_time);

注意total_cost这个字段不是从销售单主表读的,而是销售明细行里sale_quantity * cost_price的汇总。系统在销售单审核时就已经算好这个汇总值存到了主表里,所以查日报时不需要逐行关联商品表。设计上这是刻意为之的——为了报表速度,牺牲一点存储冗余。当单量到了几十万条这个量级,这种预汇总策略的价值很大,MySQL 不需要实时聚合明细表。但副作用是,如果发现某天的毛利数据异常,你得去反查销售明细,因为日报本身不存明细关联关系。

5. 部署避坑手册:ThinkPHP 项目从上传到跑通的常见问题

5.1 访问首页直接报 404:伪静态规则没写对

这是 ThinkPHP 项目部署最常见的翻车点,我几乎每次帮人排查都会遇到。现象是域名打开后直接 Nginx 404,但public/index.php单独访问却能弹出安装页面。原因是 ThinkPHP 6 的入口在public/index.php,但所有业务 URL 都是index.php?s=/admin/login/index这种格式,Nginx 默认配置没有把请求转发给入口文件,所以除了首页之外的所有路由全部 404。解决方法是按 Nginx server 块加一条 try_files 规则:

location / { try_files $uri $uri/ /index.php?s=$uri&$args; }

加完之后记得nginx -s reload。如果你是 phpstudy 或宝塔面板,宝塔的 ThinkPHP 配置模板已经内置了这条规则,直接选用即可;自建 Nginx 就必须手写。还有一种现象是首页能打开、点击登录按钮后 URL 跳转 404,那大概率是public/目录没设为运行目录,Nginx root 指向了项目根目录而不是public/子目录。

5.2 安装完打开提示「数据库连接错误」:字符集与端口两大坑

数据库连接报错的排查路径其实很固定。第一个坑是 MySQL 端口不是默认的 3306,尤其宝塔面板经常把 MySQL 端口改成 3307,但安装向导里的数据库配置只让你填账号密码,后台配置文件里写着 3306,结果连不上。第二个坑是字符集——ThinkPHP 6 默认 PDO 连接参数里charset是utf8,但如果你建库时选了utf8mb4,个别生僻字(比如商品名称里有 emoji 或特殊符号)会存不进去或查询时报Illegal mix of collations。改法是找到config/database.php,把charset改成utf8mb4,同时数据库排序规则也要选utf8mb4_general_ci。

还有一个不太明显但非常常见的坑:服务器防火墙没放行 3306 端口。本地连不上、服务器 localhost 却连得上,基本就是这个。如果你用 Navicat 远程连接测试,在服务器上执行mysql -uroot -p先确认本地能连,再排查安全组。

5.3 登录成功后页面空白或 JS 报错:static 资源路径与 HTTPS 混合内容被拦截

登录页能打开,账号密码输进去也验证通过了,但跳转后台后页面一片空白,控制台报一堆 404。90% 的原因是public/static目录下的静态资源配置被改错了。Layui 的前端文件都依赖__STATIC__模板常量的路径,这个常量在app/common.php里定义。如果项目不是部署在域名根部(比如挂在http://ip:8080/erp/public这种子目录),路径会算错。找一下app/common.php里的__STATIC__定义,改成绝对路径/erp/public/static即可。

另一类是 HTTPS 环境特有的问题:页面部署成 HTTPS 后,如果静态资源引用的是http://开头的绝对路径,浏览器会拦截所有「混合内容」。比如背景图、Layui 的 CSS、JS 全部加载不出来。控制台会明确提示Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure resource 'http://...'。解决就是在 Nginx 层做一次强制跳转或改写:

sub_filter 'http://yourdomain.com' 'https://yourdomain.com'; sub_filter_once off;

这个方案不需要改后端 PHP 代码,Nginx 会在输出 HTML 时自动替换。如果你用的是宝塔,也可以在「网站设置 → 伪静态」里配rewrite ^(.*) https://yourdomain.com$1 permanent;做整体跳转,然后统一改数据库里存的域名配置。

5.4 采购单反审核后库存没变:多表事务与状态机的约束

一个让人抓狂的场景:采购单审核后库存加了,接着点反审核,页面提示成功,但库存数字纹丝不动。原因通常在两个代码分支——审核走的是一个方法,反审核走的是另一个方法,如果后者忘了调addStock反向减库存,或者减库存的 SQL 条件没匹配上记录,事务也不会报错(因为是成功执行了 0 行 update)。排查方法很直接:打开采购单详情页看审核状态,如果状态已经回到 0(草稿),但stock_log里没有对应反向流水,那基本就是反审核逻辑里有分支漏调了。

解决方法是给反审核方法加库存回滚操作,并且用条件where('stock_log_id', $logId)更新流水表打上「已作废」标记:

public function unApprove() { $id = input('post.id/d'); $purchase = PurchaseModel::find($id); if ($purchase['status'] != 1) { $this->error('只有已审核单据才能反审核'); } Db::startTrans(); try { // 反向扣减库存:审核时加了库存,反审核要减回来 $items = PurchaseItemModel::where('purchase_id', $id)->select(); foreach ($items as $item) { $this->addStock([ 'warehouse_id' => $purchase['warehouse_id'], 'goods_id' => $item['goods_id'], 'quantity' => -$item['quantity'], // 负数扣减 'change_type' => 9 // 9 = 采购反审核 ]); } // 作废待付款记录 AccountPayableModel::where('purchase_id', $id)->update(['is_delete' => 1]); PurchaseModel::update(['status' => 0], ['id' => $id]); Db::commit(); $this->success('反审核成功'); } catch (\Exception $e) { Db::rollback(); $this->error('反审核失败:' . $e->getMessage()); } }

这里特别注意quantity传了负数,是因为addStock内部默认做库存累加,传负数就能复用同一套流水逻辑。如果系统自带的反审核逻辑少了这种反向流水,你在二次开发时必须补上,不然库存账永远对不平。另外,反审核后应付账款记录要软删除,而不是物理删除,保留历史轨迹,方便审计。

5.5 PHP 版本与扩展缺失:报Call to undefined function怎么办

这代 ThinkPHP 项目对 PHP 版本有硬性要求,6.x 要求 PHP 7.2.5 以上。如果你从 PHP 5.6 环境直接迁移,会直接数据库连接都建立不起来。除了版本之外,phpredis、fileinfo等扩展如果没装,系统有些功能会静默报错——比如图片上传依赖fileinfo扩展,没装的话上传商品图片时提示未知错误,但 PHP 错误日志里会写Call to undefined function finfo_open()。用宝塔面板的话,直接在「软件商店 → PHP 设置 → 扩展安装」里勾选装上即可。如果是自建源编译的 PHP,重编译时加上--enable-fileinfo参数。

排查原则我记得很清楚:先看 PHP 错误日志(/var/log/php-fpm/error.log或宝塔面板的日志路径),再逐项看扩展列表php -m。大部分「页面白屏无响应」都是 PHP fatal error 被日志吞了,不是业务代码的问题。

6. 进阶技巧:报表扩展与多部门利润核算的轻量改造

很多人在基础功能跑通后会遇到一个新需求:老板要的不只是总毛利,而是每个业务员、每个客户、每个品类的分维度利润。这套系统原生报表只有「总览型」统计,想按维度钻取,就得自己扩展。其实不用大改,只需要在现有销售明细表上做两个小扩展。

第一个扩展是给销售明细表加一个「业务员」字段。系统销售单主表有operator_id字段记录开单操作人,但默认报表没有按它对毛利分组。直接在cdk_sale_item表加一个salesman_id字段,开单时从主表拐过来。SQL 扩展报表时就能这样查:

SELECT s.salesman_id AS salesman_id, u.real_name AS salesman_name, SUM(i.quantity * i.sale_price) AS sales_amount, SUM(i.quantity * i.cost_price) AS cost_amount, SUM(i.quantity * (i.sale_price - i.cost_price)) AS profit FROM cdk_sale_item i LEFT JOIN cdk_sale s ON i.sale_id = s.id LEFT JOIN cdk_user u ON s.salesman_id = u.id WHERE s.status = 1 AND s.approve_time >= '2025-01-01' AND s.approve_time < '2025-02-01' GROUP BY s.salesman_id, u.real_name ORDER BY profit DESC;

这个查询跑不了全表的cdk_sale_item扫描,因为月份条件过滤了主表范围,MySQL 会用索引先定位销售单 ID 范围再联查明细,即使几十万条明细,单页报表也在百毫秒内返回。记住一个原则:明细联表必须先用时间范围把销售单主表缩小,再关联明细,否则索引失效全表扫描,页面必卡。

第二个扩展是做「部门核算」。多部门管理时,可以复用系统的仓库维度——把部门对应到仓库,然后所有利润报表都按warehouse_id分组。实现上不需要改表结构,因为每个销售明细都带出库仓库 ID。报表 SQL 只需把 group by 从业务员改成仓库即可,代码复用率很高。但要注意:如果把一个仓库当作一个部门,零售出库仓需要单独设一个「零售部」,否则零售数据会挂在某个实体仓库下,部门核算就不准了。

最后说一个很实用的工作量验证方法。上线后每周跑一次「库存总账平账检查」,SQL 很直白:

SELECT goods_id, SUM(CASE WHEN change_type IN (1,3,5) THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN change_type IN (2,4,6,9) THEN quantity ELSE 0 END) AS total_out, SUM(CASE WHEN change_type IN (1,3,5) THEN quantity ELSE 0 END) - SUM(CASE WHEN change_type IN (2,4,6,9) THEN quantity ELSE 0 END) AS calc_stock FROM cdk_stock_log GROUP BY goods_id;

把calc_stock和商品主表的stock字段对比,有差异就说明有单据没走审核流程、或者有人直接改了库存表。从那以后我每次给客户交付前都会强制跑一遍这个检查,顺便核对所有反审核单的流水有没有成对出现,基本能把 90% 的库存隐患挡在上线之前。希望这套系统的拆解思路能帮你快速上手,少走我走过的那些弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询