恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
连锁超市进销存系统实战:Django+Flask架构设计与实现
首页
资讯中心
/
连锁超市进销存系统实战:Django+Flask架构设计与实现
连锁超市进销存系统实战:Django+Flask架构设计与实现
发布时间:2026/9/24 21:54:11
做超市连锁门店的仓库进销存采购管理系统我前前后后折腾了大半年。主框架用的 Python Django中间还穿插了一个 Flask 做的独立报表服务。今天这篇就把整个项目的核心思路、数据设计、关键代码和踩坑记录一次说透。如果你是给本地商超、连锁便利店做类似系统或者想拿一个真实业务场景练手 Django这篇应该能让你少走不少弯路。先说说为什么这套系统值得认真做。进销存听起来老掉牙但它是零售企业最离不开的底座。门店卖货、仓库补货、总部采购任何一环的数据对不上月底盘点就是一场灾难。我见过太多小超市还在用 Excel 记库存总部和门店各记各的结果就是货在仓库躺着、门店却一直喊缺货或者采购重复下单造成积压。这套管理系统要解决的核心问题就是让商品从供应商到仓库、从仓库到门店、从门店到顾客手里的每一笔变动都清晰可查并且让总部能实时掌握所有门店的真实库存和销售情况。1. 超市连锁进销存到底在解决什么问题1.1 一个商品从供应商到顾客手里要经历多少次记账很多人以为进销存就是“进货、销货、存货”三张表真做起来完全不是这么回事。以一瓶饮料为例它进入门店之前要经过采购申请、采购订单、供应商发货、仓库收货、验收入库、调拨出库、门店收货这些步骤里每一步都会产生一张业务单据。到了门店卖出去之后可能还有销售退货、换货、报损、盘盈盘亏每个动作都必须记账、必须留痕。所以在设计系统之前我把所有业务单据梳理了一遍最后归纳成四条主线采购线、销售线、调拨线、盘点线。采购线包括采购申请、采购单、采购收货、采购退货销售线包括销售单、销售退货调拨线是门店和仓库之间的出库和入库盘点线负责把账面库存和实际库存之间的差异以损益单的形式登记起来。每条线都是一连串“成对”的变动有进必有出有出必有进这样才能保证账实相符。这个梳理过程看起来不起眼但它决定后续所有表结构的长相。如果一开始就把“库存表”当成唯一的数据来源只记录当前还剩多少那系统上线第一个礼拜看不出问题到了月底对账就会完全失控。真正合理的做法是库存表只是一个汇总结果底层必须有完整流水任何时候都能倒推出某一时点的库存数量。1.2 多门店、多仓库比单店复杂在哪单店进销存很多人写过但连锁门店场景复杂在“维度”上。同一个商品不是只有总量而是要按门店维度、仓库维度分别记录库存。总部有一个总仓每个门店可能还有自己的小仓库商品从总仓调到门店仓账面库存要同时变两本账总仓减门店仓加。如果调拨过程中还有在途状态问题会更麻烦。另外一个复杂点是价格策略。同一件商品在不同门店的售价可能不同会员价、促销价、团购价让价格更乱。采购入库的成本价又会因批次不同而变化这就直接影响毛利计算。只把商品表里放一个“价格”字段肯定不够需要单独维护价格策略和成本核算方式。权限也是多门店系统躲不开的问题。总部采购员可以看到所有门店的库存并统一采购但门店店长只能看到自己门店的数据收银员只能录入和查询销售单仓管只能处理出入库。这些角色划分要在权限模型里落地而不是在代码里到处写 if 判断。Django 自带的 User、Group、Permission 在这一块帮了很大的忙后面我会讲具体怎么用。1.3 这类系统的核心价值与影响范围从业务价值来看这套系统最直接的效果就是减少缺货和积压。缺货意味着销售损失积压意味着资金占用和临期损耗。有了实时的门店库存和销售数据采购员可以按安全库存自动生成补货建议而不是凭经验拍脑袋。总部也能定期看到哪些商品周转慢、哪些商品毛利低从而调整采购策略。从影响范围来说这套系统几乎牵连了超市运营的所有部门总部运营靠它看销售和库存数据采购部靠它做采购计划仓库靠它做收货和发货财务靠它核对供应商往来和成本毛利门店店长靠它盘点和补货收银员靠它查询商品和价格。这个项目做好之后等于把原来 Excel 时代割裂的信息孤岛串成了一条线数据口径统一了管理效率自然就上来了。2. Python Django Flask 技术组合怎么分工2.1 主框架为什么选 Django 而不是 Flask很多新手在选型时会纠结都是 Python Web 框架django 和 flask 到底该用哪个。我的判断标准很简单如果项目是数据管理型业务系统优先选 Django如果项目是轻量 API、报表服务或原型验证Flask 更合适。进销存采购管理系统明显属于前者。Django 自带的东西解决了这类项目百分之六十的重复劳动。ORM 可以让我快速定义商品、库存、订单这些模型不用手写 SQL 就能完成大部分增删改查Admin 后台几乎免费送了一个内部管理界面配合权限体系可以直接给运营人员用自带的迁移工具 migrations 让表结构变更可以像 git 一样提交到环境里培训成本也低。Flask 当然也能做但所有的东西都要自己拼装项目一复杂这种组装成本会越来越高。我画过一张对比表给大家参考对比项DjangoFlaskAdmin 后台自带开箱即用需要集成 flask-admin 等第三方库ORM内置且非常成熟通常要自己集成 SQLAlchemy用户权限自带 User/Group/Permission需要自己实现或使用扩展库迁移管理内置 makemigrations/migrate需要配合第三方库请求路由基于 URLconf 集中管理装饰器路由灵活但分散适合场景管理后台、业务系统、CMS轻量 API、报表、微服务、小程序后端另外Django 的 MTV 模式在这个项目里也体现得很明显。M 是 Model对应商品、库存、订单这些数据模型V 是 View写业务逻辑比如库存扣减、采购单审核T 是 Template负责页面渲染。请求进来之后先走 URL 路由被分发给对应的 ViewView 操作 Model再把数据交给 Template 渲染成 HTML 返回。理解了这个流程后面写任何模块都能按这个套路来推进。2.2 Flask 在整套系统里的实际位置有人会问既然主系统已经是 Django 了为什么还要引入 Flask。我在这个项目里让 Flask 承担了一个边界很清晰的职责报表服务。原因很实际。超市的报表查询往往很重动不动就是按时间范围、按门店、按商品分类汇总几十万条销售明细。如果这些查询都跑在 Django 主服务里一个慢查询就可能把整个业务请求队列拖住收银端的录入工作都会跟着卡。我当时把月销售报表、库存周转报表、采购成本分析这些查询拆到一个独立的 Flask 服务中让它直接读取同一个数据库的只读账号再对外提供 JSON 接口报表页面统一走这个服务。这样即使某天报表查询特别重也不会影响门店的日常操作。如果你不想拆得这么细也可以只在 Django 里加一个独立 app 来处理报表。但用 Flask 有一个额外好处如果以后要把这些报表能力开放给移动端或第三方系统它会自动变成一个天然的微服务边界接口协议和权限都可以独立管理不用动主系统的代码。当然这需要你在部署时多维护一个进程后面我会讲具体的部署方案。2.3 开发环境准备Python、虚拟环境与依赖安装这个项目建议使用 Python 3.10 或 3.11太旧的版本对 Django 新特性和第三方库的支持都不太友好。先创建一个项目目录在里面建虚拟环境python -m venv venv source venv/bin/activate # Windows 是 venv\Scripts\activate然后安装依赖。我在项目里主要用了 Django、Flask、数据库驱动和一个报表序列化用的库pip install django pip install flask pip install djangorestframework pip install mysqlclient # MySQL 驱动Linux 下需要装依赖 pip install psycopg2-binary # 如果选 PostgreSQL用这个 pip install gunicorn # Linux 部署用 pip install waitress # Windows 部署用如果你用 VSCode 写代码记得在项目里打开命令面板执行“Python: Select Interpreter”选中刚才创建的 venv这样代码补全和调试都会走正确环境。我第一次在 VSCode 里调 Django 项目时就因为解释器没切对导包一直飘红页面跑起来报 ModuleNotFoundError排查了半天才发现是环境指错了。开发阶段我用 SQLite 表结构上线再切 MySQL这一步可以通过 Django 的配置快速切换不需要改模型代码这也是选 Django ORM 的一个省心之处。3. 核心数据模型与业务流转设计3.1 商品、SKU、门店、仓库怎么建模数据模型设计是这套系统的地基地基歪了以后所有功能都难受。我的做法是从业务名词开始设计再逐步落到模型字段。首先是商品模型。超市里同一款可乐300ml 瓶装、500ml 瓶装、330ml 罐装、整箱装其实是不同商品编码也要不同。这类带规格差异的商品行业里叫 SKUStock Keeping Unit它是库存管理的最小单位。如果商品规格复杂比如一件衣服有颜色和尺码可以再引入 SPU标准产品单元来统一挂多个 SKU但在超市场景里我一般直接让“商品表”等于“SKU 表”每条记录就是一个最小库存单位简单直接。然后是门店和仓库。门店表保存门店编码、名称、地址、电话、状态仓库表保存仓库名称、所属门店、类型。类型字段很重要用于区分总部总仓还是门店仓。库存表则同时关联商品和仓库表示某个商品在某一个仓库里有多少库存。这里要用联合唯一约束确保同一个商品在同一个仓库里只有一条库存记录否则数据会重复。供应商表主要用于采购管理记录供应商名称、联系人、电话、结算账期。采购单关联供应商、仓库、商品明细保存每一笔采购的数量、单价、含税金额。这些模型建立好之后系统的主要数据骨架就出来了。3.2 库存台账与流水表进销存的灵魂如果说这个项目只能选一个最重要的设计我会选“流水表”的设计。我在项目里做了一张库存流水表 StockLog所有库存的变动不管来自采购入库、销售出库、门店调拨还是盘点调整都必须在流水表里写下一条记录记录商品、仓库、变动数量、变动类型、关联单据号、操作人、操作时间。为什么这么死板因为只要允许直接改库存表的数字就一定会有人图省事“先改了再说”结果就是账实不符。而流水表把每一次变动都留痕任何时点的库存都可以通过流水累加还原出来月底对账时一旦发现差异也能顺着流水一笔一笔查回去定位到具体是哪张单据出了问题。这里还涉及一个成本核算问题。超市进货不是一次性完成的不同批次进货价不一样那么销售后计算毛利时用哪个成本价我在这套系统里实现了两种方式先进先出和移动加权平均。先进先出就是按入库时间顺序消耗成本序列适合生鲜、快消这类有时效性的商品移动加权平均就是每次入库后用“(原库存成本 新入库成本) / (原数量 新入库数量)”算出最新成本价适合普通日用品。不管用哪种计算逻辑落库时都要在采购入库时记录批次成本否则后面的毛利报表根本没有数据支撑。3.3 采购、入库、调拨、出库四条主流程我把业务流转梳理成四条主线每条线都是一个状态机状态字段控制整条流程是否合法。采购流程门店或仓管看到库存低于安全线先提交采购申请填写商品、数量、期望到货时间总部采购员汇总各门店申请在系统里生成采购单采购单经过审核后状态变成“已审核”此时才能发给供应商供应商送货到仓库后仓管按单收货验货确认数量、批次、生产日期和保质期然后执行入库操作系统自动增加库存并写流水。销售出库流程收银台每完成一笔销售系统生成销售单同时扣减对应门店仓的库存。这里要注意销售单数量和退货单数量必须分开记录销售退货要以正向库存的姿势加回来而不是用负数去抵消这样才能保证统计口径清晰。门店调拨流程比如 A 门店缺货B 门店库存多可以通过调拨单从 B 店调货到 A 店。调拨单审核后B 门店出库减库存A 门店入库加库存系统同时生成一正一负两条流水保证两边账目守恒。调拨单没完成的时候如果把货算成“在途”就需要额外维护在途库存状态我就先提示各位这块水很深小规模项目尽量避免直接把调拨拆成“出库”和“入库”两个动作更稳。盘点流程盘点单记录某个门店或仓库的账面库存数和实际盘点数差异生成调整单系统自动把账面库存调整为实际库存并把差异写入流水。即便盘亏了也要如实记录不能直接把数字改掉这是财务审计的基本要求。4. 核心功能代码实现与细节4.1 Django 模型代码先把基础表立起来开始写模型前先用命令创建一个 app我把库存和商品相关模型放在 inventory 这个 app 里python manage.py startapp inventory然后在 settings.py 的 INSTALLED_APPS 里把 inventory 加进去否则迁移时不会扫描到这个 app。核心模型大致长这样from django.db import models from django.contrib.auth.models import User from django.utils import timezone class Category(models.Model): name models.CharField(max_length64, uniqueTrue, verbose_name分类名称) parent models.ForeignKey(self, on_deletemodels.PROTECT, nullTrue, blankTrue, verbose_name父分类) class Supplier(models.Model): name models.CharField(max_length128, verbose_name供应商名称) contact models.CharField(max_length32, blankTrue, verbose_name联系人) phone models.CharField(max_length32, blankTrue, verbose_name联系电话) settle_days models.IntegerField(default30, verbose_name结算账期天) class Product(models.Model): sku models.CharField(max_length32, uniqueTrue, verbose_nameSKU编码) name models.CharField(max_length128, verbose_name商品名称) category models.ForeignKey(Category, on_deletemodels.PROTECT, verbose_name商品分类) unit models.CharField(max_length8, default个, verbose_name单位) price models.DecimalField(max_digits10, decimal_places2, verbose_name销售价) cost models.DecimalField(max_digits10, decimal_places2, default0, verbose_name成本价) safety_stock models.IntegerField(default0, verbose_name安全库存) is_active models.BooleanField(defaultTrue, verbose_name是否上架) class Store(models.Model): code models.CharField(max_length32, uniqueTrue, verbose_name门店编码) name models.CharField(max_length128, verbose_name门店名称) address models.CharField(max_length255, blankTrue, verbose_name地址) phone models.CharField(max_length32, blankTrue, verbose_name电话) status models.BooleanField(defaultTrue, verbose_name是否营业) class Warehouse(models.Model): store models.ForeignKey(Store, on_deletemodels.PROTECT, related_namewarehouses, verbose_name所属门店) name models.CharField(max_length128, verbose_name仓库名称) wh_type models.CharField(max_length16, choices[(central, 总部仓), (store, 门店仓)], defaultstore) class Stock(models.Model): product models.ForeignKey(Product, on_deletemodels.PROTECT, verbose_name商品) warehouse models.ForeignKey(Warehouse, on_deletemodels.PROTECT, verbose_name仓库) quantity models.IntegerField(default0, verbose_name库存数量) class Meta: constraints [ models.UniqueConstraint(fields[product, warehouse], nameuniq_stock_product_warehouse) ] class StockLog(models.Model): CHANGE_TYPES [ (purchase_in, 采购入库), (purchase_return, 采购退货), (sale_out, 销售出库), (sale_return, 销售退货), (transfer_out, 调拨出库), (transfer_in, 调拨入库), (check_adjust, 盘点调整), (damage, 报损), ] product models.ForeignKey(Product, on_deletemodels.PROTECT, verbose_name商品) warehouse models.ForeignKey(Warehouse, on_deletemodels.PROTECT, verbose_name仓库) change_type models.CharField(max_length32, choicesCHANGE_TYPES, verbose_name变动类型) change models.IntegerField(verbose_name变动数量) # 正数为入库负数为出库 related_no models.CharField(max_length64, blankTrue, verbose_name关联单据号) created_by models.ForeignKey(User, on_deletemodels.SET_NULL, nullTrue, verbose_name操作人) created_at models.DateTimeField(auto_now_addTrue, verbose_name操作时间)商品表里的 DecimalField 我特别说明一下。进销存涉及金额一定不要用 FloatField浮点数计算 0.1 0.2 会得到 0.30000000000000004金额对不上时排查起来非常痛苦。DecimalField 在数据库层就是 decimal 类型精确存储虽然运算稍微慢一点但财务场景必须这么做。这个阶段我还会顺手提一句Django 删除对象在 ORM 里很简单Model.objects.filter(idxxx).delete() 或直接 instance.delete() 就可以。但库存相关的表我把外键的 on_delete 设置成了 PROTECT就是为了防止有人误删了商品分类、供应商这类基础资料导致历史流水跟着丢失。删除这种动作在业务系统里要慎之又慎宁可禁用按钮也不能让它连锁清空数据。4.2 采购入库接口事务、行锁和幂等性采购入库是整个系统里最容易出并发问题的地方。想象一个场景仓库同一时间收到两批货两个仓管同时在系统里操作同一张采购单入库如果没有任何保护机制两个请求读到库存都是 10各自加上 5最后库存变成 15而不是正确的 20。这种 bug 在单机开发时很难复现一上生产环境就会陆续冒出来。解决思路是用 Django 的原子事务配合行锁from django.db import transaction transaction.atomic def purchase_inbound(order_no): from .models import PurchaseOrder, Stock, StockLog, User order PurchaseOrder.objects.select_for_update().get(order_noorder_no) if order.status not in (audited, partial_inbound): raise ValueError(采购单当前状态不允许入库) for item in order.items.all(): stock, created Stock.objects.select_for_update().get_or_create( productitem.product, warehouseorder.warehouse, defaults{quantity: 0}, ) stock.quantity item.quantity stock.save() StockLog.objects.create( productitem.product, warehouseorder.warehouse, change_typepurchase_in, changeitem.quantity, related_noorder.order_no, created_by_id1, ) order.status inbound order.save()select_for_update 会在数据库层面把涉及的行锁住第二个请求必须等第一个事务提交后才能拿到锁这就从根上避免了并发超加。同时用事务把“查单、改库存、写流水、改状态”包成一个原子操作任何一个环节报错都会整体回滚不会出现库存加了但流水没记的中间态。幂等性也很关键。仓管可能手抖点两次提交按钮如果没有状态判断同一张采购单会被入库两次。所以我在函数开头就检查了采购单状态已经入库的单子再次执行会直接抛错。实际操作里我把这个状态检查放在锁内部是为了避免两个请求同时进来都看到“未入库”然后一起往下执行因为 select_for_update 会串行化后续读取只有当第一个事务提交后第二个事务才能读到最新状态此时 status 已经变成 inbound就会被拦住。4.3 门店调拨两颗库存同时变动的正确姿势门店调拨本质上是一笔事务内完成两个库存点的变更这里最容易犯的错误是只调出方减库存忘了调入方加库存或者两个操作之间崩了一个导致库存凭空消失。我实现的调拨逻辑是这样的transaction.atomic def apply_transfer(transfer_order_no): from .models import TransferOrder, Stock, StockLog transfer TransferOrder.objects.select_for_update().get(transfer_notransfer_order_no) if transfer.status ! audited: raise ValueError(调拨单未审核或已执行) out_stock Stock.objects.select_for_update().get( producttransfer.product, warehousetransfer.from_warehouse, ) if out_stock.quantity transfer.quantity: raise ValueError(调出仓库库存不足) out_stock.quantity - transfer.quantity out_stock.save() in_stock, created Stock.objects.select_for_update().get_or_create( producttransfer.product, warehousetransfer.to_warehouse, defaults{quantity: 0}, ) in_stock.quantity transfer.quantity in_stock.save() StockLog.objects.create( producttransfer.product, warehousetransfer.from_warehouse, change_typetransfer_out, change-transfer.quantity, related_notransfer.transfer_no, created_by_id1, ) StockLog.objects.create( producttransfer.product, warehousetransfer.to_warehouse, change_typetransfer_in, changetransfer.quantity, related_notransfer.transfer_no, created_by_id1, ) transfer.status done transfer.save()调拨和采购入库有个共同特点就是操作对象可能是同一条库存记录。为了减少死锁我建议在所有库存操作里统一锁的顺序先锁调出方再锁调入方先锁仓库 ID 小的再锁仓库 ID 大的。如果两个调拨单正好方向相反比如 A 调 B 和 B 调 A 同时发起又都先锁自己的调出方就会出现互相等对方释放锁的死锁。按统一顺序加锁可以彻底避免这种情况这也是很多数据库工程师说的“锁顺序一致性”。4.4 Flask 轻量报表服务与 Django 对接报表服务我只做了很克制的功能从只读数据库查询汇总数据返回 JSON。下面是一个按日汇总销售额的 Flask 接口from flask import Flask, jsonify from sqlalchemy import create_engine, text app Flask(__name__) engine create_engine(mysqlpymysql://report_ro:only_read_pwd127.0.0.1:3306/shop_system?charsetutf8mb4) app.route(/api/report/daily_sales, methods[GET]) def daily_sales(): with engine.connect() as conn: rows conn.execute(text( SELECT DATE(created_at) AS d, SUM(total_amount) AS amount FROM sale_order WHERE status completed GROUP BY DATE(created_at) ORDER BY d DESC LIMIT 30 )).fetchall() return jsonify([{date: str(r[0]), amount: float(r[1])} for r in rows]) if __name__ __main__: app.run(host0.0.0.0, port5001, debugFalse)我用的是 SQLAlchemy 的 engine 去连接数据库这个方式比 Flask 内置的请求上下文要干净不涉及模型定义而且天然支持连接池。Django 主系统不直接调这个查询而是前端页面通过 JavaScript 调用 /report 路径再由 Nginx 把 /report 的请求转发到 Flask 服务这样主服务和报表服务在 HTTP 层就完成了隔离。用 Flak 报表服务的时候一个容易被忽略的小事是数据库账号的权限。我给报表服务单独建了只读账号只授予 SELECT 权限即使某天服务代码出 bug也不会在数据库里误写数据。生产安全性上这是一个成本极低收益很高的做法。5. 部署、性能与数据安全5.1 生产部署方案Nginx Gunicorn / waitress上线部署我分两个平台说。Linux 服务器上用 Gunicorn 作为 WSGI 服务Nginx 做反向代理和静态文件托管。如果你在 Windows 上开发测试也可以用 waitress 启动 Django 应用稳定性比 Django 自带的 runserver 好很多这也是网上教程里常提到的“waitressnginx”组合来 Windows 部署的方案。Gunicorn 启动命令gunicorn config.wsgi:application -w 4 -b 127.0.0.1:8000 --timeout 60-w 4 表示开 4 个工作进程具体数量要根据服务器 CPU 核数和数据库连接池容量调整不是越大越好。进程太多数据库连接数很容易被打爆。对应的 Nginx 配置里除了把请求转发给 Gunicorn还要托管静态文件和上传文件server { listen 80; server_name your-domain.com; client_max_body_size 20m; location /static/ { alias /opt/project/static/; } location /media/ { alias /opt/project/media/; } location /report/ { proxy_pass http://127.0.0.1:5001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }Django 侧有几个部署前必须检查的配置DEBUG 必须设成 False否则报错页面会把服务器路径和代码信息泄露出去ALLOWED_HOSTS 要写上你的域名或服务器 IP否则请求会被拒STATIC_ROOT 要在部署机上执行 python manage.py collectstatic把静态文件统一收集到 static 目录Nginx 才能直接访问到。很多人在本地开发时一切正常一部署就出现样式丢失、图片加载不出来九成都是漏了 collectstatic 这一步。帖一个容易踩的坑如果你目标服务器是 Windows要注意路径分隔符的问题。代码里写 /opt/project/static/ 这种 Linux 绝对路径在 Windows 上会直接失效建议配置用 pathlib 或者 os.path.join 动态拼接不要把路径写死成反斜杠否则换环境就炸。5.2 数据库选型与索引优化别让报表拖垮业务库开发阶段用 SQLite 很舒服但并发写入一上来就顶不住。生产环境我建议用 MySQL 8.0 或 PostgreSQL两者的 InnoDB / MVCC 机制对事务型业务更友好。字符集在 MySQL 里务必使用 utf8mb4否则遇到生僻字或商品名里的特殊符号会报错这个坑我见过太多次了。索引设计是性能优化的核心。核心表上的字段都该建合适的索引表名建议索引解决场景Stock(warehouse_id, product_id)按仓库查商品库存最常用的查询路径StockLog(product_id, created_at)按商品查历史流水对账和报表都用得到SalesOrder(store_id, created_at)按门店查销售单销售日报核心查询PurchaseOrder(warehouse_id, created_at)按仓库查采购记录但如果只是建了索引还不够报表类查询扫全表依然是性能杀手。我在后面做了一个每日汇总机制每天凌晨通过定时任务把前一天的销售数据按门店、商品、分类汇总进一张 summary_sales 表业务查询只查这张汇总表几十万条明细不会直接面对用户的查询请求。这个优化立竿见影报表页面从原来的几秒钟降到了几百毫秒。Django ORM 还有两种查询优化的方法容易被忽略select_related 和 prefetch_related。查询采购单关联的商品信息时如果不做处理多张采购单会产生大量的 SQL 查询也就是 N1 问题。用 select_related 把外键对象一次性 join 出来或者用 prefetch_related 把反向关联批量查出来会减少到固定的几条 SQL。排障时我习惯装 django-debug-toolbar 看页面底部显示的 SQL 条数如果某个列表页 SQL 数超过几十条说明查询方式肯定要优化。5.3 权限控制、备份与操作审计权限这块我用的是 Django 自带的认证系统。每个用户可以分配多个 GroupGroup 里配置对应的 Model Permission。比如采购员组可以增加 PurchaseOrder 的 add、change 权限但不能删除门店店长组只能看到自己门店的数据这个需要在查询逻辑里再按当前用户的 store 外层过滤一层光靠 Django 的 Model Permission 做不了行级隔离。自定义权限也可以在模型 Meta 里声明比如class TransferOrder(models.Model): ... class Meta: permissions [ (can_approve_transfer, 可以审批调拨单), ]这样在后台给角色分配权限时就能看到这个自定义权限配合代码里的 user.has_perm(inventory.can_approve_transfer) 判断比自己在 UserProfile 里加 is_admin 字段要规范得多。备份是整个系统最容易被忽略又最要命的环节。我在服务器上配了定时任务每天凌晨用 mysqldump 做全量备份保留最近 30 天另外再做一次冷备文件上传到对象存储防止服务器磁盘故障把备份一起带走。光做备份还不够必须定期演练恢复我以前就遇到过 binlog 日志格式配置问题导致备份无法恢复到最新状态这种问题不演练根本发现不了。审计日志我采取的是简单粗暴的策略所有关键操作都通过 StockLog 记录再加上一张 AuditLog 表记录哪个用户在什么时间执行了什么操作、请求的 IP 和 UA。单子可以删但审计日志不能删这是最后一道防线。6. 常见问题与排查技巧实录6.1 库存负数、账实不符怎么追查库存变成负数是我遇到过最频繁的线上问题。为什么会出现负数归根结底是没有当时库存充足性校验或者并发环境下两个请求同时扣减。解决办法有两个代码层面凡是扣减库存的事务先 select_for_update 锁住对应库存行再判断 quantity 是否足够够才扣数据库层面给库存表加 CHECK (quantity 0) 约束这是最后的底线即使代码漏了数据库也会把非法数据挡在外面然后通过异常报告迅速定位到是哪个接口的问题。一旦账实已经不符我的排查路径是固定的一套先确定出问题的时间范围然后利用 StockLog 把该商品在该仓库的流水全部拉出来按时间顺序重新累加和 Stock.quantity 对比很快就能定位到是哪一笔单据让数据不平。常见原因基本就这么几类手工在数据库里改过库存、销售退货时写成了负数、调拨流程中只做一边出库又没做另一边入库、盘点差异单没生成流水。记住一个原则任何库存变动都必须有业务单据库存数字只是汇总结果不能成为直接修改对象。6.2 Django 静态文件加载不出来的经典问题本地开发时页面上的图片、CSS、JS加载不出来是最容易劝退新手的坑之一。最常见的原因是模板里没有正确使用静态文件标签而是直接写了 /img/logo.png 这种路径。正确写法是{% load static %} img src{% static img/logo.png %} altlogo然后是 settings.py 里要确保 INSTALLED_APPS 包含 django.contrib.staticfilesDEBUG 在本地开发必须是 True同时 STATIC_URL 设置成 /static/。如果模板写对、配置也对了还是加载不出来再去检查你是不是把静态文件放到了 Django 默认不扫描的目录。各 app 下的 static 目录里再建一个以 app 名命名的子目录是一劳永逸的做法避免多个 app 的静态文件互相覆盖。到了生产环境就是前面说的 collectstatic 加 Nginx 托管这两个步骤缺一不可。6.3 部署后上传和导出路径报错这个问题的本质是开发环境和服务器环境的文件系统路径不一致。开发时在 Windows 用反斜杠路径能跑通部署到 Linux 服务器上就找不到了。我的解决办法是所有上传和导出目录都用绝对路径配置且在 settings.py 里通过环境变量传入不用硬编码文件路径拼接用 os.path.join 或 pathlib 的 / 操作符不写死路径分隔符上传目录不存在时程序启动阶段自动创建避免权限和目录缺失引起的诡异报错。Flask 报表服务如果也要导出 Excel 或接收上传文件要注意 UPLOAD_FOLDER 和 MAX_CONTENT_LENGTH 都要在配置里显式声明并且确认 Nginx 的 client_max_body_size 足够大默认 1m 很容易把稍大点的 Excel 文件挡回来我遇到过客户传一个 3MB 的商品导入模板直接被 Nginx 拒绝这个问题排查到最后才发现根本不是代码问题。6.4 我踩过的几个印象深刻的坑第一个坑是时区。Django 默认 USE_TZ True数据库里存的时间是 UTC页面上如果直接展示能看到的时间比本地时间少 8 小时。报表按日分组时如果不做时区转换每天的零点到早上八点的销售额会被算到前一天数据怎么看怎么不对。我的做法是在查询开始前把当天开始结束时间转换成 UTC 时间再用转换后的时间做条件过滤。第二个坑是 DecimalField 的运算。Python 里的 Decimal 对象和 Django 的 DecimalField 配合使用时加减乘除都必须用 Decimal 类型不能混入 float。我一开始没注意用 float(price) 去参与计算结果报表金额偶尔差几分钱后来定位到是精度丢失。第三个坑是 get_or_create 在并发下的 IntegrityError。Django 的 get_or_create 底层是先查后插两个并发请求同时查询到记录不存在时两个都会尝试插入数据库其中一个会报违反唯一约束的错误。后来我在业务层加了重试逻辑捕获 IntegrityError 后重新查询一次情况就稳定了。这也再次说明库存这种核心数据表能提前对账建好记录就尽量提前建好不要依赖第一次入库时再去创建。第四个坑是 Excel 导入中文乱码。客户给的商品资料模板通常是 CSV 用 Excel 编辑的Excel 导出 CSV 默认可能是 GBK 编码Python 读取时要用 encodingutf-8-sig 去识别带 BOM 的 UTF-8或者先用 chardet 检测编码。自己写导入逻辑时最好统一约定模板文件用 UTF-8 编码并在页面提示用户不要手动改编码格式。这个坑很小但快下班时被它卡住的体验我一点都不想再来一次。最后说点个人体会。做进销存这种系统技术本身不是最大的难点真正的难点是业务流程梳理和数据一致性设计。我在项目里反复给团队成员强调一句话所有库存变动都必须从业务单据来所有单据都必须有状态流转所有人为修改都必须走审计。只要这三个原则坚持住了系统上线后的一个月里你会省下大量对账和救火的精力。如果你正在做类似的系统也把这三个原则刻进代码里吧后面你会感谢自己。