背景
项目中存在一类周期性输出需求:按月对 IQC 报告进行汇总分析,并按供应商生成一份可分发、可归档的质量分析报告。报告内容不仅包含基础统计信息,还包括 SPC 图表、异常批次、不合格批次以及重点条目的趋势图,因此这项能力本质上是一个“定时调度 + 数据聚合 + 图表渲染 + 报告输出”的组合流程。
当前实现选择直接在后端服务内完成这条链路,原因很明确:
IQC 原始数据、SPC 配置、月报结构化结果都已经在当前服务和数据库中。
生成报告时需要直接复用现有的 SPC 计算逻辑和日志体系。
报告是典型的周期性后台任务,不需要用户实时触发,也不需要单独拆成独立服务。
从业务目标上看,这个能力最终要解决三件事:
每月定时触发一次报告生成。
按供应商产出可分发的质量报告文件。
将月报的结构化数据落库,便于后续查询、比对和复用。
技术选型
定时任务为什么选 APScheduler
当前场景是典型的应用内周期性后台任务:按固定时间触发、执行逻辑集中、依赖当前服务的数据库连接、配置和日志体系。在这种前提下,APScheduler 是一个比较贴合现状的选择,尤其适合基于异步 Web 服务直接内嵌调度能力。
选择它的主要原因有:
它可以直接嵌入应用生命周期,在服务启动和关闭时统一管理,接入成本低。
支持异步任务调度,适合当前后端服务的异步执行模型。
对当前“固定周期执行一次月报生成”这种场景来说,Cron 表达能力已经足够。
不需要额外引入消息队列、worker 服务或独立部署单元,维护成本更低。
横向对比其他常见方案:
综合来看,当前任务的触发频率低、执行逻辑集中、依赖当前应用上下文,因此优先选 APScheduler 是合理的。
报告文档生成为什么选 docxtpl
质量报告本身需要一个适合分发和归档的文档载体,而当前实现选择了 Word 作为报告输出形式。在这个前提下,docxtpl 很适合“固定模板 + 动态数据填充 + 图片嵌入”的文档场景。
选择它的主要原因有:
可以直接基于现成
.docx模板文件渲染变量,降低版式实现复杂度。支持
InlineImage,便于把 SPC 图表图片插入文档模板。模板更接近业务文档本身,后续如果报告版式调整,维护成本比纯代码拼装更低。
对于“当前采用 Word 作为交付载体”的实现方式来说,输出链路完整且成熟。
横向对比其他常见方案:
因此,从模板可维护性、图片嵌入能力和工程成本来看,docxtpl 是当前这套 Word 输出实现下更平衡的选择。但如果后续报告载体改为 PDF 或网页化报表,技术方案也可以随之调整。
主要实现流程
1. 应用启动时注册调度器
应用在 FastAPI 的 lifespan 中调用 setup_scheduler() 初始化调度器,并在启动阶段执行 scheduler.start(),关闭时再执行 scheduler.shutdown()。这样可以保证定时任务和应用生命周期一致,避免额外的进程管理复杂度,代码位置见 app.py。
2. 定时任务入口触发月报生成
report_generate_job.py 中定义了 generate_monthly_report(),它是调度器真正执行的任务入口。这个函数会先记录日志,再调用 generate_word() 执行实际的报告生成逻辑。
当前代码里使用的是 CronTrigger(day="last", hour=22, minute=0),表示任务会在每月最后一天 22:00 触发一次。这种配置和“月度质量报告”的业务节奏是一致的,也能避免过于频繁地重复生成报告。
3. 查询 IQC 报表并按供应商分组
statistic_service.py 中的 generate_word() 是整条流程的核心。它会:
以当前时间作为本次月报的统计基准时间。
查询全部
IQCReport数据。按
supplier做分组。为每个供应商分别生成一份独立的质量报告文件。
这意味着当前月报的输出粒度是“每个供应商一个文件”,而不是全量只输出一个总报告。
4. 提取基础统计信息
get_base_info_list() 负责提取报告基础数据,包括:
报告名称
料号编码
当月批次数
这里会先批量查询当前供应商下所有自检来源 entry,再关联 IQCReportEntryValue 统计本月检测记录,并通过 build_identifier() 对检测日期和批次号做归一化,最终得到每份报告的批次数。
5. 计算当月 SPC 结果并提取异常信息
get_abnormal_info_list() 会针对当前供应商下的所有自检条目:
查询关联的
IQCReportSpcData构造
SpcChartRequest调用
draw_spc_chart()生成 CPK、X-Bar-R 图表结果将结果挂回
entry.cpk_result和entry.x_bar_r_result进一步通过
handle_x_bar_r_result()从 X 图和 R 图的 violation 信息中提取异常批次及规则说明
完成后,再由 extract_abnormal_and_nonconformity() 汇总成两类数据:
异常批次列表
不合格批次列表
这些内容既用于当前报告展示,也用于后续月度统计指标计算。
6. 将重点图表和异常图表转成文档可插入图片
报告中有两类重点图表:
星标条目的 SPC 图表
异常条目的 SPC 图表和异常明细
对应代码分别在:
get_starred_spc_charts()get_abnormal_summary()
这两个函数都会读取条目上的 CPK、X-Bar、R 图数据,然后调用 chart_data_to_image() 导出 PNG 字节,再通过 InlineImage(doc, io.BytesIO(...), width=Mm(180)) 生成可嵌入 Word 的图片对象。
这一层的关键点是,模板上下文中放的不是原始图表数据,而是已经构造好的图片对象。
7. 计算月度统计指标
get_abnormal_stats() 会基于基础信息、异常列表和不合格列表计算:
总批次数
异常批次数
不合格批次数
异常率
合格率
同时它还会查询上个月的 IQCMonthlyReport 数据,对比得到本月相较上月的变化幅度,并格式化为报告中可直接展示的字符串。
8. 组装模板上下文并渲染报告文件
generate_word() 最终会把下面这些数据组装成模板上下文 context:
供应商信息
报告时间和周期
基础信息列表
异常/不合格信息列表
星标条目图表
异常条目图表
月度统计指标
随后通过:
generate_doc()读取resource/template/word_template.docxsave_word()执行doc.render(context)和doc.save(output_path)
最终文件保存到 resource/temp_file 目录。
9. 月报结构化数据落库
除了生成当前的报告文件,流程还会调用 save_monthly_report() 将本次月报数据写入 iqc_monthly_report 表,模型定义见 models.py。
这里有一个很重要的处理:在落库前会移除 cpk_image、x_image、r_image 等 InlineImage 对象。原因是这些对象用于文档渲染,但不适合 JSON 序列化存储;同时直接对上下文做深拷贝还可能引发 DocxTemplate 相关的递归问题。
因此,系统最终形成了两份产物:
文件产物:当前实现下供应商维度的
.docx月报数据产物:
iqc_monthly_report.data中的结构化月报数据
效果预览
word模板

生成报告预览


技术参考
源码
调度器初始化与任务注册
# src/job/report_generate_job.py
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from src.iqc.statistic_service import generate_word
async def generate_monthly_report():
await generate_word()
def setup_scheduler():
scheduler = AsyncIOScheduler()
trigger = CronTrigger(day="last", hour=22, minute=0)
scheduler.add_job(
generate_monthly_report,
trigger=trigger,
id="monthly_report_job",
name="每月输出质量报告",
replace_existing=True,
)
return scheduler
# src/app.py
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = setup_scheduler()
scheduler.start()
app.state.scheduler = scheduler
yield
if hasattr(app.state, "scheduler"):
app.state.scheduler.shutdown()
月报生成主流程
# src/iqc/statistic_service.py
async def generate_word() -> None:
base_time = datetime.now()
month_str = base_time.strftime("%Y年%m月")
period_end = get_month_end_date(base_time)
async with async_session_maker() as db:
result = await db.execute(sa.select(IQCReport))
reports = result.scalars().all()
if not reports:
logger.info("数据库中暂无 IQC 报表数据")
return
suppliers = {report.supplier for report in reports if report.supplier}
for supplier in suppliers:
supplier_reports = [r for r in reports if r.supplier == supplier]
file_name = f"{supplier}-{month_str}-质量分析报告.docx"
supplier_doc = await generate_doc()
base_info_list = await get_base_info_list(supplier_reports, db, base_time)
abnormal_list, nonconformity_list = await get_abnormal_info_list(
supplier_reports, db, base_time
)
starred_entry_list = get_starred_spc_charts(supplier_reports, supplier_doc)
stats = await get_abnormal_stats(
db, supplier, base_info_list, abnormal_list, nonconformity_list, base_time
)
abnormal_entry_list = get_abnormal_summary(supplier_reports, supplier_doc)
context = {
"supplier": supplier,
"datetime": base_time.strftime("%Y年%m月%d日 %H:%M:%S"),
"period": month_str,
"baseInfoList": base_info_list,
"abnormalInfoList": abnormal_list,
"nonconformityInfoList": nonconformity_list,
"starredEntries": starred_entry_list,
"abnormalEntries": abnormal_entry_list,
**stats,
}
if supplier_doc:
await save_word(file_name, supplier_doc, context)
await save_monthly_report(db, supplier, period_end, context)
Word 模板渲染与保存
# src/iqc/statistic_service.py
template_path = Path("resource") / "template" / "word_template.docx"
output_dir = Path("resource") / "temp_file"
async def generate_doc() -> DocxTemplate | None:
if not template_path.exists():
logger.error(f"错误: 找不到模板文件 {template_path.absolute()}")
return None
try:
doc = DocxTemplate(template_path)
return doc
except Exception as e:
logger.error(f"渲染文档失败: {e}")
return None
async def save_word(file_name: str, doc: DocxTemplate, context: dict):
doc.render(context)
output_path = output_dir / file_name
output_dir.mkdir(parents=True, exist_ok=True)
doc.save(output_path)
图表转图片并嵌入模板
# src/utils/draw_util.py
def chart_data_to_image(chart_data: Figure | Mapping[str, Any]) -> bytes:
if chart_data is None:
raise ValueError("chart_data cannot be None")
if isinstance(chart_data, Figure):
fig = chart_data
else:
if not chart_data:
raise ValueError("chart_data cannot be empty")
fig = Figure(chart_data)
if not fig.data:
raise ValueError("chart_data does not contain any trace data")
return fig.to_image(format="png", width=800, height=500, scale=2)
# src/iqc/statistic_service.py
def get_starred_spc_charts(report_list: list[IQCReport], doc: DocxTemplate) -> list[dict[str, Any]]:
starred_entry_list = []
for report in report_list:
entries = getattr(report, "self_entries", [])
for entry in entries:
if entry.starred:
chart_info = {
"code": report.code,
"name": entry.name,
"cpk_result": entry.cpk_result,
"x_bar_r_result": entry.x_bar_r_result,
}
if chart_info.get("cpk_result"):
cpk_image = chart_data_to_image(chart_info["cpk_result"]["fig_result"]["capability_fig"])
chart_info["cpk_image"] = InlineImage(doc, io.BytesIO(cpk_image), width=Mm(180))
starred_entry_list.append(chart_info)
return starred_entry_list
月报结构化数据入库
# src/iqc/models.py
class IQCMonthlyReport(Base, TimestampTableMixin):
__tablename__ = "iqc_monthly_report"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
supplier: Mapped[str] = mapped_column(String(100), comment="供应商")
period: Mapped[date] = mapped_column(Date, comment="报告周期")
data: Mapped[dict] = mapped_column(JSON, comment="报告数据内容(不含图片)")
# src/iqc/statistic_service.py
async def save_monthly_report(db: AsyncSession, supplier: str, period_end: date, context: dict[str, Any]) -> None:
image_fields = ["cpk_image", "x_image", "r_image"]
for entry in context.get("starredEntries", []) + context.get("abnormalEntries", []):
for field in image_fields:
entry.pop(field, None)
stmt = sa.select(IQCMonthlyReport).where(
sa.and_(
IQCMonthlyReport.supplier == supplier,
IQCMonthlyReport.period == period_end,
)
)
result = await db.execute(stmt)
existing_report = result.scalars().first()
if existing_report:
existing_report.data = context
else:
report = IQCMonthlyReport(supplier=supplier, period=period_end, data=context)
db.add(report)
await db.commit()