
做果蔬批发系统这个项目起因特别接地气我一个在农贸市场做批发的朋友每天凌晨三点开始忙活收了十几家散户的货再批发给市里的超市和小摊贩。他记账用的还是手写小本子一天的流水几百条到了月底对账对到头大。他一口气说了三个需求能管进销存、能打单、能算账。我跑了几个批发市场看了一圈发现市面上的进销存软件全是给零售商家设计的——扫码、收银、小票那套逻辑根本覆盖不了批发场景。于是这个用 Python Flask 和 Django 搭建的果蔬批发系统从一次酒桌闲聊变成了正经开发项目。这篇文章不打算写成项目说明书而是把整个设计决策和实现过程摊开讲果蔬批发业务的真实痛点在哪里、Flask 和 Django 怎么选、数据库怎么建模、订单和库存怎么联动、部署上线以后踩了哪些坑。适合正在做类似进销存项目的开发者、准备拿管理系统做毕设或作品集的同学也适合刚学完 Flask/Django 基础、想知道框架到底怎么用在真实业务里的朋友。1. 果蔬批发业务剖析这根本不是普通进销存1.1 批发和零售的三个本质差异做这个系统之前我花了一个星期蹲在朋友的摊位上看他们怎么干活。越看我越确定一件事批发业务流程和零售是两个物种。零售是选品、付款、拿走批发是一条更长的链路报价、下单、过磅、记录、装车、记账、结账。这个链路里的每个环节都有自己的特殊规则。第一个差异是计量单位。零售按份按件批发按斤按箱而且很多时候同一商品两种单位并存。土豆可以按斤卖给小摊贩也可以整箱卖给食堂叶菜基本只能按斤水果则看规格。我的系统里商品表必须同时支持两种单位的换算不能像零售系统那样写一个固定单位了事。第二个差异是价格机制。零售价贴上标签一周不变批发价一天能变好几次——早市价、午市价、下午清场价老客户和散客还不一个价。更麻烦的是批发的成交价通常不是一个冷冰冰的数字而是双方口头撮合的结果。系统要能支持一天多次调价并且每次调价最好都有记录月底别人问这个月白菜怎么越卖越贵的时候你能翻出调价日志来。第三个差异是结算方式。很多批发客户不是当场付款而是月底统一结账也就是常说的账期。系统里每一笔订单除了已支付和未支付还要有已记账、未结清这类批发特有的状态并且要能按客户累计欠款。1.2 果蔬商品的娇气属性再说果蔬本身的特殊性这里是最容易让没做过生鲜系统的人翻车的地方。第一个是损耗。叶菜早上进货一筐是100斤下午卖出可能只有92斤那8斤是自然脱水和摘除烂叶造成的。如果系统里库存只记入100出100账面没两天就乱了。我的处理方式是把损耗单独建模后面第三章会细讲。第二个是规格差异。听起来奇怪但批发市场里的同一种菜其实分好几个规格。同一批山东大葱统货一个价挑出来的粗葱一个价细葱一个价。如果不做规格维度你会发现价格表根本维护不下去。第三个是保质期压力。水果蔬菜的生命周期短则两三天长则一周。库存表上的数字和冷库里的实物经常对不上——并不是记账记错了而是货真的烂掉了。所以系统里必须有一个报损入口店员发现坏了一批货要能快速从库存里扣掉。1.3 用户画像与使用场景系统最终服务四类人老板管理员维护商品、价格看报表店员操作员下单、过磅、打单、报损财务月底对账、生成应收表还有客户——有些老客户想在手机上提前看第二天报价、预约留货所以系统后来加了一个面向客户的报价查询接口。使用场景按时间轴看也很鲜明。凌晨3点到5点是下单高峰几十个客户同时来要货系统必须扛得住快速录入。白天10点以后是补货和整理时间农残检测单、进货单要录入。月底是财务最忙的时候对账、催款、打印汇总表系统要能一键生成。2. 为什么我从 Flask 转到了 Django一次真实选型复盘2.1 先说结论Flask 不是不行是项目体量到了临界点项目一开始我其实用的是 Flask。为什么因为我偏爱轻量框架的灵活性和透明感——路由自己定义ORM 用 SQLAlchemy 自己装配模板想用 Jinja2 就用 Jinja2不想用拉倒。前两周在 Flask 里做原型写了大概 1200 行代码把商品管理下单库存跑通了当时觉得挺顺的。转折点是第三周开始加后台管理功能。具体来说老板需要一个能筛选商品、分页展示、导出 Excel 的列表页店员需要一个能查看今天的订单并按状态筛选的界面。在 Flask 里这些功能每个都要手动实现或者调研第三方库分页有 flask-paginate导出有 openpyxl筛选要自己拼查询条件。功能确实都能做但每加一个功能就要做一次选型和配置那种为了攥一个螺丝要跑一趟五金店的感觉越来越强。2.2 Django 的白送能力正好踩中需求试了一圈之后我把主体迁移到 Django发现几个能力简直是给这种管理系统量身定做的。第一是 Django Admin这个不用多废话。模型一定义后台管理页面自动就有了添加商品、改价格、查订单几乎零成本。对于果蔬批发场景这种内部管理系统Admin 完全可以当员工操作界面用省下的开发时间不是一点半点。第二是认证权限体系。Django 自带 User、Group、Permission 三个模型和完整的登录会话机制。老板、店员、财务的权限直接建三个组就完事店员能录订单但不能看毛利财务能看应收不能改库存。在 Flask 里要搞到同等能力至少得接入 Flask-Login 加 flask-principal还要手动写装饰器和中间件。第三是 ORM 和迁移。Django ORM 的 QuerySet 链式查询在写业务报表时非常顺手migrations 机制让我从开发到部署改了大大小小十几次表结构从来没手动写过一条 ALTER TABLE。2.3 最终架构Django 为核心 Flask 守边缘不过Flask 并没有被我从技术栈里拿掉而是换了个位置继续服役。最终架构是Django 跑在 8000 端口负责全部核心业务商品、客户、订单、库存、报表Flask 跑在 8081 端口只提供一个面向客户微信小程序的当日报价查询接口。两个服务共用同一个 MySQL 数据库。Flask 侧只读不做写操作所以不用考虑事务冲突。这样设计的好处是客户的报价查询请求不占 Django 的主业务进程凌晨高峰期报价接口再频繁也不会拖慢店员录单。Flask 那个服务从启动到跑通只花了一个下午代码不到 100 行部署上也只要一个 systemd 服务托管省内存启动快。如果要说选型建议我的排序很明确只想做个几百行的小工具或者简单 API用 Flask做一个需要后台管理、权限、数据报表的完整业务系统用 Django 可以省掉一大半重复劳动。维度FlaskDjango本项目选择原型速度快代码量少稍重初始样板多原型期 Flask正式开发 Django后台管理需手动集成自带 Admin即建即用Django用户权限需第三方库手动拼自带完整体系DjangoORM/迁移SQLAlchemy AlembicDjango ORM MigrationsDjango轻量 API非常合适可以做但略重Flask3. 数据模型设计把报价-过磅-开单固化进数据库3.1 先梳理业务流程再动笔建表动手写 models.py 之前我把批发业务按实际发生顺序完整捋了一遍列出六个关键动作供货商送货验收入库、每天早晨维护当日售价、客户来下单、过磅确定实际重量、记账并确认应收款、月底对账。由此拆出八张核心表用户表(User)、客户表(Customer)、商品分类表(Category)、商品表(Product)、价格日志表(PriceLog)、订单表(Order)、订单明细表(OrderItem)、库存流水表(StockLog)外加一张收款记录表(Payment)。有人可能会说水果批发系统怎么没有供应商表——我把它并进客户表逻辑了用 type 字段区分客户还是供应商因为两者在系统里信息字段几乎一样早期没必要拆两张表。3.2 核心模型与关键字段商品表是业务基础字段设计上我刻意加入了两个容易被忽略的字段spec 和 base_price。spec 表示规格统货、精选、大果、小果因为前面说过同一种菜在批发市场里是按规格分别报价的base_price 是基准进价用于和当前售价对比算毛利。订单表是整个系统的关键字段设计如下from django.db import models from django.utils import timezone class Order(models.Model): class Status(models.TextChoices): PENDING pending, 待确认 CONFIRMED confirmed, 已下单 WEIGHED weighed, 已过磅 SETTLED settled, 已结算 CANCELLED cancelled, 已取消 order_no models.CharField(订单号, max_length32, uniqueTrue) customer models.ForeignKey(Customer, on_deletemodels.PROTECT, verbose_name客户) total_amount models.DecimalField(订单总额, max_digits10, decimal_places2) discount_amount models.DecimalField(抹零金额, max_digits10, decimal_places2, default0) receivable_amount models.DecimalField(应收金额, max_digits10, decimal_places2) status models.CharField(状态, max_length20, choicesStatus.choices, defaultStatus.PENDING) created_at models.DateTimeField(下单时间, defaulttimezone.now) confirmed_at models.DateTimeField(确认时间, nullTrue, blankTrue) settled_at models.DateTimeField(结算时间, nullTrue, blankTrue) operator models.ForeignKey(User, on_deletemodels.PROTECT, verbose_name操作员)订单明细表保存每一行的商品、单价、下单数量、过磅数量。这里我特别强调过磅字段因为批发订单最终是按实际过磅重量结算的而下单时的数量往往只是个预估。detail 表里同时存在 quantity 和 actual_quantity 两个字段就是为了留一个预估和实际的对照。库存流水表统一记录所有库存变动入库、销售出库、报损、盘点调整。任何一次变动都往这张表里写一行这样月末可以完整还原库存变化的来龙去脉。3.3 价格字段设计的核心思路果蔬价格一天波动多次若只给商品表放一个 price 字段月底复盘时根本说不清当天卖的什么价。我做了两层设计商品表上有 current_price当前价和 base_price基准进价另有 PriceLog 表记录每次调价包含商品、旧价、新价、调整人和时间。店员每天早晨操作的第一步就是今日定价——批量把参考价复制为当日价个别商品单独微调。订单明细里的成交单价在新建订单时会自动从商品表 current_price 带出但允许操作员手动修改因为批发市场本来就有商量余地。同时明细里保存的是成交那一刻的快照价格即使第二天价格改了历史订单也不会变这一点是靠建表时复制值入表实现不是靠外键实时关联。3.4 库存与损耗的联动设计果蔬库存最大的坑是账面数字和实物对不上。我的模型里没有用简单的总数减总数而是引入了批次概念。进货时每生成一个 Batch批次记录数量、进价和入库时间出货时按批次先进先出扣减。这样有两个好处能知道某一批货还剩多少方便报损能准确计算每一批货的毛利。报损入口直接在仓库模块里店员选择商品、填报损数量、选原因腐坏、脱水、客户退换提交后自动产生一条库存流水并扣减库存。库存小于安全阈值时系统在 Dashboard 上提示补货这个功能很简单但对批发商的经营帮助非常大——烂在仓库里的菜被量化之后老板才会真的重视损耗控制。4. 订单交易流程的 Django 实现从下单到过磅到结算4.1 订单状态机每一次流转都有据可查订单状态的流转是这套系统的业务主干我的设计是五个状态待确认、已下单、已过磅、已结算、已取消。新单从微信或前台录入后进入待确认店员确认价格库存后变为已下单装车过磅、修改实际重量之后进入已过磅最后财务确认收款或记账后变为已结算。每个状态转换都记录操作人和操作时间Order 表里我专门加了 confirmed_at、weighed_at、settled_at 三个时间戳字段。这样做一开始是觉得反正加个字段又不难后来真到了客户打电话质疑那单是不是你们少算了一笔的时候翻记录一查就真相大白省了很多扯皮。4.2 核心视图价格联动和事务控制新建订单时的核心逻辑是从商品表带出 current_price 作为默认单价计算总额时用下单数量乘以单价得到总额再按操作员输入的抹零金额扣减得到应收金额。下单动作和库存扣减必须在一个事务里完成防止出现订单建了库存没扣的中间状态。from django.db import transaction from django.db.models import F transaction.atomic def create_order(request): customer_id request.POST[customer_id] items request.POST.getlist(items) # 每项含 product_id, quantity order Order.objects.create( order_nogenerate_order_no(), customer_idcustomer_id, operatorrequest.user, statusOrder.Status.CONFIRMED, ) for item in items: product Product.objects.select_for_update().get(pkitem[product_id]) if product.stock item[quantity]: raise ValueError(f{product.name} 库存不足当前{product.stock}斤) OrderItem.objects.create( orderorder, productproduct, priceproduct.current_price, quantityitem[quantity], ) product.stock F(stock) - item[quantity] product.save() StockLog.objects.create( productproduct, change_typeStockLog.SALE, change-item[quantity], operatorrequest.user, ) order.recalc_amounts() return order这里有两个要点。一是 select_for_update()它在数据库层面给这条商品记录加了行锁两个店员同时对同一商品下单时第二个请求会等到第一个事务提交才继续从根上避免超卖。二是 F(stock)让库存扣减发生在数据库端而非 Python 层避免读取-修改-写回三步操作之间的竞态条件。4.3 过磅环节按实际重量更新金额过磅是批发系统特有的环节。装车时实际重量和下单重量通常有出入操作员在过磅界面修改数量后系统要做三件事更新订单明细的 actual_quantity、重算订单金额、按差值调整库存。这个调整同样在事务里完成而且库存流水里要把冲减差额记为过磅调整不能记成销售否则报表会失真。4.4 Flask 辅助报价接口面向客户的微信小程序只需要一个接口查今天各个商品什么价、有没有库存。这种轻量只读接口用 Flask 写非常舒服。独立服务的好处是访问量大也不影响 Django 主业务。启动方式就是常见的 flask run --port 8081代码很少约 80 行from flask import Flask, jsonify from sqlalchemy import create_engine, text app Flask(__name__) engine create_engine(mysqlpymysql://user:passlocalhost/veg_market) app.route(/api/quote, methods[GET]) def quote(): with engine.connect() as conn: rows conn.execute(text( SELECT name, spec, current_price, stock FROM product WHERE is_active1 )).fetchall() return jsonify([dict(r._mapping) for r in rows]) if __name__ __main__: app.run(host0.0.0.0, port8081)实际部署时给 Flask 单独配上 systemd 服务开机自启进程挂了自动拉起。这很小但客户小程序端每天早上七八点会集中请求报价从几个月运行数据看这个接口的平均响应时间在 30ms 以内从来没给 Django 那边添过乱。5. 果蔬批发特有的业务难点与破解方法5.1 抹零问题批发交易里抹零是常态客户拿货 86 块 4 毛双方一句算了算 86这 4 毛就不收了。如果系统直接把实收金额改掉月底统计销售额时会发现和订单总额对不上老板还会误以为有人漏单。我的做法是订单表单独放 discount_amount 字段下单或结算时显式记录抹零金额月底可以单独汇总这个月共抹掉多少钱。老板看过这个数以后反而有了谈判依据——给老客户让利让在了明面上。5.2 过磅差异过磅差异我在 4.3 提过这里补充一个容易踩的坑整个系统的扣库存必须统一放在过磅后按 actual_quantity 扣而不是下单时扣一次、过磅修改时再扣一次。我早期版本是下单扣一次过磅时按差额再修正结果一周以后发现库存流水里全是销售出库和过磅调整交织报表极其难看。后来改成下单时不扣库存只锁定预留库存过磅确认后一次性按实际重量扣减并产生库存流水。这个改动让账务清晰很多。5.3 账期与应收管理批发客户按月结账很普遍所以订单的已结算并不等于已收款。我把 Order.status 的 SETTLED 定义为账务已确认再单独用 Payment 表记录每次收款按客户统计应收余额就是老客户欠款。再加一个简单的账期预警客户欠款超过 30 天或者超过预设额度下单时给出黄色提示。老板说这个功能虽小但真的帮他少做了好几笔坏账。5.4 并发抢单与超卖果蔬批发凌晨高峰期人手忙乱两个店员在收银台同时操作很常见。如果库存处理不做并发控制会出现明明只剩 50 斤白菜两个单都下 40 斤最后库存变成 -30 的诡异局面。Django 下用 select_for_update() 和事务包裹可以解决但前提是必须保证所有修改库存的入口都走同一套逻辑。所以在项目里我把所有库存扣减统一收口到 StockService 类不允许在视图里直接操作 product.stock这样排查库存问题时只需要盯一个地方。6. 部署与验收从开发机到市场的最后一公里6.1 开发环境准备开发环境我建议用虚拟环境隔离避免日常项目污染。搭建项目骨架时我用 django-admin startproject config . 创建主项目再用 python manage.py startapp market 创建业务应用商品、订单、库存这些模型都放在 market 应用里。这个看似基础的命令有个小细节——把项目命名为 config 而不是项目本身名字目的是让 WSGI 路径更固定避免日后改目录名带来的一堆麻烦。创建虚拟环境、安装 Django 和 MySQL 驱动后接下来多数新手会卡在数据库连接上Django 默认的 MySQLdb 在 Windows 下不友好装 mysqlclient 又经常编译失败。最省事的方式是安装 pymysql然后在项目init.py 里加一行配置。还要记得把 settings.py 里的时区改为 Asia/Shanghai否则创建时间会差 8 小时这个坑我花了一天才发现。import pymysql pymysql.install_as_MySQLdb()6.2 服务器部署服务器我用的 Ubuntu部署架构是 Nginx 反向代理 Gunicorn 的 Django 应用。Gunicorn 起 4 个 worker配置 systemd 托管。关键步骤有三个静态文件收集Django 的 collectstatic、生产环境关闭 DEBUG 并配置好 ALLOWED_HOSTS、给 Flask 报价服务单独配一个 systemd 单元。部署脚本的核心配置大致如下# /etc/systemd/system/gunicorn.service [Unit] Descriptiongunicorn daemon for veg_market Afternetwork.target [Service] Userwww-data WorkingDirectory/var/www/veg_market ExecStart/var/www/veg_market/venv/bin/gunicorn --workers 4 --bind 127.0.0.1:8000 config.wsgi:application Restartalways [Install] WantedBymulti-user.targetNginx 配置里把 /static/ 直接指向静态文件目录其余请求转给 127.0.0.1:8000。第一次上线时我发现图片偶尔加载不出排查半天才发现是静态文件路径权限问题——Nginx 的 www-data 用户没有静态目录读权限chown 之后解决。6.3 上线后真实运营中的调整这个系统上线第一周真实使用中暴露了三个问题。第一个是订单列表分页慢原因是最初用 Django ORM 的 filter 不加索引数据量一大就开始卡后来给 order_no、customer_id、created_at 都加了组合索引问题解决。第二个是凌晨高峰期录单偶尔超时原因是 Gunicorn worker 配置了同步类型个别慢查询会阻塞其他请求后来换了 gevent worker 并给关键列表页做了 Redis 缓存。第三个是打印单据格式问题批发客户要的票据包含单位、规格、过磅重量和常见电商小票完全不同用 Django 模板单独写了打印页配合浏览器的打印样式才算是真正满足业务场景。这些调整都是在小范围试运行期完成的没有出现数据丢失靠的是每次上线前都做了数据库备份和迁移回滚演练。最后再分享一个小技巧项目上线后我每隔两天会跑一个简单的告警脚本检查订单数、库存负数和接口响应时间。这个脚本是我自己用 Python 写的每天早上八点把前一天的经营汇总推到手机。批发生意最怕的就是坏账和损耗系统能做的不是帮你决定而是把这些数字明明白白摆在你面前。对我来说技术上最大的收获不是哪一行代码写得精妙而是学会了站在批发商的角度去理解数据两个字的分量。