
做小超市进货采购管理系统这个项目前后折腾了小一个月从需求梳理到前端页面一个个抠出来再到后端接口联调踩的坑不算少但做完之后回头再看这个项目非常适合用来打通 Python 后端和 Vue 前端的整个开发链路。今天就把这套系统的完整思路、表结构设计、核心接口代码、前端页面逻辑以及我实际开发中遇到的坑全部整理出来想拿它练手、做课设或者实际给家里小店用的朋友可以直接照着抄。1. 项目整体拆解小超市进货采购系统到底要做什么1.1 核心需求与功能边界很多朋友一上来就想把系统做得很“大”供应商管理、采购订单、库存预警、销售统计、员工权限全部塞进去。但小超市的真实场景根本不需要这么复杂。我见过不少小超市老板进货还是靠手写本子记账供应商电话存手机里谁家便宜就打谁家电话。所以这个系统的核心价值就一句话把“谁进的货、进了多少、花了多少钱、还剩多少”这四件事管清楚。基于这个思路功能边界我划定如下商品信息管理商品名称、条码、规格、单位、分类、当前库存、预警阈值。供应商管理供应商名称、联系人、电话、地址、备注。采购进货管理创建采购单、选择供应商、添加商品明细数量、进价、提交入库。库存自动更新采购单审核通过后对应商品库存自动增加。简单用户登录区分管理员和普通操作员防止随便谁都能改数据。至于销售收银、会员管理这些不是这个系统的重点。一旦塞进来项目复杂度翻倍反而丢掉了“进货采购”这个核心主线。1.2 技术选型为什么是 Flask Vue 而不是其他组合标题里混着 Flask、Django、Vue、PyCharm 一堆词看起来乱其实这正是做技术选型时最常见的状态把候选方案全部摆出来对比一遍最终确定一个组合。我最终选择了 Flask Vue 前后端分离架构理由有三点第一Flask 足够轻。小超市管理系统总共就那么二三十个接口Django 自带 Admin、ORM、Migration、Auth 一大堆东西能力强但重。Flask 的微框架特性让它起步极快路由写起来非常直接配合 SQLAlchemy 一样能做 ORM完全够用。第二Vue 做前端对后端开发者友好。Vue 的模板语法接近原生 HTML学习曲线比 React 平缓。而且小超市的页面基本都是表格、表单、弹窗这类 CRUD 界面Vue Element UI或者 Element Plus可以非常快地搭出好看的后台界面。第三PyCharm 对这两个框架的支持都很完善。Flask 项目可以直接在 PyCharm 里配置运行Vue 项目也有对应的插件支持。一个 IDE 搞定前后端两个项目省去了来回切换的麻烦。顺带说一句如果你偏向 Django它的优势是“全家桶”模型、视图、模板、Admin 一站搞定适合不想单独写前端的场景。但既然项目标题点了 Flask前端又用了 Vue说明走的是前后端分离路线Django 在这里只作为技术调研阶段的备选项。两种方案没有绝对优劣关键看你想要的是“轻快灵活”还是“一步到位”。2. 数据库设计与后台核心模块2.1 数据表设计商品、供应商、采购单、库存、用户数据库我用的 MySQL虽然系统也可以用 SQLite但考虑到小超市后续可能接入收银机、需要多终端访问MySQL 更符合实际生产场景。总共设计了 5 张核心表下面逐一说明。用户表users字段id、username、password_hash、role、created_at。密码必须存哈希不能存明文。Flask 的 werkzeug.security 提供了 generate_password_hash 和 check_password_hash直接用就行。role 字段区分 admin 和 operatoradmin 可以删除数据operator 只能新增和修改。供应商表suppliers字段id、name、contact_person、phone、address、remark、created_at。这里有个细节小超市采购经常出现“这个供应商进过货但这次不想用了”的情况所以我没有设计 is_deleted 软删除字段而是直接物理删除。原因很简单——采购历史记录里还关联着这个供应商物理删除会导致历史单据数据断裂。正确的做法是保留供应商记录前端列表里加一个“停用”状态即可。商品表products字段id、barcode、name、specification、unit、category、stock_quantity、min_stock、purchase_price、sale_price、supplier_id、created_at、updated_at。barcode条码字段建议设置唯一索引小超市很多商品扫码录入。单位字段存的是“瓶”“包”“箱”这种文本虽然不够规范化但胜在灵活老板怎么叫就怎么存。stock_quantity 就是当前库存min_stock 是库存预警阈值。采购单表purchase_orders字段id、order_no、supplier_id、total_amount、status、operator_id、remark、created_at、reviewed_at。order_no 采购单号我建议用时间戳生成格式类似于 PO20240521153000保证唯一且可读。status 字段用 0、1、2 表示待审核、已入库、已作废。这里可能有人问为什么采购单还要审核因为实际场景里采购员录入单据后需要老板确认价格没问题再入库。采购单明细表purchase_order_items字段id、order_id、product_id、quantity、purchase_price、subtotal。为什么要把采购明细单独拆一张表因为一张采购单会包含多种商品如果把明细直接塞在采购单表里数据库设计就违反了第一范式。拆表之后查询“某张采购单有哪些商品”就是一次简单的关联查询。2.2 采购入库与库存扣减的核心逻辑这个模块是整个系统的核心也是最容易写错的地方。我画过一条完整的数据流转线创建采购单状态待审核 - 添加商品明细 - 审核通过状态已入库 - 遍历明细表逐条更新 products 表的 stock_quantity 字段。这里有三个必须注意的点第一创建采购单和添加明细必须在同一个事务里完成。否则会出现“采购单有了但明细没插入成功”的脏数据。用 SQLAlchemy 的 db.session 做到这一点非常容易session 里所有操作要么全部提交要么全部回滚。第二审核入库操作必须加锁或者用乐观锁控制。比如两个管理员同时审核同一张采购单如果不加控制入库操作会执行两次库存就翻倍了。我在代码里是判断 status 是否等于 0待审核只有状态为 0 的单子才能执行入库入库后状态改为 1已入库。再次点击审核时因为状态已经不是 0直接拒绝操作。这属于典型的乐观锁思路原理跟“抢票系统的余票扣减”是一样的。第三采购价格和商品表中的 purchase_price 可能不一致。同一个商品这次进货价和上次进货价可能有波动。所以在采购明细里必须单独保存本次采购价不能直接更新商品表里的基准采购价。否则历史单据里的成本数据就会被覆盖。3. 后端 Flask 接口实现与关键代码3.1 项目结构与配置后端项目我严格按照 Flask 官方推荐的工厂模式组织不用单文件 app.py 一把梭。目录结构如下purchase_system/ ├── app/ │ ├── __init__.py # 应用工厂 │ ├── models.py # 数据库模型 │ ├── auth.py # 登录鉴权 │ ├── views/ │ │ ├── __init__.py │ │ ├── product.py # 商品接口 │ │ ├── supplier.py # 供应商接口 │ │ └── purchase.py # 采购单接口 │ └── utils.py # 公共函数(生成单号等) ├── config.py # 配置文件 ├── run.py # 启动入口 └── requirements.txtconfig.py 里我单独抽了一个配置类方便切换开发环境和生产环境import os class Config: SECRET_KEY os.environ.get(SECRET_KEY) or dev-secret-key SQLALCHEMY_DATABASE_URI os.environ.get(DATABASE_URL) or \ mysqlpymysql://root:123456localhost:3306/supermarket?charsetutf8mb4 SQLALCHEMY_TRACK_MODIFICATIONS False JSON_AS_ASCII False # 让接口返回中文不转义这里重点说下 JSON_AS_ASCII。Flask 2.1 版本之前如果不设置这个参数为 False接口返回的中文会变成\uXXXX这种 Unicode 转义序列前端拿去展示没问题但抓包调试时看着非常痛苦。新版 Flask 虽然默认返回中文但旧项目切换时很容易踩这个坑。3.2 登录鉴权与采购单接口登录鉴权这块我不建议引入 Flask-JWT-Extended 这种重库小项目用 token 自己实现就行。思路是登录成功后用itsdangerous生成一个带时间戳的签名 token存到前端 localStorage后续请求在 Header 里带上Authorization: Bearer token。后端写一个装饰器token_required在每个需要鉴权的接口上直接装饰。采购单创建接口是核心代码如下from flask import Blueprint, request, jsonify from app.models import db, PurchaseOrder, PurchaseOrderItem, Product from app.auth import token_required from app.utils import generate_order_no from datetime import datetime purchase_bp Blueprint(purchase, __name__, url_prefix/api/purchase) purchase_bp.route(/create, methods[POST]) token_required def create_purchase_order(current_user): data request.get_json() if not data or not data.get(supplier_id): return jsonify({code: 400, msg: 供应商不能为空}), 400 items data.get(items, []) if not items: return jsonify({code: 400, msg: 采购明细不能为空}), 400 total_amount 0 try: order PurchaseOrder( order_nogenerate_order_no(), supplier_iddata[supplier_id], operator_idcurrent_user.id, remarkdata.get(remark, ), status0 ) db.session.add(order) db.session.flush() # 先拿到 order.id for item in items: product Product.query.get(item[product_id]) if not product: return jsonify({code: 404, msg: f商品ID {item[product_id]} 不存在}), 404 subtotal item[quantity] * item[purchase_price] total_amount subtotal order_item PurchaseOrderItem( order_idorder.id, product_iditem[product_id], quantityitem[quantity], purchase_priceitem[purchase_price], subtotalsubtotal ) db.session.add(order_item) order.total_amount total_amount db.session.commit() return jsonify({code: 200, msg: 采购单创建成功, order_id: order.id}) except Exception as e: db.session.rollback() return jsonify({code: 500, msg: f创建失败: {str(e)}}), 500这里有个关键动作db.session.flush()。很多新手不理解为什么 add 之后还要 flush。add 只是把对象加到 session 里此时 order.id 可能还是 None。flush 会把 SQL 发送到数据库执行但事务还没有提交这样 order.id 就能拿到了。如果直接把order.id用在后面的明细表外键上必须先 flush 或 commit。采购审核入库的核心代码如下注意我在外层加了事务保护purchase_bp.route(/review/int:order_id, methods[POST]) token_required def review_purchase_order(current_user, order_id): if current_user.role ! admin: return jsonify({code: 403, msg: 无权限只有管理员可审核}), 403 order PurchaseOrder.query.get(order_id) if not order: return jsonify({code: 404, msg: 采购单不存在}), 404 if order.status ! 0: return jsonify({code: 400, msg: 该采购单已处理禁止重复操作}), 400 try: items PurchaseOrderItem.query.filter_by(order_idorder.id).all() for item in items: product Product.query.get(item.product_id) if not product: raise Exception(f商品ID {item.product_id} 不存在) product.stock_quantity item.quantity order.status 1 order.reviewed_at datetime.now() db.session.commit() return jsonify({code: 200, msg: 入库成功}) except Exception as e: db.session.rollback() return jsonify({code: 500, msg: f入库失败: {str(e)}}), 500注意这段代码里的异常抛出raise Exception(...)会触发 except 里的 rollback这样前面累加的库存也会一起回滚不会出现“商品A库存加了但商品B不存在导致整个状态混乱”的脏数据。3.3 进货入库的事务处理说到事务我再展开讲一下。SQLAlchemy 的 session 默认是“事务性”的但不代表你写个 commit 就万事大吉。我在测试阶段就遇到过一个坑采购单入库时如果明细表里有 100 条记录中途第 50 条商品在 products 表里被删除了这时库存更新会中断但前面 49 条的库存已经改了。这就是明显的部分成功问题。解决办法就是上面那个 try-except-rollback 结构。一旦任何一步异常整个事务回滚到操作前的状态。这里有一个经验所有涉及多条写入操作的接口必须做事务保护。不要依赖数据库自身的事务因为 SQLAlchemy 的 session 级别事务需要你主动 rollback否则连接池里的连接状态会残留。另外提示一点SQLAlchemy 在 MySQL 下事务隔离级别默认是 REPEATABLE READ这种级别下事务 A 更新了库存但没提交事务 B 读取到的还是旧值。所以审核入库操作要尽量保持短事务不要在事务里做耗时的网络请求或文件操作。4. 前端 Vue 页面设计与联调4.1 前端项目初始化与路由前端我用的 Vue 2 Element UI虽然 Vue 3 Element Plus 已经是主流但小超市系统用 Vue 2 有一个现实优势网上现成代码多、教程多遇到问题容易搜到答案。如果你自己有把握直接上 Vue 3 也没问题。项目初始化步骤npm install -g vue/cli vue create supermarket-web cd supermarket-web npm install element-ui axios npm install vue-router3路由设计比较简单登录页独立登录后进入主布局主布局里包括商品管理、供应商管理、采购单管理三个页面。这里注意 vue-router 的 beforeEach 守卫用来做登录验证router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path ! /login !token) { next(/login) } else { next() } })这个守卫的逻辑就是没有 token 的用户除了登录页其他页面一律不允许访问。4.2 采购单页面的表单与列表采购单页面是小超市管理系统里交互最复杂的一块。它需要实现选择供应商、动态添加商品行、每个商品行选择商品名称、数量、进价、自动计算小计、底部自动汇总总金额、最后提交或保存草稿。动态添加商品行用 Element UI 的 Table 加动态行实现。关键代码如下template el-table :dataorderItems el-table-column label商品名称 template slot-scopescope el-select v-modelscope.row.product_id filterable remote placeholder输入商品名称搜索 :remote-methodsearchProducts el-option v-forp in productOptions :keyp.id :labelp.name :valuep.id /el-option /el-select /template /el-table-column el-table-column label数量 template slot-scopescope el-input-number v-modelscope.row.quantity :min1/el-input-number /template /el-table-column el-table-column label进价 template slot-scopescope el-input-number v-modelscope.row.purchase_price :min0 :precision2/el-input-number /template /el-table-column el-table-column label小计 template slot-scopescope {{ scope.row.quantity * scope.row.purchase_price }} /template /el-table-column /el-table el-button typetext clickaddRow 添加商品/el-button /templateremote属性让商品下拉框支持远程搜索避免商品多了以后一次加载太慢。每次输入关键词就发一次请求到后端/api/product/search?keywordxxx后端用 LIKE 模糊查询返回前 20 条。这个交互在小超市场景下非常实用员工直接打字搜“可乐”“农夫”就能定位商品。4.3 前后端联调与常见 CORS 问题前后端分离项目联调时第一个拦路虎基本就是跨域。前端跑在 8080 端口后端跑在 5000 端口浏览器默认会拦截跨域请求。解决办法有几种后端装 flask-cors 插件全放开。前端用 Vite 或 webpack-dev-server 的 proxy 配置代理。后端手动设置响应头。我建议开发阶段两层都做。生产部署时走 Nginx 转发把/api路径转发到 Flask 端口这样就没有跨域问题了。开发阶段的 proxy 配置如下// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } } }这个配置的意思是前端代码里请求/api/purchase/create开发服务器会自动转发到http://localhost:5000/api/purchase/create。浏览器里看 Network 面板时请求地址还是前端服务器的地址所以不存在跨域。但要注意生产环境如果不用 Nginx 代理前端直接请求后端 IP那就必须在 Flask 里配置 CORS。我曾经在本地一切正常部署到服务器发现所有接口都报跨域原因就是我把 proxy 当成唯一解决方案忘了后端 CORS 压根没配。后来统一加上 flask-cors问题才彻底解决。5. 开发工具配置与部署上线5.1 PyCharm 环境配置与运行这个项目用 PyCharm 开发非常顺手但第一次配置的同学容易栽在虚拟环境上。PyCharm 默认会给新项目创建一个 venv 虚拟环境你装的 Flask、SQLAlchemy 都在虚拟环境里。命令行下pip list看到的是全局环境不是虚拟环境所以经常出现“我明明装了 Flask 但运行报找不到模块”的诡异问题。解决方法是PyCharm 底部 Terminal 窗口打开时会自动激活项目的虚拟环境命令行前缀会出现(venv)字样。你在这个 Terminal 里执行安装命令才算装到项目环境里。也可以在 PyCharm 的 Settings - Project Interpreter 里直接查看和安装包。运行 Flask 项目时建议不要直接点运行按钮而是配置一个 Flask 类型的 Run Configuration。如果嫌麻烦也可以在 Terminal 里执行export FLASK_APPrun.py export FLASK_ENVdevelopment flask run --host0.0.0.0 --port5000注意--host0.0.0.0这个参数加上它之后局域网内其他设备比如老板的手机平板上用浏览器访问也能访问到你的后端。不加的话默认只监听 127.0.0.1别人访问不了。5.2 本地部署与数据库迁移开发完成后部署我给的推荐方案是Nginx 托管前端静态文件 Flask 跑后端 MySQL 存数据。前端先执行npm run build生成 dist 目录把 dist 目录里的文件放到 Nginx 的 html 目录下。Nginx 配置里加一个 location 转发location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }数据库迁移用 Flask-Migrate。初次配置执行流程pip install flask-migrate flask db init flask db migrate -m init tables flask db upgrade每次修改模型后只需要重新执行后两条命令。这里有个细节flask db migrate不会自动检测到删除字段的操作偶尔需要手动在迁移脚本里补。所以我建议每次迁移后检查一下生成的迁移文件确认内容正确再执行 upgrade。数据库连接这块我在 config.py 里用的是mysqlpymysql://root:123456localhost:3306/supermarket?charsetutf8mb4。特别注意参数charsetutf8mb4如果不加插入商品名称里的特殊字符如 Emoji会报Incorrect string value错误。6. 常见问题与排查技巧实录6.1 模块导入与虚拟环境问题这个坑出现了大概七八次每次新换电脑或者新开项目都会遇到。典型报错ModuleNotFoundError: No module named flask排查路径我总结成三步确认 PyCharm 选择的解释器是不是项目虚拟环境的Settings - Project - Python Interpreter。确认 Terminal 前缀有没有(venv)。在 PyCharm Terminal 里执行pip list看有没有 Flask。如果还没有直接执行pip install -r requirements.txt把依赖全部装一遍。只要解释器路径对了这个报错基本能解决。6.2 数据库连接与中文乱码问题中文乱码有两种情况。第一种是接口返回的中文变成\uXXXX这个前面说过设置 JSON_AS_ASCII False。第二种是写入数据库的中文变成???问号这个通常有两种原因数据库和表没有设置 utf8mb4 字符集或者连接字符串没带 charset 参数。建库时建议用显式语句CREATE DATABASE supermarket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;表如果已经建好了可以修改表级别的字符集。但要注意修改表字符集不会自动转变已存在字段的字符集需要逐字段执行 ALTER。所以最省事的做法就是建库时一步到位。6.3 前端打包后路由 404 与接口报错Vue 项目本地开发一切正常npm run build部署到 Nginx 后刷新页面就 404。这个问题的根源是 Vue Router 的 history 模式。在 history 模式下前端路由是纯前端状态Nginx 收到/purchase/list这种路径时找不到对应文件就返回 404。解决办法是在 Nginx 配置里加入location / { try_files $uri $uri/ /index.html; }意思就是如果找不到对应文件就返回 index.html让前端路由接管。如果不想做这个配置也可以改用 hash 模式URL 会变成/#/purchase/list这种带 # 的格式不需要服务端配合但不好看。接口报错方面前端请求/api/xxx控制台 Network 面板显示 404大概率是 Nginx 的 location /api 转发没配上或者转发的 target 指向了错误的端口。先用 curl 在服务器上测一下后端接口curl http://127.0.0.1:5000/api/product/list如果这一步通了再看 Nginx 配置和前端请求路径是否一致。6.4 常见问题速查表问题现象可能原因解决方案启动 Flask 报 ModuleNotFoundError: flaskPyCharm 解释器没选对检查 Project Interpreter执行 pip install -r requirements.txt接口返回中文变 \uXXXXFlask 未关闭 ASCII 转义设置 JSON_AS_ASCII False数据库中文写入变问号数据库字符集不是 utf8mb4建库时指定 utf8mb4连接串加 charset 参数采购单入库后库存没有变化事务回滚或状态判断失败检查 status 是否为待审核检查代码是否进入 except前端 axios 请求报 CORS后端未配置跨域安装 flask-cors或使用 Nginx 转发打包部署后刷新页面 404vue-router history 模式未配置Nginx 增加 try_files 配置提交采购单时提示商品不存在商品在 products 表中已删除修改商品时建议使用停用状态而非物理删除最后再分享一个小技巧。采购单号不要用自增 ID因为客户看到 PO5 这种单号会觉得很随意。我用的是PO 年月日时分秒 3位随机数既能保证唯一又方便口头报单号。生成代码很简单但这个小细节会让整个系统看起来专业很多。实际跑下来这套系统已经能正常支撑一个小超市日常进货采购的记录和管理后续如果要扩展销售模块或财务统计现在的表结构也不需要大改加表就行。