# Excel 数据导入实战参考 > **适用场景**: MVP/里程碑完成后,从业务 Excel 报表批量导入测试数据做效果验证 > **创建日期**: 2026-06-24 > **更新日期**: 2026-06-30 --- ## 两种导入方式 ### 方式一:CLI 脚本(适合首次全量导入) `scripts/import_excel_data.py` — 从文件系统读取 Excel,一次性导入全部数据。 **优点**: 无网络开销,可调试,适合大数据量 **缺点**: 需要服务器文件系统访问权限 ### 方式二:Web API(适合日常增量导入) `POST /api/import/excel` — 前端上传 Excel 文件,后端解析导入。 **优点**: 浏览器操作,无需服务器登录,有导入结果统计 **缺点**: 文件大小受 HTTP 限制,大文件上传慢 **前端页面**: `/import` 路径,拖拽上传 + 导入结果展示 **API 端点**: ```python @router.post("/api/import/excel") async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)): # 保存临时文件 → openpyxl 解析 → 导入各表 → 删除临时文件 ``` --- ## 核心挑战 业务 Excel 通常有 15-20 个 sheet,列布局因项目而异,且存在公式、合并单元格、同名项目等陷阱。一次性全量导入比逐条录入高效百倍,但需要处理以下问题。 --- ## 实战陷阱与对策 ### 1. 列布局因 sheet 而异 每个项目明细 sheet 的列数不同: | Sheet | 列布局 | 人日列 | 描述列 | |-------|--------|:------:|:------:| | 标准 | 序号\|模块\|功能项\|人日\|负责人\|占比\|... | col 3 | — | | 工健健康小屋 | 序号\|模块\|功能项\|说明1\|详细说明\|人日\|负责人\|... | col 5 | col 4 | | 基层社区AI医助 | 序号\|模块\|功能项\|描述\|人日\|负责人\|... | col 4 | col 3 | | 汤原县AI医助 | 序号\|功能项\|描述\|人日\|负责人\|... | col 3 | col 2(无模块列) | **对策**: 为每个 sheet 定义独立的列映射配置,而非通用解析逻辑。 ```python sheet_configs = [ ("项目-百度-珠江医院", "2026-BD-1", 1, 2, 3, 4, None, [...]), ("项目-工健健康小屋", "2026-YZ-1", 1, 2, 5, 6, 4, [...]), ("项目-汤原县AI医助", "2026-AI-2", None, 1, 3, 4, 2, [...]), ] ``` ### 2. 同名项目(不同项目编号) Excel 中可能有多个同名项目(如"南方医科大学珠江医院"有 `20260205-A` 和 `2026-BD-1` 两个编号)。用 `project_name` 做 dict key 会覆盖。 **对策**: 始终用 `project_code` 做映射,不要用 `project_name`。 ```python projects_by_code = {p.project_code: {"name": p.project_name, "id": p.id} for p in db.query(Project).all()} ``` ### 3. 人员不在人员概况 sheet 中 新 Excel 可能新增了人员(如"陈天然"),但只出现在收益分析 sheet 中,不在人员概况 sheet 里。 **对策**: 扫描所有 sheet 发现新人员,手动补加到人员表。 ### 4. 公式字段 openpyxl 读不到 Excel 中的 `=SUM(...)`、`=C2/D2` 等公式,openpyxl 读取时返回公式字符串而非计算结果。 **对策**: - 预期收益从收益分析 sheet 的"总计"行读取(那里是数值) - 投产比等计算字段在数据库端用 ROI 计算器重新计算 ### 5. 自由文本 → 结构化关联 "人员项目负荷情况" sheet 的工作描述是自由文本(如"1、百度医院智能体-南方医科大学珠江医院-近一个月工作占比50%"),需要从中提取项目关联和分配比例。 **对策**: 两阶段匹配: 1. 精确匹配:项目全名在文本中 2. 关键词回退:`"健康小屋" → "工会健康小屋"`, `"AI医助" → "佳木斯中医院AI医助"` 分配比例从文本中 `占比(\d+)%` 正则提取,无比例时按项目关联人数均分。 ### 6. 增量更新 vs 全量重来 **原则**: 首次导入用全量清空重来;后续更新用增量 upsert(按 project_code/personnel_id 匹配)。 **增量 upsert 模式**: ```python existing = db.query(Model).filter(Model.code == code).first() if existing: for key, val in new_data.items(): setattr(existing, key, val) else: db.add(Model(**new_data)) ``` --- ### 7. openpyxl 样式解析 bug(TypeError: expected Fill) openpyxl 3.1.5 在解析某些 Excel 文件的 `styles.xml` 时,遇到不兼容的 Fill 样式对象会抛出 `TypeError: expected `。 **无效尝试**: - `data_only=True` — 只影响公式计算,不跳过样式解析 - `read_only=True` — 同样需要解析样式表 - 升级 openpyxl — 3.1.5 是当前最新版,bug 尚未修复 **根治方案**:改用 **python-calamine**(Rust 实现的 Excel 解析器),它完全不解析样式,只读数据。 ```python from python_calamine import CalamineWorkbook wb = CalamineWorkbook.from_path(path) sheets = wb.sheet_names ws = wb.get_sheet_by_name("Sheet1") rows = list(ws.to_python()) # 返回 list[list],每个 cell 是 Python 原生类型 ``` **注意事项**: - `python-calamine` 返回的单元格值是 Python 原生类型(float/int/str/None),无需额外转换 - 不解析公式,返回的是缓存的计算结果(与 `data_only=True` 类似) - 不支持写 Excel,只用于读取 - 安装:`pip install python-calamine` 或加到 requirements.txt **⚠️ employee_id 空值陷阱**: `python-calamine` 返回的空单元格是 `None`,但 Excel 中写了空字符串的单元格也会被 `str(val).strip()` 转为空字符串。用 `clean()` 函数处理时,空字符串会变成 `None`,导致 NOT NULL 约束失败。 ```python # ❌ 错误:clean(row[2]) 对空单元格返回 None emp_id = clean(row[2]) if len(row) > 2 else f"EMP{name}" # ✅ 正确:显式检查 clean() 结果是否为空 emp_id = clean(row[2]) if len(row) > 2 and clean(row[2]) else f"EMP{name}" ``` **规律**:任何 `clean()` 的返回值都可能为 `None`,不能因为列存在就假定值非空。在 NOT NULL 字段上使用 `clean()` 时,必须加 `and clean(val)` 二次检查。 **Web API 中的完整模式**: ```python tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") try: content = await file.read() tmp.write(content) tmp.close() wb = CalamineWorkbook.from_path(tmp.name) # ... 解析各 sheet ... finally: os.unlink(tmp.name) ``` --- ## 导入后验证清单 - [ ] 项目数是否匹配 Excel 项目概况行数 - [ ] 人员数是否覆盖所有出现的人名 - [ ] 预期收益是否与 Excel 收益分析 sheet 的"总计"行一致 - [ ] WBS 任务数是否合理(每个项目应有 >0 条) - [ ] 人员-项目关联数是否覆盖主要参与关系 - [ ] 收益矩阵 API 返回的数据与 Excel 收益分析 sheet 交叉验证 - [ ] 仪表盘 OKR 完成率是否与 Excel 部门概况一致