背景

项目中存在一类周期性输出需求:按月对 IQC 报告进行汇总分析,并按供应商生成一份可分发、可归档的质量分析报告。报告内容不仅包含基础统计信息,还包括 SPC 图表、异常批次、不合格批次以及重点条目的趋势图,因此这项能力本质上是一个“定时调度 + 数据聚合 + 图表渲染 + 报告输出”的组合流程。

当前实现选择直接在后端服务内完成这条链路,原因很明确:

  • IQC 原始数据、SPC 配置、月报结构化结果都已经在当前服务和数据库中。

  • 生成报告时需要直接复用现有的 SPC 计算逻辑和日志体系。

  • 报告是典型的周期性后台任务,不需要用户实时触发,也不需要单独拆成独立服务。

从业务目标上看,这个能力最终要解决三件事:

  • 每月定时触发一次报告生成。

  • 按供应商产出可分发的质量报告文件。

  • 将月报的结构化数据落库,便于后续查询、比对和复用。

技术选型

定时任务为什么选 APScheduler

当前场景是典型的应用内周期性后台任务:按固定时间触发、执行逻辑集中、依赖当前服务的数据库连接、配置和日志体系。在这种前提下,APScheduler 是一个比较贴合现状的选择,尤其适合基于异步 Web 服务直接内嵌调度能力。

选择它的主要原因有:

  • 它可以直接嵌入应用生命周期,在服务启动和关闭时统一管理,接入成本低。

  • 支持异步任务调度,适合当前后端服务的异步执行模型。

  • 对当前“固定周期执行一次月报生成”这种场景来说,Cron 表达能力已经足够。

  • 不需要额外引入消息队列、worker 服务或独立部署单元,维护成本更低。

横向对比其他常见方案:

方案

优点

局限

适配当前场景结论

APScheduler

轻量、可嵌入 FastAPI、支持异步调度、开发成本低

更适合单体应用内任务,分布式调度能力相对有限

最适合当前月报定时生成场景

Celery + Beat

适合分布式任务、失败重试、任务队列解耦、可横向扩展

需要引入 Redis/RabbitMQ 和 worker,系统复杂度、部署和运维成本明显上升

对当前单一月报任务偏重

系统 cron / Windows 计划任务

独立于应用进程、系统层实现简单

任务和应用逻辑分离,不方便直接复用应用内配置、日志、数据库上下文,部署一致性较差

能做,但工程体验不如内嵌调度

综合来看,当前任务的触发频率低、执行逻辑集中、依赖当前应用上下文,因此优先选 APScheduler 是合理的。

报告文档生成为什么选 docxtpl

质量报告本身需要一个适合分发和归档的文档载体,而当前实现选择了 Word 作为报告输出形式。在这个前提下,docxtpl 很适合“固定模板 + 动态数据填充 + 图片嵌入”的文档场景。

选择它的主要原因有:

  • 可以直接基于现成 .docx 模板文件渲染变量,降低版式实现复杂度。

  • 支持 InlineImage,便于把 SPC 图表图片插入文档模板。

  • 模板更接近业务文档本身,后续如果报告版式调整,维护成本比纯代码拼装更低。

  • 对于“当前采用 Word 作为交付载体”的实现方式来说,输出链路完整且成熟。

横向对比其他常见方案:

方案

优点

局限

适配当前场景结论

docxtpl

模板驱动、适合固定版式、支持变量渲染和图片插入、输出 docx

复杂布局仍受 Word 模板能力约束

在当前采用 Word 作为报告载体的前提下最合适

python-docx

对 Word 对象模型控制更细,可以纯代码构建文档

代码量大,版式维护困难,不利于模板协作

适合高度程序化文档,不如 docxtpl 高效

HTML 转 PDF

展示能力强,样式灵活,适合网页化报表

更偏向固定版式导出,后续编辑能力弱

如果未来要统一走网页报表或 PDF,可再评估

reportlab 等 PDF 方案

适合精确生成 PDF 文档

主要面向 PDF,模板协作成本较高

适合以 PDF 为最终交付物的场景

因此,从模板可维护性、图片嵌入能力和工程成本来看,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_resultentry.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.docx

  • save_word() 执行 doc.render(context)doc.save(output_path)

最终文件保存到 resource/temp_file 目录。

9. 月报结构化数据落库

除了生成当前的报告文件,流程还会调用 save_monthly_report() 将本次月报数据写入 iqc_monthly_report 表,模型定义见 models.py。

这里有一个很重要的处理:在落库前会移除 cpk_imagex_imager_imageInlineImage 对象。原因是这些对象用于文档渲染,但不适合 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()