ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

连锁超市进销存系统实战:Django+Flask双框架架构解析

连锁超市进销存系统实战:Django+Flask双框架架构解析 如果你管过连锁超市的采购或者仓库大概率见过这种场面店长在微信群里报要货采购员对着 Excel 手工汇总仓库发货靠经验总部月底对账时发现三家门店的库存数据对不上更离谱的是同一个商品居然被重复采购了三次。我上手的第一个进销存项目就是在这种“Excel 微信群 电话”的混沌状态下启动的。折腾完整个系统我最大的感受是Python、Django、Flask 这套组合用在超市连锁门店仓库进销存采购管理系统上确实是国内中小型零售团队最务实的选择。这篇文章会把整个方案的边界、数据建模、核心流程、踩坑记录和落地部署全部分享出来适合正在规划同类系统、或者想用 Django 和 Flask 搭配做业务系统的开发者参考。1. 为什么这套系统必须让 Django 和 Flask 各管一头先说结论这个项目里Django 负责总部级的核心业务Flask 负责门店侧的轻量应用和接口服务。两者不是二选一而是各管一头通过统一的 API 边界协作。1.1 总部的复杂度需要 Django 兜底连锁超市的总部后台天然是“重”的。组织架构里有总部、区域、门店、仓库业务链条里有供应商、采购合同、采购订单、入库验收、调拨、盘点、销售汇总、财务结算角色上有仓管员、采购员、店长、财务、总部运营。这些实体和流程之间的关联关系非常密集靠手工拼装 SQL 或自己撸 ORM 都不现实。Django 在这类中大型业务系统里的优势不是炫技而是“开箱即用”的工程化能力。内置的 ORM 让多表关联查询、事务控制、数据迁移变得非常顺手写进销存这种高耦合业务时模型之间的 ForeignKey、UniqueConstraint、SelectForUpdate 能直接映射到业务规则。Admin 后台在一期开发时几乎等于白送了一个总部管理界面采购单审核、门店资料维护、供应商账期配置这些 CRUD 场景用 Django Admin 稍作定制就能上线。还有自带的认证授权体系给运营、财务、仓管分配不同权限不需要从零造轮子。另外Django 的迁移机制对进销存系统非常重要。业务上线后数据库结构几乎每个月都要调整比如给库存表增加“锁定库存”字段、给采购明细增加“含税单价”直接执行 SQL 容易出事故Django 的 migration 可以保证开发库、测试库、生产库结构一致。早期我图省事用过直接改表的方式结果一次字段漏改导致对账报表全乱从那以后再也不敢跳过迁移。1.2 门店边缘场景需要 Flask 的轻巧门店端的需求和总部完全不一样。店长不需要维护供应商不需要看复杂报表他每天要干的事情就几件查看本店库存、提交要货申请、录入收货确认、偶尔做盘点。这些操作如果也走 Django Admin界面太重、配置成本太高而且门店的网络环境往往不稳定系统必须足够轻、足够快。Flask 在这里刚好合适。路由简单、启动方便、依赖少门店端只需要一个几百 KB 的 Web 应用配合 SQLite 或轻量 MySQL 就能跑起来。更关键的是Flask 非常适合做“边缘适配层”它可以直接对接门店电脑上的扫码枪、小票打印机也能临时缓存离线数据网络恢复后再向总部同步。我曾经在门店收银机上部署 Flask 服务开机自启、无界面运行店长打开浏览器就能操作整个部署过程不到十分钟。1.3 双框架的通信边界与数据流两个框架之间的边界不能含糊否则就会变成“双头怪物”。我在这个项目里定的规矩是Django 持有主数据库负责所有核心单据和主数据的增删改查Flask 不直接操作总部数据库而是通过 HTTP JSON API 调用 Django 暴露的接口。门店端所有的要货单、收货确认、销售汇总都先落在本地库再异步提交到总部。数据流大概是这样的门店在 Flask 页面上提交要货申请Flask 生成一条带唯一请求编号的单据并传入总部接口Django 收到后校验商品编码和数量创建要货单总部仓管在 Django Admin 审核后仓库拣货出库库存减少门店确认收货后Flask 再一次调用接口Django 完成门店库存增加。这样设计的好处是任何一端出问题另一端都能保留数据继续运行等网络恢复后再对账补单。这个边界定清楚之后后面所有问题和 Django 的、Flask 的都可以快速定位。否则两边代码纠缠在一起光是排查“这个字段到底在哪边维护”就能耗掉半天时间。建议新项目一开始就把 API 版本、鉴权方式、异常码约定好不要边写边改。2. 数据库是第一位的进销存系统的表结构如何设计进销存系统的灵魂不在代码在数据模型。模型设计得不好后面所有流程都会卡壳。我经历了三轮重构才把表结构稳定下来下面这几组表是最终沉淀下来的核心。2.1 核心主数据供应商、商品与门店主数据是所有单据的基础。商品表、供应商表、门店表、仓库表这四个表必须在一开始就定得严谨。商品表里除了编码、名称、条码、规格、单位、分类、状态还应该预留“税率”“默认供应商”“保质期管理开关”这些字段因为这些字段一旦商品量上来之后再补清洗成本极高。# Django 模型示例商品、门店、供应商 class Product(models.Model): code models.CharField(max_length50, uniqueTrue, verbose_name商品编码) name models.CharField(max_length200, verbose_name商品名称) barcode models.CharField(max_length32, blankTrue, verbose_name条码) spec models.CharField(max_length100, blankTrue, verbose_name规格) category models.ForeignKey(Category, on_deletemodels.PROTECT) unit models.CharField(max_length20, verbose_name单位) status models.BooleanField(defaultTrue, verbose_name启用状态) shelf_life_days models.IntegerField(nullTrue, blankTrue, verbose_name保质期天数) default_supplier models.ForeignKey(Supplier, nullTrue, on_deletemodels.SET_NULL)这里有个关键点商品编码不能让业务员手工输入必须通过条码或选择已有商品生成否则数据质量很快失控。门店表和仓库表建议都增加唯一编码字段比如门店编码“SH001”、仓库编码“WH001”后续做跨系统对账时业务编码比自增 ID 可靠得多。供应商表除了公司名称之外至少要有联系人、电话、结算账期先别急着做太复杂的资质管理否则录入门槛太高。2.2 单据流采购单、入库单、调拨单与销售单进销存的核心逻辑围绕单据展开。所有“库存变化”都必须有单据来源禁止直接改库存数字。我做过的单表头 单明细结构基本可以覆盖超市场景采购订单头部记录供应商、采购员、状态、预计到货日期明细记录商品、数量、单价、金额。入库单关联采购单或其他来源记录仓库明细记录商品、批次、生产日期、保质期、数量。要货单/调拨单头部记录源仓库、目标门店、状态明细与要货计划绑定。销售汇总单门店每天上报的销售流水按 SKU 聚合数量与金额。这类单据属于强业务约束的数据建议所有金额字段用 DecimalField不要用 FloatField。Float 在累计计算时会出现精度漂移对账时差个几分钱对超市财务来说就是大事。状态字段用字符串别名管理比布尔值灵活比如采购单可以有 draft、pending、partial_received、received、cancelled 几种状态布尔值根本表达不了这种多阶段流程。2.3 库存台账与批次设计唯一可信数据源我踩过最大的教训是没有台账表只有一张实时库存表。结果每次盘点差异都查不到原因只知道“库存不对”却不知道哪个环节错了。后来老老实实加了库存台账表每一笔变动都记录一条流水哪个门店/仓库、哪个商品、变动数量、变动后结存、业务类型、业务单号、单价、发生时间。这样任何库存数字都能追溯来源盘点不平的时候顺着流水指到具体单据。批次管理是超市进销存容易遗漏的环节尤其是食品、生鲜、日化这类有保质期的商品。如果要管到保质期库存表里必须增加 batch_no、production_date、expire_date 字段。库存数量按照“仓库 商品 批次”维度存放出库时才能做到先进先出避免门店把临期商品压仓。这一块设计要预期到有些商品需要批次管理有些不需要所以批次字段应该可空不该一刀切。# 库存与台账模型 class StoreInventory(models.Model): store models.ForeignKey(Store, on_deletemodels.PROTECT) product models.ForeignKey(Product, on_deletemodels.PROTECT) batch_no models.CharField(max_length50, blankTrue, nullTrue) expire_date models.DateField(nullTrue, blankTrue) quantity models.DecimalField(max_digits12, decimal_places3, default0) updated_at models.DateTimeField(auto_nowTrue) class Meta: unique_together (store, product, batch_no, expire_date) class InventoryLedger(models.Model): store models.ForeignKey(Store, on_deletemodels.PROTECT) product models.ForeignKey(Product, on_deletemodels.PROTECT) change_qty models.DecimalField(max_digits12, decimal_places3) balance_qty models.DecimalField(max_digits12, decimal_places3) biz_type models.CharField(max_length20) # PURCHASE / TRANSFER / SALE / CHECK biz_no models.CharField(max_length50) created_at models.DateTimeField(auto_now_addTrue)库存唯一可信的数据源应该是台账实时库存只是台账汇总的结果。这意味着所有业务操作里的库存读写必须放在同一个事务中同时处理写完台账再更新库存表两者要么一起成功要么一起失败。这一点在下一部分展开细说。3. 核心流程的关键实现采购、要货、销售到底怎么跑通表结构定完之后最让人头疼的是业务流程的细节。超市进销存表面上是买东西、卖东西真正落地时每一步都有边界情况。下面这三个流程是我个人认为最容易出问题的也是整个系统的核心命脉。3.1 采购管理从下单到入库的闭环采购流程不是“创建采购单”然后“加库存”这么简单。实际业务里有这几个环节采购员根据门店要货汇总和库存下限生成采购计划确认供应商后创建采购订单订单要经过主管审核供应商送货后仓管验收入库入库数量可能和订单数量不一致最后财务按实际入库金额结算。我实现采购入库时特别强调“部分收货”能力。供应商经常这一批少送两箱、下一批补上采购单不能因为一次收货就关闭。每张采购明细可以多次入库只要累计入库数量不超过订单数量即可。Django 里用事务锁住采购单后再判断当前累计收货数量防止同时触发两次收货导致超收。from django.db import transaction from django.db.models import F transaction.atomic def receive_purchase_order(po_id, receive_items): po (PurchaseOrder.objects .select_for_update() .select_related(supplier) .get(idpo_id)) for item in receive_items: line po.items.get(iditem[line_id]) new_received line.received_qty item[qty] if new_received line.order_qty: raise ValueError(f商品 {line.product.name} 超出可收货数量) line.received_qty new_received line.save() StockInBill.objects.create(...) # 写台账、更新库存这里的 select_for_update 就是给采购单行记录加锁同时只有一个请求能进行收货不会出现两个仓管各自扫码之后把数量叠加超限的情况。发票联动的逻辑可以后续加但采购订单、入库单、应付账款这三者的关联必须从一开始就设计好否则财务没法对账。3.2 门店要货与仓库调拨双重库存变化的原子性门店要货是连锁超市每天最频繁的操作之一。门店在 Flask 端提交要货申请后总部审核、仓库出库、门店收货这个链条涉及“仓库库存减少”和“门店库存增加”两个动作而且中间可能隔几个小时甚至一两天。尤其在商品跨仓库调拨时更不能只扣减源头库存因为车辆在途的时间差会让双方对不上账。我的做法是引入“调拨单”作为中间单据而不是直接修改两端的实时库存。仓库出库时记为“仓库库存减少 在途库存增加”门店确认收货后在途库存减少、门店库存增加。这样调拨单未完成时商品在在途库存里也算有台账记录月底盘点不会丢数。这个逻辑用两个事务步骤做仓库出库一个事务门店确认收货另一个事务中间状态任何时候都能查清楚。3.3 销售出库与夜班结存不追求实时扣减反而更稳超市门店的销售是高频低单值的如果用 Flask 把每一笔销售都实时传给总部再扣库存网络稍微抖动就会造成一堆失败记录。我在门店端的方案是本地留存销售明细每晚打烊后生成当日销售汇总再批量上传到 Django 扣减库存。这里有一个设计取舍总部库存不需要实时等于门店前台库存因为门店本地有自己的一套库存概念。总部层面的库存主要用于采购分析和财务结算延迟一天完全可以接受。关键在于销售汇总上传的接口必须是幂等的门店局域网里的程序很容易重复提交同一批数据。我加了“业务日期 门店 唯一批次号”的唯一约束重复上传直接拒绝或返回成功但不重复扣减这样店长哪怕多点了几次“结存上报”也不会产生脏数据。4. 实战中踩过的坑与修复记录这个项目最值得分享的不是成功经验而是一堆真实坑。写代码时觉得逻辑没问题一上生产就翻车。下面三个坑每一个我都花过好几天的排查时间。4.1 库存并发扣减两个请求同时改同一个数字第一次上线门店盘点功能时两个仓管在同一时间对同一商品做了盘点一人加 5一人减 3。由于没有加锁最后库存变成了只加 5 而不是加 2线上库存直接错掉。查了半天才发现问题出在“读取 - 修改 - 写入”的竞争两个事务都读到原库存 10一个写入 15另一个写入 7后写覆盖先写。解法是用 Django 的 select_for_update 把目标行锁住或者至少用 F() 表达式做原子更新。我后来在库存变更统一入口强制使用选行锁避免绕过事务直接改库存。核心代码如下with transaction.atomic(): inv (StoreInventory.objects .select_for_update() .get(storestore, productproduct, batch_nobatch_no)) inv.quantity change_qty inv.save(update_fields[quantity, updated_at])注意不要锁库存明细里没有的负库存处理。超市允许部分商品负库存销售但必须设置上限我最初没加这个限制结果有些生鲜商品负到离谱的负数月度毛利全被拉平。后来在扣减逻辑里判断“扣减后数量如果小于 -50 就报错”至少把异常控制在可解释范围。4.2 事务边界不是把所有操作塞进一个原子块就完了早期我在处理入库时把采购单状态更新、台账写入、库存更新、应付账款生成全部塞进一个巨大的事务里。结果入库单只要一行数据校验失败整个单据回滚但前端提示却不友好而且长时间占用的行锁在并发操作时会堵死其他仓管的操作。正确的做法是拆分事务边界校验性的事务、计算性的事务、写日志的事务分开处理。大头提交数据前先做完整校验入库核心动作保持一个事务但只包含关键的库存和台账操作后续的财务生成可以异步去做。否则“事务大而全”听着安全实际上连锁反应更多一张单卡住后面所有单。4.3 报表性能进销存数据一多Django ORM 也扛不住进销存跑了一个月之后总部后台的“商品动销报表”慢到 30 多秒。问题出在报表查询对每行商品都实时统计采购量、销售量、库存等于每行报表跑几十次 SQL典型的 N1 查询。而且每次打开都重新聚合数据库越来越吃力。后来我做了三件事一是改进查询逻辑用 annotate 一次性聚合避免循环查询二是把每月数据的汇总结果缓存在汇总表里每天凌晨用 Celery 定时任务重新生成三是给库存台账表的 biz_type、created_at、product_id 加了联合索引。做完之后报表秒开数据库负载明显下降。做进销存项目时报表查询一定要从第一天就考虑数据量增长不要等慢了再优化。5. 管理驾驶舱与预警让数据自己说话而不是等人来翻表系统不只是记账工具还要能支撑运营决策。超市的利润是“省出来的”缺货、滞销、临期都直接吞利润。我在系统里加了一套预警和可视化看板这是管理层最喜欢的功能。5.1 库存周转与动销排行总部看板最核心的指标是库存周转天数和库存金额占比。周转天数等于当前库存金额除以日均销售成本可以简单用 30 天平均来近似。系统里我用 SQL 聚合把每个 SKU 的日均销量算出来再对比当前库存量低于安全库存的商品自动进入补货建议池。上线三个月之后总部发现排在滞销榜前 20 的商品占了 30% 的库存金额于是果断做了一轮清仓促销这部分经验对连锁超市的品类管理非常关键。5.2 临期商品与保质期预警做超市进销存保质期预警比缺货预警更急迫。商品过期报废直接就是亏损。我按商品表的 shelf_life_days 和库存批次表的 expire_date每天自动扫描所有批次的剩余天数剩余 60 天以内的进入黄色预警30 天以内进入红色预警并联动调拨建议把临期商品从A店调到B店促销。这个功能用 Django 的 management command 每天凌晨定时跑再用 WebSocket 或简单轮询推送到门店的 Flask 端店长打开页面就能看到“本店 3 个批次临期”。5.3 Flask 轻量可视化与实时推送门店端和总部端都增加了数据可视化。总部端用 Django 提供 JSON 接口前端用自带模板加简单图表渲染展示了门店销售趋势、品类销售占比、毛利贡献曲线。门店端用 Flask 返回轻量图表数据因为门店数据量小直接抓取当日销售额和库存状态即可不需要引入重型 BI 工具。热词里提到的“python django websocket 实现后台有数据前端推送”我在预警模块落地过后台扫描到异常数据缺货、临期、超缺货单未审核通过 WebSocket 推送到指定页面上门店不用刷新就能看到红色警报。初期如果不想引入 Django Channels用 Flask-SocketIO 也可以实现类似效果两者通过同一个 Redis 消息广播。6. 部署和落地时没人提前告诉我的事代码写完之后部署才是最考验人的环节尤其是门店电脑环境参差不齐。很多写着“本地可运行”的 Demo 一到真门店就翻车原因就在于环境配置没做好。6.1 Python 环境的那些细节门店电脑常见的是 Windows总部服务器一般是 Linux。我强烈建议所有代码从第一天就在虚拟环境里开发不要直接装在系统 Python 里。用 venv 或 conda 都可以关键是锁定 Python 版本和依赖版本Python 3.8 到 3.12 之间有一些库的兼容性差异。我在 VSCode 里配置 Python 环境时经常遇到解释器选错导致 import 不到已安装库的问题后来习惯在每个项目根目录建 .vscode/settings.json 指定虚拟环境路径才彻底解决。Linux 服务器上安装 Python 也是一样尽量用官方源码编译或系统包管理器安装不要同时混多个版本的 Python。Django 项目部署我用 Gunicorn 跑Flask 项目同样用 GunicornNginx 做反向代理和静态文件服务。数据库方面总部用 MySQL 8.0门店本地用 SQLite 或 MySQL 单库都行只要接口设计得好切换影响不大。6.2 让 Flask 应用变成门店能用的“傻瓜系统”门店工作人员不会敲命令所以 Flask 端要做成开机自启、无需维护。我在门店电脑上写了一个启动脚本注册成 Windows 服务开机自动拉起 Gunicorn 和定时任务。店长打开浏览器访问 localhost所有功能都用大按钮尽量不要出现需要手动输入 SQL 或者改配置文件的操作。部署时容易忽略的还有数据库字符集和时区。MySQL 里一定要把表字符集设置成 utf8mb4否则商品名称里有生僻字或者 emoji 时直接保存失败这在超市商品名里经常遇到。时区最好统一成业务所在地区不要为了省事都留 UTC否则每日销售汇总对账的时候会有小时偏差月底财务核对绝对头疼。6.3 上线切户的真实顺序先试点再推广最后聊一点落地经验这部分是技术之外但最影响成败的。不要一上来就所有门店同时切换系统我第一次推广时就是太乐观三家门店的数据迁移和人员培训同时做结果店长不会用、库存数据乱、退货单找不到差点项目黄掉。正确的做法是选一家配合度最高、日均单量适中的门店作为试点先并行跑两周手工账和系统账每天两边对账。确认系统账目准确、店员熟练后再扩展到其他门店。数据迁移阶段最需要注意的是期初库存必须通过盘点确认每个商品、每个批次的真实库存再录入系统否则“起始就错后面全错”。我用导模板 店长和总部会计双重复核的方式处理期初库存基本杜绝了批量导入造成的差异。这个系统从开发到稳定运行最大的体会是技术上 Django 和 Flask 的搭配非常成熟真正难的是把业务边界定清楚把数据模型理明白把并发和事务这些细节处理好。超市进销存虽然是老领域但做扎实了对连锁零售的效率提升是肉眼可见的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进