大厂真题 / 蚂蚁

蚂蚁 AI Coding 8.20 真题解析:征信对账系统账单解析引擎

考试信息

  • 考试日期:2026 年 8 月 20 日
  • 考试类型:AI Coding 工程题
  • 开发环境:云端 IDE + CodeFuse Agent
  • 作答时间:约 2 小时
  • 项目目录/home/exam

题目要求实现一套“征信对账系统——账单解析引擎”。项目目录中提供多家机构的 Excel 账单,候选人需要分析不同文件的结构,提取机构、产品和计费明细,最终输出结构化 JSON。

说明:当前留存截图只展示了 README 的前半部分,JSON 字段定义、完整验收命令和隐藏测试规则未出现在画面中。本文严格区分可确认题面与解题建议,不补造截图之外的固定字段。


一、题目要求

项目目标

征信公司内部对账系统需要对接多个数据源,数据源会定期以 Excel 文件形式发送账单。需要实现一套账单识别系统,从格式各异的账单文件中准确提取不同机构、不同产品的计费信息,并输出结构化 JSON。

背景约束

题面明确给出以下问题:

  • 数据源来自多家合作机构,各机构的账单格式不统一;
  • 一个账单文件可能包含多个 Sheet;
  • Sheet 中可能包含汇总行、小计行、备注等非明细信息;
  • 同一机构可能使用不同简称或全称;
  • 部分字段可能缺失或格式不规范。

输入

指定目录下的 Excel 账单文件,格式为 .xls.xlsx

截图中可见的样例文件包括:

机构A(CODE001)202603月账单.xlsx
机构B(CODE002)202603月账单.xlsx
机构C(CODE003)202603月账单.xlsx
机构D_12月_机构D账单.xlsx
机构E-2025年12月账单.xlsx
机构F产品-机构F25年12月账单.xlsx

输出

系统需要输出账单明细 JSON 字符串。截图未展示每个 JSON 元素的完整字段定义,实际作答时必须继续阅读 README,并以题面给出的字段名、类型和输出方式为准。


二、这道题真正考什么

这不是一道“会不会调用 pandas.read_excel”的题,而是一道小型异构数据接入工程题。

1. 需求发现能力

六个文件名已经暗示:机构编码、账期、产品名和机构名可能出现在不同位置,不能只依赖统一文件名正则。候选人需要先检查所有工作簿、Sheet 名、表头和样例值,再决定抽取规则。

2. 异构数据建模

不同机构可能使用不同字段名:

  • 产品名称产品业务类型
  • 调用量查询次数计费数量
  • 单价计费单价
  • 金额应付金额费用合计

正确做法是先映射到统一内部模型,而不是把原表逐行转成字典后直接输出。

3. 鲁棒性

隐藏测试很可能包含:

  • 表头不在第一行;
  • 多 Sheet,部分 Sheet 不是明细表;
  • 空行、合计行、备注行混入数据;
  • 金额带逗号、货币符号或中文单位;
  • 合并单元格导致机构名只在第一行出现;
  • 数量或单价为空,但金额存在;
  • 同一机构使用简称、全称或机构编码。

4. AI 协作能力

AI 可以快速生成读取、映射和测试代码,但候选人必须决定:

  • 哪些规则可以通用化;
  • 哪些机构需要专用适配器;
  • 如何证明输出正确;
  • 如何避免 AI 根据一个样例过拟合。

三、先做数据侦察,不要直接写解析器

拿到项目后,第一步应让 Agent 只做只读分析。

建议的侦察脚本

from pathlib import Path
import pandas as pd


def inspect_workbooks(root: str) -> None:
    for path in sorted(Path(root).glob("*.xls*")):
        print(f"\n=== {path.name} ===")
        book = pd.ExcelFile(path)
        print("sheets:", book.sheet_names)

        for sheet in book.sheet_names:
            raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
            print(f"\n-- {sheet}: shape={raw.shape} --")
            print(raw.head(12).to_string(index=False, header=False))


if __name__ == "__main__":
    inspect_workbooks(".")

这一步的目标不是产出最终结果,而是回答五个问题:

  1. 每个文件有哪些 Sheet?
  2. 真正表头位于第几行?
  3. 哪些列能映射到统一字段?
  4. 哪些行是明细,哪些行是汇总或说明?
  5. 机构、编码、账期和产品分别来自文件名、Sheet、表头还是数据行?

不要一开始就让 Agent “根据 README 完成全部代码”。AI 很容易只适配第一个文件,并在剩余文件上静默输出错误数据。


四、推荐架构:通用流水线 + 机构适配器

整体流程

发现 Excel 文件
  -> 读取工作簿与所有 Sheet
  -> 判断 Sheet 是否包含明细
  -> 定位表头
  -> 标准化列名
  -> 识别机构与账期
  -> 过滤空行、汇总行、备注行
  -> 字段清洗与类型转换
  -> 业务校验
  -> 输出统一 JSON
  -> 生成处理统计与错误信息

推荐目录

exam/
├── main.py                 # 程序入口
├── models.py               # 统一输出模型
├── parser.py               # 文件发现与解析调度
├── excel_utils.py          # 表头检测、单元格清洗
├── normalizers.py          # 名称、数字、日期归一化
├── adapters/
│   ├── base.py             # 适配器协议
│   ├── generic.py          # 通用适配器
│   └── institutions.py     # 必要时存放机构专用规则
├── tests/
│   └── test_parser.py
└── README.md

考试时间有限时,可以合并文件,但逻辑层次应保留。比起文件数量,评审更关注职责是否清晰、规则是否可测试。

为什么不建议纯硬编码

下面这种代码可能通过当前样例,却很容易挂在隐藏测试:

if "机构A" in filename:
    df = pd.read_excel(path, sheet_name="账单明细", skiprows=2)
elif "机构B" in filename:
    df = pd.read_excel(path, sheet_name="Sheet1", skiprows=5)

更稳妥的方式是:

  • 先用通用规则检测表头和字段;
  • 通用规则无法识别时,再由机构适配器补充;
  • 每个适配器只描述差异,不复制整套解析流程。

五、核心数据模型

实际字段必须以完整 README 为准。设计时可以先建立一个内部模型,再在输出层转换成题目要求的 JSON 键名。

from dataclasses import asdict, dataclass
from decimal import Decimal
from typing import Optional


@dataclass
class BillItem:
    institution_name: str
    institution_code: Optional[str]
    billing_period: Optional[str]
    product_name: str
    quantity: Optional[int]
    unit_price: Optional[Decimal]
    amount: Decimal
    source_file: str
    source_sheet: str
    source_row: int

    def to_dict(self) -> dict:
        data = asdict(self)
        data["unit_price"] = (
            str(self.unit_price) if self.unit_price is not None else None
        )
        data["amount"] = str(self.amount)
        return data

设计原则

  • 金额使用 Decimal,避免浮点误差;
  • 缺失字段使用 None,不要擅自填 0;
  • 保留 source_filesource_sheetsource_row,便于排错;
  • 最终序列化时再转换成 README 要求的字段与类型;
  • 不要把读取 Excel 后得到的 NaN 直接输出到 JSON。

六、关键实现一:表头自动定位

不同机构的表头不一定在第一行。可以读取前若干行,对每一行计算“字段别名命中数”。

HEADER_ALIASES = {
    "institution_name": {"机构", "机构名称", "合作机构", "数据源"},
    "institution_code": {"机构编码", "机构代码", "渠道编码"},
    "product_name": {"产品", "产品名称", "业务类型", "服务名称"},
    "quantity": {"数量", "调用量", "查询次数", "计费数量"},
    "unit_price": {"单价", "计费单价", "含税单价"},
    "amount": {"金额", "费用", "应付金额", "合计金额"},
}


def normalize_header(value: object) -> str:
    if value is None:
        return ""
    return "".join(str(value).strip().lower().split())


def find_header_row(raw, scan_rows: int = 20) -> int | None:
    aliases = {
        normalize_header(alias)
        for names in HEADER_ALIASES.values()
        for alias in names
    }
    best_row = None
    best_score = 0

    for row_index in range(min(scan_rows, len(raw))):
        values = {normalize_header(v) for v in raw.iloc[row_index].tolist()}
        score = len(values & aliases)
        if score > best_score:
            best_row = row_index
            best_score = score

    return best_row if best_score >= 2 else None

这里不要追求一个“万能阈值”。先用样例验证,再决定最低命中数。若某机构表头非常特殊,可在适配器中声明自己的别名。


七、关键实现二:列名与机构名称归一化

列名映射

def build_column_mapping(columns) -> dict[str, str]:
    mapping = {}
    for column in columns:
        normalized = normalize_header(column)
        for target, aliases in HEADER_ALIASES.items():
            normalized_aliases = {normalize_header(x) for x in aliases}
            if normalized in normalized_aliases:
                mapping[column] = target
                break
    return mapping

机构别名

INSTITUTION_ALIASES = {
    "机构A": {"机构A", "A机构", "机构A有限公司", "CODE001"},
    "机构B": {"机构B", "B机构", "机构B有限公司", "CODE002"},
}


def normalize_institution(value: str) -> str:
    compact = "".join(str(value).split())
    for canonical, aliases in INSTITUTION_ALIASES.items():
        if compact in {"".join(x.split()) for x in aliases}:
            return canonical
    return compact

机构别名表应该来自对样例文件的实际检查。不要看到文件名就臆造真实企业名称,也不要在无法确认时错误合并两个机构。


八、关键实现三:过滤非明细行

只按“金额非空”判断不够,因为合计行也常有金额。

SUMMARY_WORDS = {
    "合计", "总计", "小计", "本页合计", "费用合计", "备注", "说明"
}


def is_detail_row(row: dict) -> bool:
    text = " ".join(
        str(value).strip()
        for value in row.values()
        if value is not None
    )

    if not text:
        return False
    if any(word in text for word in SUMMARY_WORDS):
        return False

    product = row.get("product_name")
    amount = row.get("amount")
    quantity = row.get("quantity")

    # 至少出现产品,或出现可计费的数量/金额信息。
    return bool(product) and (amount is not None or quantity is not None)

更稳妥的策略是组合判断:

  • 关键文本是否出现“合计”“备注”;
  • 产品字段是否存在;
  • 数量、单价、金额能否转成合法数值;
  • 当前行是否与表头重复;
  • 是否存在大量空单元格或纯说明文字。

九、关键实现四:数字和金额清洗

import re
from decimal import Decimal, InvalidOperation


def parse_decimal(value) -> Decimal | None:
    if value is None:
        return None

    text = str(value).strip()
    if not text or text.lower() in {"nan", "none", "-", "--"}:
        return None

    multiplier = Decimal("1")
    if text.endswith("万"):
        multiplier = Decimal("10000")
        text = text[:-1]

    text = re.sub(r"[¥¥$,,\s]", "", text)
    text = text.replace("元", "")

    try:
        return Decimal(text) * multiplier
    except InvalidOperation:
        return None

金额校验

若数量、单价和金额同时存在,可以做容差校验:

from decimal import Decimal


def amount_is_consistent(quantity, unit_price, amount) -> bool:
    if quantity is None or unit_price is None or amount is None:
        return True
    expected = Decimal(quantity) * unit_price
    return abs(expected - amount) <= Decimal("0.01")

发现不一致时不要静默覆盖原金额。更好的处理是保留原值,并记录 warning 或失败原因。


十、解析主流程

from pathlib import Path
import json
import pandas as pd


def parse_sheet(path: Path, sheet_name: str) -> list[dict]:
    raw = pd.read_excel(
        path,
        sheet_name=sheet_name,
        header=None,
        dtype=object,
    )
    header_row = find_header_row(raw)
    if header_row is None:
        return []

    headers = raw.iloc[header_row].tolist()
    table = raw.iloc[header_row + 1:].copy()
    table.columns = headers
    table = table.where(pd.notna(table), None)

    mapping = build_column_mapping(table.columns)
    table = table.rename(columns=mapping)

    records = []
    for offset, series in table.iterrows():
        row = series.to_dict()
        if not is_detail_row(row):
            continue

        # 这里继续执行机构、产品、账期和金额归一化,
        # 再映射成 README 要求的最终字段。
        row["source_file"] = path.name
        row["source_sheet"] = sheet_name
        row["source_row"] = int(offset) + 1
        records.append(row)
    return records


def parse_directory(root: str) -> list[dict]:
    records = []
    for path in sorted(Path(root).glob("*.xls*")):
        book = pd.ExcelFile(path)
        for sheet_name in book.sheet_names:
            records.extend(parse_sheet(path, sheet_name))
    return records


def main() -> None:
    records = parse_directory(".")
    print(json.dumps(records, ensure_ascii=False, allow_nan=False))


if __name__ == "__main__":
    main()

这段代码是架构骨架,不是可以脱离完整 README 直接提交的最终答案。实际作答必须补上:

  • 题面指定的 JSON 字段;
  • 样例机构的真实别名与列名映射;
  • 账期提取规则;
  • 程序入口和参数格式;
  • 题面要求的异常行为;
  • 针对六个工作簿的回归测试。

十一、测试策略

1. 表头定位测试

  • 表头位于第 1、3、8 行;
  • 表头前存在标题、账期和空行;
  • Sheet 完全不包含明细表头。

2. 行过滤测试

  • 正常明细行;
  • 空行;
  • 合计、小计、备注;
  • 重复表头;
  • 产品名存在但金额为空;
  • 金额存在但产品名为空。

3. 数字清洗测试

from decimal import Decimal


def test_parse_decimal():
    assert parse_decimal("1,234.50") == Decimal("1234.50")
    assert parse_decimal("¥88.00元") == Decimal("88.00")
    assert parse_decimal("1.2万") == Decimal("12000.0")
    assert parse_decimal("-") is None

4. 多 Sheet 测试

  • 一个文件中只有一个明细 Sheet;
  • 多个 Sheet 都有明细;
  • 明细 Sheet 与“汇总”“说明”Sheet 混合;
  • 空 Sheet 不应让整个文件失败。

5. 端到端测试

对每个样例文件至少验证:

  • 解析结果不为空;
  • 所有输出元素包含 README 要求的键;
  • 输出是标准 JSON,不含 NaNInfinity
  • 汇总行未进入结果;
  • 机构和产品完成归一化;
  • 运行两次输出顺序一致;
  • 单个文件失败不会阻断其他文件。

十二、两小时作答节奏

0—15 分钟:读题与盘点样例

  • 通读 README,记录固定字段、输入路径、输出格式和验收命令;
  • 枚举所有工作簿和 Sheet;
  • 打印前 10—20 行,制作“机构—表头—字段—特殊行”清单。

15—35 分钟:确定模型与架构

  • 定义统一记录模型;
  • 确定表头定位、列名映射、行过滤和数值清洗策略;
  • 先实现通用解析器,不做界面和复杂抽象。

35—75 分钟:打通全部样例

  • 每完成一家机构就立即运行;
  • 将差异限制在配置或适配器中;
  • 确保所有文件都能输出,并人工抽查若干条。

75—100 分钟:补测试与异常处理

  • 测空值、合计行、多 Sheet、格式错误和 JSON 合法性;
  • 检查金额精度与确定性排序;
  • 检查单文件失败是否会拖垮整批任务。

100—115 分钟:按 README 验收

  • 运行题面给出的测试或启动命令;
  • 对照输出字段逐项检查;
  • 删除调试打印、硬编码绝对路径和临时文件。

115—120 分钟:交付

  • 补充简短使用说明;
  • 记录设计取舍与已知边界;
  • 最后再执行一次完整测试,然后提交任务。

十三、可直接用于 CodeFuse 的 Prompt

Prompt 1:先分析,不写代码

请先阅读当前目录的 README.md,并检查所有 .xls/.xlsx 文件。
现在不要修改文件,也不要直接生成最终实现。

请输出:
1. README 中明确要求的输入、输出字段、程序入口和验收方式;
2. 每个工作簿的 Sheet 名、表头位置、关键列和前 5 条有效明细;
3. 各机构账单格式的共同点与差异;
4. 汇总行、小计行、备注行和空行的识别规则;
5. 一份最小可行的实现计划,以及隐藏测试最可能覆盖的边界。

所有结论必须来自当前仓库文件;无法确认的内容请标记为未知,不要猜测。

Prompt 2:实现最小闭环

根据刚才的数据盘点,实现最小可运行版本:
- 扫描题目指定目录下的 .xls/.xlsx;
- 读取所有 Sheet;
- 自动定位表头并映射到 README 指定字段;
- 过滤空行、汇总行、小计行和备注行;
- 统一机构名、产品名、账期和数值格式;
- 输出严格合法的 JSON,禁止 NaN 和 Infinity;
- 单个文件或 Sheet 解析失败时,记录错误并继续处理其他输入。

优先复用通用规则。只有样例证明通用规则无法覆盖时,才增加机构适配器。
先完成核心解析和命令行入口,不做无关界面。
完成后运行程序,并汇报每个文件、每个 Sheet 的明细条数和失败原因。

Prompt 3:针对隐藏测试审查

请审查当前实现,不要重写整个项目。重点检查:
1. 表头不在第一行;
2. 一个文件包含多个 Sheet;
3. 合并单元格产生的空机构名;
4. 合计、备注、重复表头误入明细;
5. 金额中的逗号、货币符号、中文单位和空值;
6. Decimal 精度和 JSON 中的 NaN;
7. 文件名、Sheet 名、行顺序变化后的稳定性;
8. 单文件失败是否影响整个批次;
9. 是否存在仅适配当前六个文件的硬编码;
10. 最终字段、类型和入口是否与 README 完全一致。

先列出问题和最小修复方案,再逐项修改并补测试。每次修改后运行相关测试。

Prompt 4:最终验收

请按 README 的验收标准执行最终检查:
- 运行全部测试和题目要求的命令;
- 校验输出是严格 JSON;
- 检查每条记录的必填字段和类型;
- 汇总每个输入文件、Sheet 的有效记录数;
- 抽查数量 × 单价与金额的一致性;
- 检查是否残留调试输出、绝对路径、密钥或临时文件;
- 根据项目实际代码更新使用说明,不要描述不存在的功能。

如果失败,请先定位根因,只做最小修改,直到验收通过。

十四、常见失分点

  1. 只解析第一个 Sheet:题面明确说明一个账单可能包含多个 Sheet。
  2. 默认第一行是表头:标题、账期和说明经常位于表头之前。
  3. 把合计行当明细:金额非空不代表它是有效产品记录。
  4. 直接使用 float 处理金额:可能引入精度误差。
  5. 把缺失值全部填 0:这会混淆“真实为 0”和“无法识别”。
  6. 输出包含 NaN:Python 默认 json.dumps 可能产生非标准 JSON 值,应使用 allow_nan=False
  7. 完全依赖文件名识别机构:文件名规则可能变化,应结合机构编码、Sheet 和表内信息。
  8. 为六个文件复制六套代码:可维护性差,也容易在隐藏样例中失败。
  9. 没有保存来源位置:解析结果异常时无法回查原 Sheet 和行号。
  10. 过早让 Agent 一次性完成全部需求:缺少样例盘点和分步验收,最容易得到“看起来完整、实际不正确”的实现。

小结

这道题的稳定解法不是堆叠大量机构判断,而是建立一条清晰的数据接入流水线:

侦察样例 -> 定义统一模型 -> 自动识别表头 -> 映射字段
-> 过滤非明细 -> 清洗与校验 -> 严格 JSON 输出 -> 回归测试

AI Coding 考试中,Agent 可以帮你快速写代码,但“先观察什么、采用什么抽象、如何证明结果正确”仍然需要候选人主导。对于这道账单解析题,最重要的交付标准依次是:字段正确、样例全覆盖、异常不扩散、输出可验证、代码可扩展