简介:面向正在学习 Python Web 开发、希望把后端数据转化为可视化报表的开发者,这份项目资料围绕 Django 与 ECharts 的配合展开,覆盖模型与视图、接口返回 JSON、前端模板渲染图表以及静态文件管理等完整链路。整包共 2000 个文件、21.96MB,以 py 源码和 pyc 缓存为主体,辅以 html 模板、js 脚本、css 样式及 mo/po 本地化文件,并保留 venv 环境与项目配置痕迹,便于按目录结构检索和对照运行。已有 1132 人学习下载。读者可从中了解 Django 视图如何组织数据接口、ECharts 如何接收数据并配置折线图、柱状图、饼图等常见图表,以及模板、静态资源与环境隔离的落地方式,适合作为课程设计、报表模块原型或二次开发的参考底稿。
1. 从一张明早要交的看板说起:Python + Django + ECharts 到底解决什么问题
月度经营会前一天,业务方丢过来一句话:「明早我要看到各渠道的销售走势和城市分布。」从零搭一套 BI 平台显然来不及。这时候 Python + Django + ECharts 这套组合就派上用场了:Django 用 ORM 把库里的明细数据聚合成统计结果,ECharts 在浏览器里把结果画成折线、柱状、饼图,整条链路只有一条 Python 服务和几个静态文件,不用额外的前端工程,也不用买图表服务。它适合手里已经有业务库、需要快速交付内部报表的开发者;也适合想把「取数—聚合—展示」跑通一遍再决定要不要上重型方案的人。下面按真实落地顺序拆:骨架怎么搭、聚合怎么写、图表怎么配、坑在哪。
2. 骨架与数据链路:为什么模板直出比前后端分离更合适
先把选型说清楚,不然写到一半容易反复。做内部报表时,常见的三种做法是:Django 模板直出(把数据塞进 context,页面里渲染)、Django 提供 JSON 接口 + 前端 fetch 渲染、完全前后端分离(Vue/React 单独工程)。第一种最快,但图表数据一多,模板里写{{ data|safe }}很容易出现转义和 XSS 问题;第三种最规范,但对一个「三天要上线」的报表来说,构建链、跨域、部署全是额外成本。
我一般选中间那条路:页面用 Django 模板渲染骨架,图表数据走独立的 JSON 接口。好处是职责清晰——模板只负责布局和静态资源引用,接口只负责返回{code, data}结构,ECharts 的option组装全在 JS 里完成。数据量翻倍时只需要改接口,页面不用动;后面要把这张图嵌到别的系统里,接口直接复用。
2.1 三层结构:ORM 聚合 → 视图组装 → 前端渲染
整条链路分三层,每一层的输入输出都要约定死,否则后期排查会很痛苦。
| 层级 | 职责 | 输入 | 输出 |
|---|---|---|---|
| 数据层(models + ORM) | 明细表、聚合查询 | 过滤条件、时间范围 | QuerySet(values 字典) |
| 接口层(views) | 参数校验、结构统一、类型转换 | HTTP 查询参数 | {"code":0,"data":[...]} |
| 渲染层(template + ECharts) | 组装 option、交互、自适应 | JSON 数组 | 图表 |
关键约定只有两条:接口返回的每条记录必须是{"name": ..., "value": ...}这种扁平结构,前端拿到就能直接喂给series.data;所有Decimal在接口层统一转float,绝不留到 JSON 序列化阶段再炸。这两条定下来,后面加十张图都是复制粘贴。
2.2 环境与最小可运行骨架
先用一个模拟业务表起步,字段贴近真实场景:渠道、城市、金额、下单时间、是否支付。
# 建虚拟环境并装依赖,版本按自己项目的锁文件来,不要盲目 latest python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install "django>=4.2,<5.0" # 长期支持版,报表类项目够用 django-admin startproject report_site cd report_site python manage.py startapp reports模型只保留报表真正要用的字段,索引加在时间字段上——后面所有按天、按月聚合都靠它。
# reports/models.py from django.db import models class Order(models.Model): """模拟业务表:一条订单明细""" channel = models.CharField(max_length=32, db_index=True) # 渠道来源 city = models.CharField(max_length=32, db_index=True) # 城市 amount = models.DecimalField(max_digits=12, decimal_places=2) created_at = models.DateTimeField(db_index=True) # 聚合和筛选的主力字段 is_paid = models.BooleanField(default=False) class Meta: db_table = "t_order" indexes = [models.Index(fields=["is_paid", "created_at"])] # 组合索引覆盖常见过滤db_index=True单独加在created_at上是为了范围过滤,Meta.indexes里的组合索引是给「已支付 + 时间段」这种高频查询用的。报表项目的慢查询八成出在这一步,字段没索引,几十万行数据按月聚合就能卡到超时。
注册 app、配置静态目录,然后把模板骨架搭起来:
# report_site/settings.py 片段 INSTALLED_APPS = [ "django.contrib.contenttypes", "django.contrib.staticfiles", "reports", ] STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR / "static"] # 放 dashboard.js 和 echarts.min.js<!-- reports/templates/reports/dashboard.html --> {% load static %} <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>渠道销售看板</title> <!-- 本地引入,别依赖外部 CDN,内网环境加载不出来就是一片空白 --> <script src="{% static 'js/echarts.min.js' %}"></script> </head> <body> <div id="channel-chart" style="width:100%;height:420px;"></div> <script src="{% static 'js/dashboard.js' %}"></script> </body> </html>容器上的height:420px不是随便写的,ECharts 初始化时会读容器尺寸,高度为 0 或者父级display:none时图表就是一片白,控制台还不报错。这个坑后面还会单独拎出来说。
2.3 第一个柱状图:从 QuerySet 到 option 的完整闭环
先写接口。注意values()和annotate()的先后顺序,这决定了分组字段是谁——写在values里的字段是分组维度,annotate里的是聚合结果。
# reports/views.py from django.db.models import Sum, Count from django.http import JsonResponse from django.shortcuts import render from .models import Order def dashboard(request): return render(request, "reports/dashboard.html") def api_channel_amount(request): """按渠道汇总已支付金额,返回 ECharts 可直接消费的结构""" rows = (Order.objects .filter(is_paid=True) # 只统计已支付,过滤放在聚合之前 .values("channel") # 分组维度 .annotate(total=Sum("amount"), cnt=Count("id")) .order_by("-total")) # 排序在数据库侧完成,前端不再排 data = [ {"name": r["channel"], "value": float(r["total"]), "cnt": r["cnt"]} for r in rows ] return JsonResponse({"code": 0, "data": data})float()这一步不能省。Sum("amount")出来的是Decimal,Django 默认的JsonResponse用DjangoJSONEncoder,虽然能序列化Decimal,但会转成字符串,前端拿到"1234.50"再去做数值比较或者格式化就会出幺蛾子。统一在接口层转float,前端只管画图。order_by("-total")放数据库侧,是因为渠道可能有几十个,排序结果直接影响前端取 TopN 的逻辑。
前端侧把fetch和setOption拆开,方便后面复用:
// static/js/dashboard.js const chart = echarts.init(document.getElementById('channel-chart')); async function loadChannel() { const resp = await fetch('/api/channel-amount/'); const { data } = await resp.json(); // 约定结构固定为 {code, data} chart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: 60, right: 24, top: 40, bottom: 60 }, xAxis: { type: 'category', data: data.map(d => d.name), axisLabel: { rotate: data.length > 8 ? 30 : 0 } // 渠道多时倾斜,避免文字重叠 }, yAxis: { type: 'value', name: '金额(元)' }, series: [{ type: 'bar', data: data.map(d => d.value), barMaxWidth: 36, itemStyle: { borderRadius: [4, 4, 0, 0] } }] }); } window.addEventListener('resize', () => chart.resize()); // 侧边栏收起时也能跟上 loadChannel();grid的四个值是按「Y 轴标签宽度 + X 轴文字倾斜 + 标题」估出来的,默认值在中文标签下几乎必然被裁掉。barMaxWidth是防止只有两三个渠道时柱子被拉成巨型色块。resize监听挂在window上能应付窗口缩放,但应付不了容器自身宽度变化,这个放到避坑一节展开。
跑起来验证:先python manage.py migrate,往表里塞几条测试数据,python manage.py runserver打开页面。如果柱子高度正常、tooltip 能跟随,这条链路就算通了。
3. 报表聚合:把计算压进数据库,别用 Python 循环硬扛
报表做多了会发现,真正的难点不是画图,是「怎么把明细算成统计值」。新手最典型的写法是把明细全filter出来,然后在 Python 里for循环累加——几千行还行,几十万行就是把数据库当存储、把应用服务器当计算引擎,内存和响应时间双爆。正确姿势是把聚合下推到 ORM,让数据库去算,Python 只负责组装输出结构。
3.1 values + annotate:分组统计的标准写法
values("字段").annotate(别名=聚合函数())是分组统计的固定套路。理解它的关键是:values里列出的字段就是 SQL 的GROUP BY列表,annotate里的是SELECT中的聚合列。
# 按城市统计订单数和客单价 from django.db.models import Sum, Count, Avg rows = (Order.objects .filter(is_paid=True, created_at__gte=start, created_at__lt=end) .values("city") .annotate(order_cnt=Count("id"), gmv=Sum("amount"), avg_amount=Avg("amount")) .order_by("-gmv")[:20]) # 只取前 20 个城市,避免图表挤成一团created_at__gte/__lt这种左闭右开的写法比__range更安全,跨月统计时不会把边界那一天的零点重复计入。Count("id")比Count("*")语义清晰,虽然生成的 SQL 差不多。切片[:20]会直接变成 SQL 的LIMIT 20,不是在 Python 里截断,这点很关键——限制在数据库侧做,传输量才降得下来。
需要多个维度交叉时,values里加字段就行:
# 渠道 × 城市 的二维汇总,用于堆叠柱状图 rows = (Order.objects.filter(is_paid=True) .values("channel", "city") .annotate(gmv=Sum("amount")) .order_by("channel", "-gmv"))返回的是扁平的字典列表,每行带channel、city、gmv三个键。前端要做堆叠图,得自己按channel分组拼成series数组,这一步放在接口层做比放前端做更省事,也更容易加缓存。
3.2 条件聚合:一次查询算出多个口径
报表经常要在同一张图上展示「总金额」和「已支付金额」两个口径。写成两次查询再合并,不仅多一次往返,还可能出现两次查询之间数据变动导致的不一致。用Case/When做条件聚合,一条 SQL 就能出结果。
from django.db.models import Case, When, Value, DecimalField, Sum, Count rows = (Order.objects .values("channel") .annotate( total_gmv=Sum("amount"), # 全部订单金额 paid_gmv=Sum(Case( # 仅已支付的金额 When(is_paid=True, then="amount"), default=Value(0), output_field=DecimalField(max_digits=12, decimal_places=2), )), paid_cnt=Count(Case(When(is_paid=True, then="id"))), # 条件计数 ))output_field在Sum(Case(...))里是必须的,Django 靠它推断返回类型,缺了在部分数据库后端会直接抛FieldError。Count(Case(When(...)))统计的是非NULL值,不满足条件的行Case返回NULL,正好被Count跳过——这是条件计数不用写filter的原因。这个写法在支付转化率、退款率这类报表里能省掉一半查询。
3.3 时间粒度:按天、按周、按月聚合
时间序列图最常翻车的地方是「日期不连续」。直接按天分组,没有订单的那天在结果里根本不存在,折线图会把这些天跳过,画出来是一条斜率失真的斜线,看上去业务天天在涨,实际中间空了三天。
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek def api_daily_trend(request): rows = (Order.objects.filter(is_paid=True) .annotate(day=TruncDate("created_at")) # 数据库侧截断到天 .values("day") .annotate(gmv=Sum("amount")) .order_by("day")) # 结果里只有有数据的日期,缺口要自己补 return JsonResponse({"code": 0, "data": [ {"date": r["day"].strftime("%Y-%m-%d"), "value": float(r["gmv"])} for r in rows ]})TruncDate走的是数据库的日期截断函数,比在 Python 里strftime快一个量级,而且能命中created_at的索引。TruncMonth、TruncWeek同理,切换粒度只改一行。补缺口放在接口层做:先用datetime生成完整的日期序列,再用查询结果做字典映射,缺失的补 0。这样前端拿到的xAxis.data和series.data长度永远一致,不会出现错位。
3.4 接口层统一结构:让十张图共用一套前端代码
图表一多,最怕每个接口返回结构都不一样。定一个统一外壳:{"code": 0, "msg": "ok", "data": {...}},data内部再按图表类型分。折线图给{"x": [...], "series": [{"name": "...", "data": [...]}]},饼图给[{"name": ..., "value": ...}]。前端写一个renderChart(el, option)的公共函数,各页面只负责拼option,渲染逻辑不重复。
参数校验也要在接口层收口。时间范围、粒度、TopN 这些查询参数,用request.GET.get()取出来之后必须做类型转换和边界限制——top传个100000进来,等于让数据库全表排序。常见做法是写个小的parse_int工具函数,给默认值、给上限,非法输入直接返回错误码而不是让它进 SQL。
4. ECharts 侧的四类图表:配置项怎么写才不出洋相
图表配置看着是前端活,但报表项目的配置项基本是后端开发顺手写的,所以这一章按「什么业务场景配什么图、关键项写什么值」来组织。四类图覆盖了内部报表八成的需求:趋势看折线、对比看柱状、构成看饼图、两个量纲放一起看双轴。
| 图表类型 | 典型场景 | 必须显式配置的项 |
|---|---|---|
| 折线图 | 日/月趋势、同比环比 | xAxis.boundaryGap、dataZoom、connectNulls |
| 堆叠柱状图 | 渠道构成、分类对比 | series.stack、series.barMaxWidth、图例顺序 |
| 饼图/环形图 | 占比、TopN + 其他 | radius、label.formatter、minAngle |
| 双 Y 轴 | 金额与单量、流量与转化 | yAxis数组、series.yAxisIndex、轴颜色对应 |
4.1 折线图:时间轴、空值与缩放
时间序列的xAxis用type: 'category'而不是'time',原因是后端已经补全了连续日期,用类目轴能保证「每个点等间距」,不会因为周末没有数据而被压缩。配boundaryGap: false让折线从 Y 轴起始,视觉上更符合趋势图的直觉。
// 趋势图:日期连续 + 可缩放 + 空值断点 const trendChart = echarts.init(document.getElementById('trend-chart')); trendChart.setOption({ tooltip: { trigger: 'axis' }, xAxis: { type: 'category', boundaryGap: false, data: xLabels, // 后端补全的连续日期,长度与 series 一致 axisLabel: { hideOverlap: true } // 日期多时自动隐藏重叠标签 }, yAxis: { type: 'value', scale: true }, // scale:true 让 Y 轴不从 0 起,波动更明显 dataZoom: [ { type: 'inside', start: 70, end: 100 }, // 滚轮缩放,默认看最近 30% { type: 'slider', height: 20, bottom: 8 } // 底部滑块,方便拖拽定位 ], series: [{ name: 'GMV', type: 'line', smooth: false, // 报表别开平滑,平滑曲线会让数据看起来比实际好看 connectNulls: false, // 真空值就断开,不要连成假趋势 showSymbol: xLabels.length < 40, // 点太密时隐藏圆点,减少渲染量 data: values }] });smooth: false是血泪经验:开了平滑之后,两个数据点之间会画出并不存在的峰值,业务方拿着截图来对数据,解释半天。scale: true同理,金额从 10 万到 12 万波动,Y 轴从 0 起的话整条线几乎是平的,看不出任何信息,但要在图上标注「Y 轴未从 0 开始」,避免误读。dataZoom的start: 70表示默认显示后 30% 区间,长周期报表打开就能看到最近的数据。
4.2 堆叠柱状图:series 顺序和 stack 必须对齐
堆叠图的核心是同一组stack名字的series会叠在一起。坑在于「图例顺序」和「堆叠顺序」是两回事,图例按series数组顺序排,堆叠从下往上也是这个顺序,但业务方往往希望某个大头在最底下。控制方法就是控制series数组的构造顺序,别指望 ECharts 帮你排。
// 渠道 × 城市堆叠:接口返回扁平数组,前端按维度重组 const channels = [...new Set(raw.map(r => r.channel))]; const cities = [...new Set(raw.map(r => r.city))]; const series = channels.map(ch => ({ name: ch, type: 'bar', stack: 'total', // 同一 stack 名才会堆叠 barMaxWidth: 40, emphasis: { focus: 'series' }, // hover 时高亮整条系列 data: cities.map(c => { const hit = raw.find(r => r.channel === ch && r.city === c); return hit ? hit.gmv : 0; // 缺组合补 0,保证长度一致 }) })); stackChart.setOption({ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, legend: { data: channels }, // 显式给图例,顺序即 series 顺序 xAxis: { type: 'category', data: cities }, yAxis: { type: 'value' }, series });data数组长度必须和xAxis.data完全一致,缺的组合要补 0 而不是跳过——跳过会导致后面的数据整体左移,图看起来对但数值全错,这类问题肉眼极难发现。emphasis.focus: 'series'让鼠标悬停时其它系列变淡,多系列堆叠时可读性提升明显。
4.3 饼图与环形图:TopN 加「其他」
渠道可能有几十个,全塞进饼图就是一盘碎渣。标准做法是取 TopN,剩下的合并成「其他」。合并动作在接口层做,前端不应该知道业务规则。
// 环形图:中心留白放总额,小占比合并展示 pieChart.setOption({ tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, legend: { orient: 'vertical', right: 12, top: 'middle' }, series: [{ type: 'pie', radius: ['45%', '68%'], // 内半径/外半径,形成环形 center: ['40%', '50%'], // 给右侧图例留空间 minAngle: 3, // 小于 3 度的扇区强制撑开,避免看不见 avoidLabelOverlap: true, label: { formatter: '{b}\n{d}%' }, data: pieData // [{name, value}, ...],已含「其他」 }], graphic: [{ // 环心文字,用 graphic 而不是 title type: 'text', left: '36%', top: '46%', style: { text: '总 GMV', fontSize: 13, fill: '#888' } }] });minAngle解决的是「1% 的扇区细得点不中」的问题,avoidLabelOverlap解决标签打架。环心文字用graphic而不是title,因为title是相对整个画布定位的,图例一挪位置就对不上了。
4.4 双 Y 轴:量纲不同的两个指标怎么放一张图
「金额」和「订单数」放一张图是最常见的需求,量纲差两个数量级,共用一根 Y 轴的话订单数会被压成一条贴着 X 轴的直线。双轴的标准配置是yAxis给数组、series里用yAxisIndex指定挂哪根轴,同时把轴线和系列颜色对应起来,否则读者分不清哪根轴对应哪条线。
// 金额(左轴)+ 订单数(右轴) dualChart.setOption({ tooltip: { trigger: 'axis' }, legend: { data: ['GMV', '订单数'] }, xAxis: { type: 'category', data: xLabels }, yAxis: [ { type: 'value', name: 'GMV(元)', axisLine: { show: true, lineStyle: { color: '#5470c6' } } }, { type: 'value', name: '订单数', axisLine: { show: true, lineStyle: { color: '#91cc75' } }, splitLine: { show: false } } // 右轴不画网格线,否则两组网格交错,视觉噪音大 ], series: [ { name: 'GMV', type: 'bar', yAxisIndex: 0, data: gmvValues, barMaxWidth: 36 }, { name: '订单数', type: 'line', yAxisIndex: 1, data: cntValues, smooth: false } ] });右轴的splitLine.show: false是必须的,两组网格线叠在一起,读图的人会下意识拿左轴刻度去读右边的线。系列颜色和轴线颜色保持一致,是最省事的降低误解手段。
5. 排查与避坑:图表空白、数据错位的五个现场
报表上线后出问题,症状往往只有一句「打不开」或者「数不对」,没有堆栈没有日志。下面五条是这类项目里复现率最高的,每条按「现象 → 原因 → 解决」写,遇到时可以直接对照。
5.1 图表一片空白,控制台没有任何报错
现象:页面能打开,div也在 DOM 里,但画布区域是白的,network 面板里接口返回 200 且数据正常。
原因:echarts.init()执行时容器高度为 0。最常见的是容器写在display:none的 tab 页里,或者父级用 flex 布局但没给固定高度,height:100%在父级高度为 auto 时算出来就是 0。ECharts 按初始尺寸算画布,尺寸为 0 时它什么都不画,也不报错。
解决:给容器写死像素高度(height:420px),或者确保父链上每一级都有确定高度。如果图表在 tab 里,改成「切换 tab 后再init」,已经初始化过的调chart.resize()。
// tab 切换后再渲染,避免在隐藏容器里 init document.querySelectorAll('[data-tab]').forEach(btn => { btn.addEventListener('click', () => { const el = document.getElementById(btn.dataset.tab); if (!el.dataset.inited) { // 首次可见时才初始化 initChart(el); el.dataset.inited = '1'; } else { echarts.getInstanceByDom(el).resize(); } }); });5.2 接口返回正常,前端报 “Converting circular structure” 或数据是字符串
现象:图能出来但数值全是NaN,或者浏览器控制台报 JSON 序列化相关错误。
原因:两个方向。后端把Decimal直接塞进JsonResponse,序列化成字符串"1234.50",前端拿去做数值运算就成了NaN;或者视图里直接return JsonResponse(queryset),QuerySet 不是可序列化对象,个别场景会绕到模型实例的循环引用上报错。
解决:接口层强制走一遍「values()→ 字典列表 → 显式类型转换」,Decimal一律float(),日期一律strftime成字符串。JsonResponse只接收dict或list,永远不要直接扔 QuerySet 或模型实例进去。转换这一步写成公共函数,所有接口复用,别指望每个视图都记得。
5.3 中文标题显示成方块或乱码
现象:图表里的轴标签、图例、tooltip 中文变成一个个方框,HTML 文本部分正常。
原因:ECharts 用 canvas 渲染,文字走的是系统字体,不走 CSS 的font-family继承。服务端环境(尤其是精简版容器镜像)没装中文字体,或者页面charset不是 UTF-8,都会出现这种情况。
解决:模板头部确认<meta charset="utf-8">;ECharts 全局配置里显式指定textStyle.fontFamily;部署环境装一套基础中文字体。
// 全局字体配置,放在所有图表初始化之前 echarts.registerTheme('report', { textStyle: { fontFamily: '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif' } }); const chart = echarts.init(document.getElementById('channel-chart'), 'report');5.4 折线图走势「过于漂亮」,和明细对不上
现象:业务方拿明细表逐日核对,发现图上某几天的值明显偏高,或者曲线比实际平滑。
原因:xAxis.data只有有数据的日期,缺口日期被跳过;或者开了smooth: true,两点之间插值出并不存在的峰值;再或者connectNulls: true把空值连起来了。
解决:接口层补全日期序列,缺失补 0,保证xAxis.data与series.data等长;smooth一律false;connectNulls保持false。补日期的逻辑要单测,跨月、跨年、闰月都跑一遍,别在边界上翻车。
5.5 侧边栏收起后图表被压扁,窗口缩放也不恢复
现象:点击侧边栏折叠按钮,内容区变宽了,但图表还是原来的宽度,右侧留一大片空白或者被裁掉。
原因:只监听了window.resize。侧边栏折叠不改变窗口尺寸,只改变容器尺寸,window的 resize 事件根本不会触发。ECharts 内部不会自动感知父容器变化。
解决:用ResizeObserver监听图表容器本身,比监听 window 精准得多。
// 监听容器尺寸变化,兼容侧边栏折叠、栅格重排 const ro = new ResizeObserver(() => chart.resize()); ro.observe(document.getElementById('channel-chart')); // 页面销毁时记得断开,避免 SPA 场景下内存泄漏 // window.addEventListener('beforeunload', () => ro.disconnect());这一条属于「不上线不知道」的典型。本地开发全屏浏览器,永远复现不出来,一进带侧边栏的后台布局就翻车。
6. 进阶:报表组件化与出数前的对账习惯
图表数量从三张涨到二十张之后,重复代码会迅速失控。我的做法是把「接口调用 + option 组装 + 实例管理」抽成一个createReport(el, {url, buildOption})的工厂函数,各页面只写buildOption,实例的resize、销毁、loading 状态统一在工厂里管。这样加一张新图只需要写一个buildOption和一个接口,不用再复制那几十行样板。
比组件化更重要的是对账。报表最怕的不是画不出来,是画错了还没人发现。我固定走三条线交叉验证:ORM 聚合结果、直接连库手写 SQL 的结果、页面导出的明细合计,三者必须一致。
# 对账脚本:ORM 聚合 vs 原生 SQL,差一分钱都要查 from django.db import connection from django.db.models import Sum from reports.models import Order def check_month(day): orm_total = (Order.objects .filter(is_paid=True, created_at__date=day) .aggregate(s=Sum("amount"))["s"]) or 0 with connection.cursor() as cur: # 绕开 ORM 手写一遍 cur.execute( "SELECT COALESCE(SUM(amount),0) FROM t_order " "WHERE is_paid = 1 AND DATE(created_at) = %s", [day] ) raw_total = cur.fetchone()[0] diff = float(orm_total) - float(raw_total) print(day, orm_total, raw_total, "OK" if abs(diff) < 0.01 else f"DIFF={diff}")aggregate返回的是Decimal,和float混算会有精度噪音,所以两边都转float再比,容差给 0.01。这个脚本挂到每日定时任务里,跑完把差异写日志,比等业务方来问强得多。
从那以后我每次交付报表,上线前都会强制走一遍「明细总数对聚合总数」的核对,哪怕只加了一个filter条件也不例外——那个条件漏了is_paid,整张图的数字就全错了,而且错得很合理,没人看得出来。希望帮到你。
本文还有配套的精品资源,点击获取