资讯详情

资讯详情

ThinkPHP6+Layui构建中小商贸进销存系统实战

简介点可云进销存系统是一套面向中小企业的轻量级ERP管理解决方案基于ThinkPHP框架与Layui前端库开发聚焦采购、销售、零售、多仓库协同、财务核算及全维度经营报表等核心业务场景助力企业实现进销存一体化管控与数据驱动决策。资源包共1736个文件含623个PHP后端逻辑文件、198个JS交互脚本、124个HTML页面模板、92个PNG图标及37个CSS样式文件辅以数据库配置.sql/.db、安装脚本.bat/.sh和完整文档README、CHANGELOG、LICENSE整体压缩包仅19.78MB结构清晰、开箱即用。目前已有109人学习下载适合PHP中级开发者快速掌握企业级应用的MVC分层设计、Layui组件集成与多模块业务联动实现。读者可直接部署运行深入理解采购单流转、销售订单闭环、多仓库存同步、资金流水对账及10类动态报表生成等真实业务逻辑。1. 点可云进销存系统为什么一个基于 ThinkPHP Layui 的老派技术栈反而在中小商贸企业里跑得比 SaaS 还稳你可能刚点开这个标题时心里一咯噔ThinkPHP 6 都出三年了Layui 官网更新停在 2021 年现在还拿这套组合做进销存是不是过时了但现实是——我去年在珠三角跑了 7 家做五金、建材、农产品批发的客户他们用的正是「点可云」这类系统不是钉钉宜搭也不是简道云更不是某SaaS厂商的云端订阅版。他们不关心 Vue3 或微前端只问三件事能不能离线开单财务报表导出 Excel 能不能带公式多仓库调拨时扫码枪扫完 50 行商品3 秒内必须回显库存差额并锁库。点可云这类系统恰恰卡在「够用、可控、可改」的黄金缝里ThinkPHP 提供稳定的 MVC 分层和数据库抽象层让采购单、销售单、零售小票这些强事务场景能靠事务锁表兜底Layui 虽无响应式但它的 table 组件原生支持服务端分页、列宽拖拽、Excel 导出含合并单元格比手写 Vue 表格快 3 倍——尤其当客户要求「把未清采购订单按供应商物料单位件/袋/吨三维度汇总到一张表里」时Layui 的toolbarexportdone回调链配合 ThinkPHP 的Db::query()原生 SQL反而比 ORM 更直给、更可控。它不是为炫技而生而是为「老板下午三点要看到昨天所有门店的毛利对比柱状图」而活。适合年营收 500 万5000 万、IT 人员≤1 名、ERP 预算 ≤3 万元的实体商贸企业。2. 搭建最小可运行环境用 ThinkPHP 6.1 Layui 2.8 快速拉起采购单模块点可云系统不是从零造轮子而是基于 ThinkPHP 6.1 LTS 版本非 7.x和 Layui 2.8非 layui-vue构建。这两个版本组合经过 3 年以上生产验证兼容 PHP 7.4–8.1且避免了 TP7 的中间件重构和 Layui-vue 的生态断层风险。下面带你从零拉起「采购单新增」功能这是整个系统最核心的事务入口。2.1 初始化 ThinkPHP 6.1 项目并配置多数据库连接点可云需同时操作主业务库db_main和报表缓存库db_report尤其在「采购收货汇总表单位件和袋怎么汇总在一个表上」这类需求中需跨库 JOIN。TP6.1 的多连接配置比 TP5.1 更清晰# 使用 Composer 创建项目注意指定 6.1.x 版本 composer create-project topthink/think6.1.* pointcloud-inventory cd pointcloud-inventory编辑config/database.php关键配置如下// config/database.php return [ // 主库业务写入 default mysql_main, connections [ mysql_main [ type mysql, hostname 127.0.0.1, database pointcloud_main, username root, password your_pass, hostport 3306, charset utf8mb4, prefix pc_, // 所有表前缀统一为 pc_ debug true, deploy 0, rw_separate false, ], // 报表库只读用于复杂汇总查询 mysql_report [ type mysql, hostname 127.0.0.1, database pointcloud_report, username report_user, password readonly_pass, hostport 3306, charset utf8mb4, prefix , debug false, deploy 0, rw_separate false, ], ], ];提示prefix设为pc_是点可云约定后续所有数据表名如pc_purchase_order、pc_warehouse均由此生成。不要用空字符串否则迁移时易与旧系统冲突。2.2 引入 Layui 2.8 并初始化采购单页面骨架Layui 不走 npm直接下载layui-v2.8.18.zip官网最后稳定版解压后将layui文件夹放入public/static/下。在app/view/index/purchase/add.html中写基础结构!-- app/view/index/purchase/add.html -- !DOCTYPE html html head meta charsetutf-8 title新增采购单/title link relstylesheet href/static/layui/css/layui.css /head body classlayui-layout-body div classlayui-layout layui-layout-admin div classlayui-header div classlayui-logo点可云·采购管理/div /div div classlayui-body div stylepadding: 20px; form classlayui-form action{:url(purchase/save)} methodpost div classlayui-form-item label classlayui-form-label供应商/label div classlayui-input-block select namesupplier_id lay-filtersupplier required option value请选择/option {volist namesuppliers idsup} option value{$sup.id}{$sup.name}{$sup.code}/option {/volist} /select /div /div div classlayui-form-item label classlayui-form-label仓库/label div classlayui-input-block select namewarehouse_id required option value请选择/option {volist namewarehouses idwh} option value{$wh.id}{$wh.name}{$wh.code}/option {/volist} /select /div /div !-- 商品明细表格占位 -- div idgoods-table-container/div div classlayui-form-item div classlayui-input-block button classlayui-btn lay-submit lay-filteradd-purchase立即提交/button button typereset classlayui-btn layui-btn-primary重置/button /div /div /form /div /div /div script src/static/layui/layui.js/script script layui.use([form, table, layer], function(){ var form layui.form; var table layui.table; var layer layui.layer; // 动态渲染商品明细表格关键 table.render({ elem: #goods-table, height: 400, url: {:url(purchase/goodsList)}, // 后台返回 JSON[{id:1,name:螺丝,unit:件,price:2.5}] cols: [[ {type:checkbox}, {field:id, title:ID, width:80, unresize: true}, {field:name, title:商品名称, edit: text}, {field:unit, title:单位, width:100, templet: #unitTpl}, {field:qty, title:数量, edit: number, width:120}, {field:price, title:单价(元), edit: number, width:120}, {field:amount, title:金额(元), width:120, templet: div{{d.qty * d.price}}/div}, {fixed: right, title:操作, toolbar: #barDemo, width:120} ]], page: true, limit: 20, done: function(res, curr, count){ // 表格渲染完成后触发金额自动计算 calcTotalAmount(); } }); // 监听工具条事件删除行 table.on(tool(goodsTable), function(obj){ if(obj.event del){ layer.confirm(确定删除该商品, function(index){ obj.del(); calcTotalAmount(); layer.close(index); }); } }); // 金额合计函数放在全局供编辑、删除、新增时调用 function calcTotalAmount(){ var total 0; var data table.cache[goodsTable]; for(var i in data){ total (data[i].qty || 0) * (data[i].price || 0); } $(#total-amount).text(total.toFixed(2)); } // 提交前校验防止空明细 form.on(submit(add-purchase), function(data){ var goodsData table.cache[goodsTable]; if(goodsData.length 0){ layer.msg(请至少添加一行商品明细, {icon:2}); return false; } return true; // 允许提交 }); }); /script script typetext/html idunitTpl {{# if(d.unit 件){ }}件{{# } else if(d.unit 袋){ }}袋{{# } else { }}{{d.unit}}{{# } }} /script script typetext/html idbarDemo a classlayui-btn layui-btn-xs lay-eventdel删除/a /script /body /html这段代码的关键不在“能显示”而在三个落地细节templet: #unitTpl实现「件/袋」单位动态渲染避免后端拼 HTML 字符串done回调中调用calcTotalAmount()确保分页后金额仍准确Layui table 默认只渲染当前页但 cache 存全量form.on(submit)中校验table.cache长度而非 DOM 行数——因为 Layui 表格支持编辑但不自动同步到 DOM必须读 cache。3. 核心数据模型设计采购单、商品明细、多仓库与单位换算的 MySQL 表结构点可云的「采购收货汇总表单位件和袋怎么汇总在一个表上」需求本质是同一物料在不同采购场景下存在多种计量单位件/袋/箱/吨且需按实际收货单位归集统计。这要求数据库设计必须支持「单位换算因子」和「收货单位快照」而非简单用unit VARCHAR(10)硬编码。3.1 五张核心表及其字段逻辑说明表名字段关键类型说明索引建议pc_purchase_orderid,order_no,supplier_id,warehouse_id,status,created_at,updated_atBIGINT / VARCHAR / INT / DATETIME主单头表status用 tinyint(1)0草稿、1已提交、2已收货、3已入库、4已关闭UNIQUE(order_no),INDEX(supplier_id, status)pc_purchase_itemid,order_id,goods_id,unit_id,qty,price,amount,received_qty,received_unit_idBIGINT / BIGINT / BIGINT / DECIMAL / DECIMAL / DECIMAL / DECIMAL / BIGINT明细表unit_id是采购单位如“袋”received_unit_id是收货单位如“件”二者可不同received_qty为实际收货数量INDEX(order_id),INDEX(goods_id)pc_goodsid,code,name,base_unit_id,is_multi_unitBIGINT / VARCHAR / VARCHAR / BIGINT / TINYINT商品主数据base_unit_id是基准单位如“件”所有换算以此为基准UNIQUE(code),INDEX(name)pc_unitid,code,name,factor_to_baseBIGINT / VARCHAR / VARCHAR / DECIMAL(10,4)单位表factor_to_base表示该单位换算成基准单位的系数如“袋→件20”则factor_to_base20UNIQUE(code)pc_warehouseid,code,name,type,is_enabledBIGINT / VARCHAR / VARCHAR / TINYINT / TINYINT仓库表type1总部仓、2区域仓、3门店仓、4虚拟仓用于调拨中转INDEX(type, is_enabled)注意pc_purchase_item.received_unit_id不是外键指向pc_unit.id而是冗余存储——因为收货时单位可能临时新增如某次采购特批用“托盘”且历史单据单位不可变。这是点可云的务实设计宁可冗余不建外键约束导致插入失败。3.2 实现「件和袋汇总到一张表」的 SQL 查询逻辑当客户要求「按供应商物料单位件/袋三维度汇总采购收货」时不能简单GROUP BY unit_id因为unit_id是采购单位而收货单位在received_unit_id。正确做法是以received_unit_id为主维度关联pc_unit获取单位名称并用factor_to_base将所有收货量统一折算为基准单位件再汇总-- 采购收货汇总视图含件/袋/箱等多单位统一折算 CREATE VIEW v_purchase_received_summary AS SELECT s.name AS supplier_name, g.name AS goods_name, u.name AS received_unit, SUM(pi.received_qty) AS raw_qty, SUM(pi.received_qty * u.factor_to_base) AS qty_in_base_unit, COUNT(DISTINCT po.id) AS order_count FROM pc_purchase_item pi JOIN pc_purchase_order po ON pi.order_id po.id JOIN pc_supplier s ON po.supplier_id s.id JOIN pc_goods g ON pi.goods_id g.id JOIN pc_unit u ON pi.received_unit_id u.id WHERE po.status IN (2,3) -- 已收货或已入库 GROUP BY s.id, g.id, u.id, u.name;此视图输出结果示例supplier_namegoods_namereceived_unitraw_qtyqty_in_base_unitorder_count东莞XX五金厂M8螺栓件12001200.00003东莞XX五金厂M8螺栓袋501000.00002这样前端报表只需查v_purchase_received_summary就能天然支持「件和袋汇总在一个表」无需应用层做单位转换——这是数据库设计阶段就埋下的确定性。4. 多仓库调拨与库存锁定用事务行锁解决并发超卖问题点可云的「多仓库管理」不是简单增删改查核心痛点是A 仓向 B 仓调拨 100 件螺丝同时 C 仓也在销售这 100 件螺丝如何保证不超卖ThinkPHP 的事务封装 MySQL 行锁是唯一可靠解法Layui 前端只负责触发不参与锁逻辑。4.1 库存扣减的原子操作pc_stock表结构与 SQL 锁定语句库存表pc_stock必须按「仓库商品」粒度建行且qty字段为DECIMAL(12,2)支持小数库存如液体、散装料CREATE TABLE pc_stock ( id bigint unsigned NOT NULL AUTO_INCREMENT, warehouse_id bigint unsigned NOT NULL, goods_id bigint unsigned NOT NULL, qty decimal(12,2) NOT NULL DEFAULT 0.00, frozen_qty decimal(12,2) NOT NULL DEFAULT 0.00, -- 冻结量已分配未出库 updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_warehouse_goods (warehouse_id,goods_id), KEY idx_goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键字段frozen_qty表示该仓库该商品已被采购单、销售单、调拨单预占的数量。真正扣减时必须同时更新qty和frozen_qty且用SELECT ... FOR UPDATE锁住目标行// app/controller/Purchase.php public function save() { $data input(post.); $this-validate($data, PurchaseValidate); Db::startTrans(); // 开启事务 try { // 1. 插入采购单头 $orderId Db::name(purchase_order)-insertGetId([ order_no PO . date(ymd) . str_pad(rand(1000,9999), 4, 0, STR_PAD_LEFT), supplier_id $data[supplier_id], warehouse_id $data[warehouse_id], status 1, created_at date(Y-m-d H:i:s) ]); // 2. 插入明细并锁定库存行关键 foreach ($data[items] as $item) { // 锁定该仓库商品的库存行阻塞其他事务 $stock Db::name(stock)-where([ warehouse_id $data[warehouse_id], goods_id $item[goods_id] ])-lock(true)-find(); if (!$stock || $stock[qty] $item[qty]) { throw new \Exception(库存不足商品 {$item[goods_id]} 在仓库 {$data[warehouse_id]} 剩余 {$stock[qty]}需 {$item[qty]}); } // 3. 更新库存扣减可用量增加冻结量 Db::name(stock)-where(id, $stock[id])-update([ qty $stock[qty] - $item[qty], frozen_qty $stock[frozen_qty] $item[qty], updated_at date(Y-m-d H:i:s) ]); // 4. 插入采购明细此时已锁库安全 Db::name(purchase_item)-insert([ order_id $orderId, goods_id $item[goods_id], unit_id $item[unit_id], qty $item[qty], price $item[price], amount $item[qty] * $item[price], received_qty 0, received_unit_id $item[unit_id] // 默认收货单位采购单位 ]); } Db::commit(); return json([code1, msg采购单创建成功]); } catch (\Exception $e) { Db::rollback(); return json([code0, msg$e-getMessage()]); } }血泪经验lock(true)是 ThinkPHP 6.1 的写法等价于SELECT ... FOR UPDATE。若用lock(false)即LOCK IN SHARE MODE则无法阻止其他事务修改qty会导致超卖。务必用true。4.2 Layui 前端如何感知库存锁定状态用户点击「提交采购单」后按钮应禁用并显示「库存校验中…」避免重复提交。Layui 表单提交拦截需加 loading 状态form.on(submit(add-purchase), function(data){ // 1. 禁用按钮 var btn $(this).find(button[lay-filteradd-purchase]); btn.addClass(layui-btn-disabled).html(i classlayui-icon layui-icon-loading/i 库存校验中…); // 2. 提交 AJAX $.post({:url(purchase/save)}, data.field, function(res){ if(res.code 1){ layer.msg(成功, {icon:1}); setTimeout(function(){ location.href {:url(purchase/index)}; }, 1500); } else { layer.msg(res.msg, {icon:2}); btn.removeClass(layui-btn-disabled).html(立即提交); } }).fail(function(){ layer.msg(网络错误请重试, {icon:2}); btn.removeClass(layui-btn-disabled).html(立即提交); }); return false; // 阻止默认提交 });此处「禁用按钮」不是防 JS 重复执行而是防用户狂点——因为后端事务锁住库存行后第二个请求会阻塞数秒甚至超时前端必须明确反馈「正在处理」否则用户会以为卡死而刷新页面造成脏数据。5. 报表功能避坑指南Layui table 导出 Excel 的 4 个致命陷阱点可云的「超详细的报表功能」是客户付费核心点但 Layui 的table.exportFile()在真实生产中极易翻车。我踩过 4 个必须写死在 checklist 里的坑每个都导致客户投诉「导出的 Excel 乱码/缺列/公式失效/超时」。5.1 现象导出 Excel 中文列头显示为方框或乱码原因Layui 导出使用SheetJSxlsx.full.min.js其默认编码为 ANSI中文需显式指定bookType: xlsx和type: array且后端返回必须是 UTF-8 BOM 头。解决前端table.exportFile()必须加参数table.exportFile(goodsTable, data, xlsx, { bookType: xlsx, type: array });后端 PHP 导出接口如app/controller/Report.php必须输出 BOM 头header(Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); header(Content-Disposition: attachment;filenamereport.xlsx); header(Cache-Control: max-age0); // 关键输出 UTF-8 BOM echo \xEF\xBB\xBF; // 后续输出 xlsx 二进制流...5.2 现象导出的 Excel 列宽为 0内容挤成一条线原因Layui 默认不设置列宽Excel 打开时自动适应但某些版本尤其是 WPS会忽略自适应显示为 0 宽。解决在table.render()的cols中为每列加minWidth{field:name, title:商品名称, minWidth:150}, // 至少 150px {field:unit, title:单位, width:100, minWidth:80},5.3 现象导出的 Excel 中「金额」列无千分位且无法用 SUM 函数求和原因Layui 导出时把数字当字符串处理因templet返回的是 HTML 字符串div1234.56/div。解决对数值列templet必须返回纯数字由 Layui 自动识别类型{field:amount, title:金额(元), width:120, templet: function(d){ return d.qty * d.price; }}, // ❌ 错误写法templet: div{{d.qty * d.price}}/div5.4 现象大数据量5000 行导出时浏览器卡死或超时原因LayuiexportFile()在前端生成 Excel内存爆炸。解决超过 2000 行强制走后端导出。前端判断var total table.cache[goodsTable].length; if(total 2000){ layer.confirm(数据量过大将启用后台导出约30秒, function(){ window.location.href {:url(report/exportLarge)}?tablegoodsTableparams JSON.stringify(params); }); } else { table.exportFile(goodsTable, table.cache[goodsTable], xlsx); }后端exportLarge方法用PhpSpreadsheet流式写入内存占用 2MB。避坑总结报表不是功能堆砌而是「导出即交付」。Layui 的便捷性在小数据时成立一旦客户说「我要导出去年全年销售明细」就必须切后端导出——这不是妥协是职业底线。6. 财务报表联动技巧用「科目余额表」驱动利润表与现金流量表自动计算点可云的「财务管理功能」常被低估其实它才是系统粘性最强的部分。客户不要总账凭证只要「今天毛利多少」「本月经营性现金流净额」——而这依赖一个核心设计所有业务单据采购、销售、零售在保存时必须实时写入pc_accounting_entry科目余额表且按「会计期间科目方向」聚合。这样利润表和现金流量表才能脱离 SQL 复杂 JOIN靠简单SUM()实时生成。6.1 科目余额表pc_accounting_entry结构与写入规则CREATE TABLE pc_accounting_entry ( id bigint unsigned NOT NULL AUTO_INCREMENT, period char(6) NOT NULL COMMENT 会计期间格式202401, subject_code varchar(20) NOT NULL COMMENT 科目编码如112201应收账款-XX客户, subject_name varchar(100) NOT NULL, direction tinyint NOT NULL COMMENT 1借方-1贷方, amount decimal(14,2) NOT NULL DEFAULT 0.00, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_period_subject (period,subject_code), KEY idx_subject (subject_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;写入规则以销售单为例销售单保存时生成 3 条分录1122应收账款借方销售总额6001主营业务收入贷方销售总额6401主营业务成本贷方销售成本需关联商品成本价所有分录period为date(Ym)subject_code严格按科目体系编码direction用1或-1不用dr/cr字符串——为后续SUM(direction * amount)做准备。6.2 利润表v_profit_statement视图实现-- 利润表视图按月聚合实时计算 CREATE VIEW v_profit_statement AS SELECT period, SUM(CASE WHEN subject_code LIKE 6% THEN direction * amount ELSE 0 END) AS revenue, SUM(CASE WHEN subject_code LIKE 64% THEN direction * amount ELSE 0 END) AS cost_of_sales, SUM(CASE WHEN subject_code LIKE 66% THEN direction * amount ELSE 0 END) AS operating_expense, SUM(CASE WHEN subject_code LIKE 6% OR subject_code LIKE 64% OR subject_code LIKE 66% THEN direction * amount ELSE 0 END) AS net_profit FROM pc_accounting_entry GROUP BY period;关键技巧CASE WHEN subject_code LIKE 6%比IN (6001,6002,...)更可持续——当客户新增科目如6003其他业务收入视图自动包含无需改 SQL。6.3 现金流量表间接法的 Layui 表格联动现金流量表难点在于「将净利润调节为经营活动现金流」。点可云用「科目映射表」解耦建表pc_cashflow_mapping定义哪些科目变动影响现金流subject_codecashflow_typedescription11221经营活动-销售商品收到现金2202-1经营活动-购买商品支付现金10020现金及现金等价物期末余额然后前端 Layui 表格用url加载动态数据table.render({ elem: #cashflow-table, url: {:url(report/cashflowData)}?period{:input(period)}, cols: [[ {field:type_name, title:项目, width:200}, {field:amount, title:金额(元), width:150, templet: function(d){ return d.amount 0 ? d.amount : d.amount; }}, {field:note, title:说明, width:250} ]], page: false, done: function(res){ // 渲染完成后手动计算「现金及现金等价物净增加额」 var total 0; res.data.forEach(function(row){ if(row.cashflow_type 0){ total row.amount; } }); $(#cash-end-balance).text(total.toFixed(2)); } });后端cashflowData方法根据pc_cashflow_mapping查出当期各科目发生额再按cashflow_type归类。我坚持在每个新客户上线前花半天时间陪财务人员核对「科目余额表」的首笔分录是否正确——因为一旦源头写错利润表就是黑匣子后面所有报表都是后悔药。点可云的价值不在界面有多炫而在每一笔钱的来龙去脉都能在pc_accounting_entry里找到原始凭证。这才是中小企业的「财务安全感」。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →