开发超市进销存系统我是真踩过不少坑。之前给一个社区小超市做过一套老板的需求很简单每天卖了多少钱、仓库还剩什么、哪些货快过期了。当时用了 Flask Vue MySQL 这套组合从数据库建模到前后端联调再到最后部署一路走过来积累了不少可以直接“抄作业”的经验。这篇文章我就把这套系统的完整开发过程拆开讲清楚从表设计、后端接口、前端页面到常见的坑适合正在学 Flask 和 Vue 的新手、准备做毕业设计的同学也适合想帮线下门店做个简单管理工具的朋友。文中的所有代码片段都是我在实际项目中用过的你可以直接参考。1. 项目整体设计思路与数据库建模1.1 核心需求拆解进销存到底在管什么很多新手拿到“进销存”三个字就懵了觉得功能一大堆。其实你把它拆开就很简单进是采购入库销是销售出库存是当前库存。剩下的一切功能都是围绕这三个核心动作的辅助商品资料要维护供应商信息要记录账目要统计报表用户要有权限。我建议第一版只做四个页面商品管理、进货入库、销售收银、库存查看。用户权限和报表统计可以放到第二版。这样开发的周期短你也能更快看到成果不容易中途放弃。这个项目的核心业务流程是这样的先在商品管理里把商品信息录好然后从供应商进货生成采购单并入库库存表里对应的商品数量增加。日常销售时前台收银生成销售单库存表里商品数量减少。老板想了解经营情况就通过报表统计来看每日销售额和库存预警。搞清楚这个闭环数据库怎么建、接口怎么设计思路就自然出来了。1.2 数据库表设计详解这套系统我设计了 7 张核心表分别是用户表、供应商表、商品表、采购单表、采购明细表、销售单表、销售明细表。下面给出每张表的关键字段。用户表user字段名类型说明idINT 主键自增用户IDusernameVARCHAR(50) 唯一登录名password_hashVARCHAR(255)密码哈希用werkzeug生成roleVARCHAR(20)角色admin或staff供应商表supplier字段名类型说明idINT 主键自增供应商IDnameVARCHAR(100)供应商名称contact_personVARCHAR(50)联系人phoneVARCHAR(20)联系电话addressVARCHAR(200)地址商品表product字段名类型说明idINT 主键自增商品IDnameVARCHAR(100)商品名称barcodeVARCHAR(50)条形码扫码收银用categoryVARCHAR(50)分类比如饮料、零食、日用品unitVARCHAR(10)单位比如瓶、包、盒purchase_priceDECIMAL(10,2)进货价sale_priceDECIMAL(10,2)销售价safety_stockINT安全库存低于这个值就预警采购单表purchase_order字段名类型说明idINT 主键自增采购单IDorder_noVARCHAR(30)单号比如PO20240101supplier_idINT供应商IDtotal_amountDECIMAL(10,2)采购总金额statusTINYINT状态0草稿 1已入库 2作废create_timeDATETIME创建时间采购明细表purchase_item字段名类型说明idINT 主键自增明细IDpurchase_idINT采购单ID外键product_idINT商品IDquantityINT数量priceDECIMAL(10,2)进货单价amountDECIMAL(10,2)小计金额销售单和销售明细的表结构跟采购类似区别在于销售单多了支付方式字段这里就不重复列了。另外不要把库存放在商品表里单独建一张 stock 表或者就在商品表里加一个 quantity 字段。我查了下业内做法小系统直接在商品表加 stock_quantity 字段更简单如果进销存业务有多个仓库才需要单独建库存表。为了控制复杂度我这套是直接在 product 表加 stock_quantity 字段。表设计有几个关键点提醒你金额和价格一律用 DECIMAL(10,2)不要用 FLOAT 或 DOUBLE。浮点数在计算机里存的是近似值算账容易差几分钱超市对账最忌讳这个。采购单和销售单一定要拆主表和明细表。因为一张单子可以包含多种商品主表存单号、供应商、总金额这些整体信息明细表存每行商品的详细信息。这也是订单类业务的经典建模方式。密码字段存 password_hash绝不存明文。用 werkzeug 自带的 generate_password_hash 和 check_password_hash安全又省事。采购入库如果出现错误不要物理删除单据用 status 字段标记作废。这样任何时候都能追查原始单据对账的时候非常重要。1.3 技术选型逻辑为什么是 Flask Vue MySQL技术选型上Flask 最大的优势是轻。进销存系统逻辑并不复杂不需要 Django 那样重型的框架。Flask 搭配 SQLAlchemy 做 ORM需要自己配置但很灵活而且中文文档和教程非常多新手遇到问题基本都能搜到答案。FastAPI 虽然性能更好、支持异步但传统管理后台的场景下优势并不明显反而是 Flask 的生态更老、更成熟稳定的坑都有人踩过了。Vue 适合做管理后台是因为这种界面本质上是大量表单和表格Vue 的响应式数据流用起来顺手组件化方便复用。搭配 Element Plus 现成的表格、表单、弹窗组件开发效率非常高UI 也不需要自己从头画。MySQL 免费、稳定、事务支持好。超市进销存的数据量级MySQL 完全没压力。如果以后要上云MySQL 的托管服务也到处都是迁移成本低。这套组合最大的价值是“稳”没有什么炫技的地方但每一步都有大量参考资料遇到问题几乎都能找到现成的解决方案。对于项目开发来讲稳比新更重要。2. 后端 Flask 核心模块实现2.1 项目初始化与环境准备后端我习惯用虚拟环境隔离依赖避免把系统 Python 环境搞乱。先创建虚拟环境并激活然后安装依赖。requirements.txt 我整理了一份实测可用的版本组合flask2.3.3 flask-sqlalchemy3.1.1 flask-cors4.0.0 flask-jwt-extended4.5.3 pymysql1.1.0 cryptography41.0.7 werkzeug3.0.0PyMySQL 是纯 Python 实现的 MySQL 驱动不用编译装起来很省心。cryptography 是 PyMySQL 连 MySQL 8 时的认证插件需要的少了会报错。数据库连接串这样写SQLALCHEMY_DATABASE_URI mysqlpymysql://root:passwordlocalhost:3306/supermarket?charsetutf8mb4 SQLALCHEMY_TRACK_MODIFICATIONS False SQLALCHEMY_ENGINE_OPTIONS {pool_pre_ping: True, pool_recycle: 3600}这里有两个配置非常关键。pool_pre_ping解决的是 MySQL 长时间空闲后主动断开连接的问题。如果不加这个Flask 跑一晚上第二天访问接口可能报 “MySQL server has gone away”。pool_recycle是让连接池里的连接每隔一小时重建避免被 MySQL 的 wait_timeout 杀掉。2.2 登录认证与用户权限控制管理后台虽然是小系统但也不能裸奔。我用 JWT 做登录认证。核心逻辑很简单登录成功后服务端用密钥签发一个 token前端把 token 存下来之后每次请求带上这个 token服务端验证签名即可。生成 token 的代码from flask_jwt_extended import create_access_token app.post(/api/auth/login) def login(): data request.get_json() user User.query.filter_by(usernamedata.get(username)).first() if user and check_password_hash(user.password_hash, data.get(password)): token create_access_token( identityuser.id, additional_claims{role: user.role} ) return {code: 0, data: {token: token, role: user.role}} return {code: 400, message: 用户名或密码错误}, 401前端拿到 token 后每次请求在请求头的 Authorization 字段带上Bearer token服务端就能识别用户身份。我还写了一个只允许管理员访问的装饰器from functools import wraps from flask_jwt_extended import get_jwt, verify_jwt_in_request def admin_required(fn): wraps(fn) def wrapper(*args, **kwargs): verify_jwt_in_request() claims get_jwt() if claims.get(role) ! admin: return {code: 403, message: 权限不足}, 403 return fn(*args, **kwargs) return wrapper注意不要把 SECRET_KEY 写在代码里放到环境变量里更安全。生成密钥可以用 Python 的 secrets 模块python -c import secrets; print(secrets.token_hex(32))。2.3 商品与供应商基础资料管理商品和供应商的基础资料管理都是标准的增删改查接口遵循 RESTful 风格GET /api/products?page1per_page10keyword可乐分页搜索POST /api/products新增商品PUT /api/products/id修改商品DELETE /api/products/id删除商品分页和模糊搜索的实现app.get(/api/products) def get_products(): page request.args.get(page, 1, typeint) per_page request.args.get(per_page, 10, typeint) keyword request.args.get(keyword, , typestr) query Product.query if keyword: query query.filter(Product.name.like(f%{keyword}%)) paginate query.paginate(pagepage, per_pageper_page, error_outFalse) return { code: 0, data: { items: [p.to_dict() for p in paginate.items], total: paginate.total, page: page, per_page: per_page, }, }这里我养成了一个习惯所有接口返回统一格式{code: 0, message: ok, data: ...}。前端拿数据不需要判断多种结构错误码统一处理写代码的体验会好很多。这个看起来是小事但对前后端联调的效率影响很大。2.4 进货与销售业务实现重点讲事务进货入库是整个系统最核心的业务接口完整流程是创建采购单 - 添加采购明细 - 确认入库。入库这一步涉及多个表的变更必须使用事务。from sqlalchemy import update app.post(/api/purchase/int:purchase_id/confirm) def confirm_purchase(purchase_id): try: with db.session.begin(): purchase db.session.get(PurchaseOrder, purchase_id) if purchase is None or purchase.status ! 0: return {code: 400, message: 采购单不存在或状态不允许入库} items PurchaseItem.query.filter_by(purchase_idpurchase_id).all() for item in items: product db.session.get(Product, item.product_id, with_for_updateTrue) if product is None: raise Exception(f商品 {item.product_id} 不存在) # 同步最新进价 product.purchase_price item.price # 库存累加 product.stock_quantity item.quantity item.amount item.quantity * item.price purchase.total_amount sum(i.amount for i in items) purchase.status 1 return {code: 0, message: 入库成功} except Exception as e: db.session.rollback() return {code: 500, message: str(e)}, 500这里有两个重点。一是with db.session.begin()会开启事务如果中间任何一步抛异常所有修改都会回滚。为什么一定要事务因为一张采购单可能有 10 行明细如果入库到第 5 行时报错前 4 行的库存已经改了没有事务就会留下半成功的脏状态。做账的系统这是绝对不可接受的。二是with_for_update()是行级锁。超市有两个收银台同时卖同一件商品没有任何约束的话库存可能被扣成负数。行级锁的含义是一个事务在读取这行数据时其他事务必须等它提交后才能改这样就保证了扣库存的原子性。这个在小系统里是最实用的并发安全方案。销售出库的流程类似多了库存校验for item in sale_items: product db.session.get(Product, item.product_id, with_for_updateTrue) if product is None or product.stock_quantity item.quantity: raise Exception(f{product.name if product else 商品} 库存不足) product.stock_quantity - item.quantity这里的校验和扣减在同一个事务、同一把行锁里完成不会出现“先查到库存够了扣的时候被别人扣光了”的情况。2.5 库存查询与报表统计库存查询页面除了展示当前库存量和商品信息外最有用的是低库存预警。实现就是一个条件查询low_stock_products Product.query.filter( Product.stock_quantity Product.safety_stock ).all()前端拿到这些数据后在表格里把这行标红老板看一眼就知道哪些货要补了。报表统计我用了一条简单的 SQL 实现每日销售额统计from sqlalchemy import func, text app.get(/api/reports/sales) def sales_report(): start request.args.get(start, 2024-01-01) end request.args.get(end, 2025-12-31) sql text( SELECT DATE(create_time) as day, SUM(total_amount) as total FROM sale_order WHERE create_time BETWEEN :start AND :end GROUP BY DATE(create_time) ORDER BY day ) result db.session.execute(sql, {start: start, end: end}).fetchall() return {code: 0, data: [{day: str(r.day), total: float(r.total)} for r in result]}前端用 ECharts 画一个折线图就能看到每天销售额的波动趋势。报表这块不要做太重一个“每日销售趋势”加上“低库存预警”就够用真实场景里老板最关心的也就是这两样。3. 前端 Vue 页面开发与业务闭环3.1 前端工程初始化前端用 Vite 初始化项目Vite 比 Webpack 快得多配置也简单npm create vitelatest supermarket-web -- --template vue然后安装依赖npm install vue-router4 pinia axios element-plus element-plus/icons-vue在 main.js 里注册 Element Plus、路由和 Pinia。Element Plus 可以全量引入虽然打包体积会大一点但省心。真到了上线发现首屏太慢再改成按需引入也不迟。Vite 的 vite.config.js 里配置开发服务器代理把 /api 开头的请求转发到 Flask 后端这样开发时就没有跨域问题了export default defineConfig({ server: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } } })这一步极其重要。如果不配代理前端在 5173 端口后端在 5000 端口浏览器的同源策略会把所有请求都拦下来。配置代理后前端代码里直接写/api/products请求会自动转发到后端。3.2 认证流程与 Axios 封装Axios 封装我每次做项目都会写核心就两个拦截器请求拦截器自动带 token响应拦截器统一处理错误。import axios from axios import router from ../router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } )把 token 放在 Authorization 头并使用 Bearer 前缀是 JWT 的标准约定。服务端解析时直接按这个格式去读两边都省事。响应拦截器里处理 401 跳转登录页这样待办事项里不需要每个接口都去判断 token 是否失效。3.3 核心页面开发列表、表单、收银台商品管理页是典型的列表弹窗表单模式。列表用 el-table分页用 el-pagination新增和编辑共用一个 el-dialog里面放一个 el-form。表单校验规则是 Element Plus 自带的基本不用额外写逻辑。最有细节的是销售收银页。我做的时候参考了真实超市收银台的交互顶部一个大的搜索框输入商品名称或扫码后回车自动把商品加进购物车列表购物车表格显示条码、品名、单价、数量、小计数量可以手动加减底部实时计算合计金额点击“结算”按钮把整单提交到后端成功后清空购物车前端可以做一层库存校验如果添加的商品数量超过库存直接弹提示。但这只是体验层的校验最终可靠性还是靠后端的行锁和事务。进货入库页我用的是“左侧列表 右侧抽屉”的布局。列表展示历史采购单每行显示单号和状态标签。点击“新增采购”打开抽屉里面先选择供应商再逐行添加商品和数量。确认入库时前端把采购单 ID 发给后端由后端完成状态校验和库存更新。3.4 前后端联调与跨域处理联调时最容易出问题的是数据格式不一致。比如 MySQL 的 DATETIME 类型Flask 默认的 jsonify 会把日期转成 2024-01-15T10:30:00 这样的字符串前端如果要单独取日期部分就得自己 split。更麻烦的是 Decimal 类型。金额字段在 SQLAlchemy 里是 Decimal转 JSON 时 Flask 会报错或转成 float。我的处理是写一个自定义 JSONEncoderfrom flask.json import JSONEncoder from decimal import Decimal class CustomJSONEncoder(JSONEncoder): def default(self, obj): if isinstance(obj, Decimal): return float(obj) return super().default(obj) app.json_encoder CustomJSONEncoder这样一来所有接口返回的金额都会自动转成 JSON 兼容的数值类型前端不需要额外处理。这个坑几乎每个用 Flask MySQL 的人都会踩一次提前写好能省很多事。4. 常见问题与排查技巧实录4.1 环境与部署类问题现象原因解决方案pip 安装依赖非常慢或超时默认访问 PyPI 官方源网络慢使用国内镜像源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple 包名Flask 启动后浏览器访问不到默认监听 127.0.0.1只能本机访问app.run(host0.0.0.0, port5000)部署时必须这样写中文乱码建表时字符集不是 utf8mb4建库语句带DEFAULT CHARSETutf8mb4连接串加charsetutf8mb4MySQL 服务无法启动端口被占用或数据目录权限不对先查 3306 端口被谁占用再检查 MySQL 数据目录权限数据库打印出的 SQL 是执行的原始语句无法直接看到参数值这是 SQLAlchemy 的默认行为参数是单独绑定的需要排查时把 execute 语句完整打印出来用str(statement.compile(compile_kwargs{literal_binds: True}))4.2 业务逻辑与数据问题现象原因解决方案库存被扣成负数接口没有事务或没有行锁确认扣库存的代码在事务里且使用了with_for_update()采购单重复入库没有校验状态入库前判断purchase.status ! 0时直接拒绝金额对不上使用了 FLOAT/DOUBLE 存储金额改用 DECIMAL(10,2)前端结算时也尽量只计算展示以服务端计算为准前端分页数据错乱请求参数拼错比如 page 和 per_page 写反打开浏览器 Network 面板看实际请求的 URL 参数修改了数据库数据前端一直显示旧数据浏览器缓存或 GET 请求缓存每次前端重新请求数据注意检查 Network 面板是否有缓存命中4.3 一些提升效率的小建议项目做到收尾阶段我总结了几条对新手最有用的经验写代码的时候可以少走很多弯路。先写数据库再写后端接口最后写前端页面。这个顺序能让你每一步都有明确的输入。先写页面再倒推接口和表结构很容易把设计搞乱。每个接口写完用 Postman 或 Apifox 测一遍再继续写下一个。不要把所有接口写完再一起测不然出问题了会纠结是哪个接口的问题排查成本大得多。接口文档不需要写得多正式但在代码里把参数、返回结构注释清楚。动手写代码前先把接口返回格式确定下来前端后端的联调效率至少提升一倍。做账相关数据改动前先备份数据库或导出 SQL。这个习惯帮我避免过至少三次灾难性的数据丢失特别重要。在后端写一个简单的初始化脚本自动创建数据表并插入管理员账号。省去每次手工敲 SQL 的时间而且新同事或新环境部署时一条命令就把环境弄好了。遇到报错先读最下面一行别只看第一行。Python 的 traceback 最关键的信息在最后一行前面的都是调用链。5. 复盘与后续扩展方向做完这个项目后我最深的体会是代码只是工具真正花时间的是把业务逻辑理清楚、把账算对。一个进销存系统从进货到销售到库存任何一个环节出错使用者对软件的整体信任度就会大打折扣所以一定要自己做完整的流程测试再交付。这个项目可以做进一步扩展的方向很多商品批次管理和保质期预警超市有大量食品需要跟踪批次会员积分体系把回头客留住多门店库存统计每个门店独立库存总部查看汇总对接电子发票或移动支付这需要单独申请接口权限。技术层面也可以继续深化后端接口可以考虑加 Redis 缓存热点商品信息前端首屏可以用路由懒加载优化部署可以用 Docker 打包后端配合 Nginx 反向代理和 HTTPS 证书。进销存系统是一个经典的管理系统练手项目麻雀虽小五脏俱全。做完这一套你对 Flask 的 ORM、事务控制、JWT 认证、Vue 的路由、状态管理、组件通信、前后端联调都会有非常扎实的掌握。如果你正在做类似的项目希望这篇文章能帮你少踩几个坑把精力花在真正有意思的业务逻辑上。