1. 痛点:列名不统一,硬编码列号必崩

货代/物流场景里,Gmail 附件中的派送表 没有统一模板

  • 表头可能在第 3 行,不在第 1 行
  • 同一含义多种写法:FBA ID / fba / Reference PO
  • 既有 .xlsx 也有 .csv,分隔符可能是 Tab

如果写 row[2] 取 FBA、row[5] 取箱数——换一家客户模板就全线崩溃。

正确做法是:动态识别表头行 + 别名映射到标准字段 + 行级 warning


2. 解析在链路中的位置

Gmail 下载 Buffer
  → parseDeliveryFileBuffer(buffer, filename, containerNo)
  → parseDeliveryMatrix(rows)
  → DeliveryParseResult { items, summaries, warnings }
  → Prisma 写 delivery_items + warehouse_summaries

统一入口(推荐):

parseDeliveryFileBuffer(
  buffer: Buffer,
  filename?: string,
  defaultContainerNo?: string
): DeliveryParseResult

分支逻辑:

filename 以 .csv 结尾 → parseCsvBufferToMatrix → parseDeliveryMatrix
否则                  → ExcelJS 读 xlsx      → parseDeliveryMatrix

注意:项目里还有「订单大表导入」走 order-sheet-import.ts,入库 cargo_sheet,与本文 派送明细表 不是同一条链路,不要混用。


3. 五步流水线

① 读成二维矩阵 string[][]
② detectHeaderRow     — 前 30 行找表头
③ buildColumnMap      — 表头 → 标准字段名
④ parseDeliveryMatrix — 逐行解析 + warning
⑤ aggregateWarehouseSummaries — 按仓库汇总箱数

返回值结构:

interface DeliveryParseResult {
  headerRow: number;           // 1-based,给人看
  items: DeliveryItemParsed[];
  summaries: WarehouseSummaryComputed[];
  warnings: string[];          // 如「第 5 行:箱数为空」
}

4. 读矩阵:xlsx 与 csv

4.1 xlsx(ExcelJS)

const workbook = await loadWorkbook(buffer);
const sheet = workbook.worksheets[0];  // 只读第一个工作表
const rows = getWorksheetRows(sheet, 30); // 每行最多 30 列,去尾空

4.2 csv

// 优先 UTF-8,乱码 fallback latin1
// 去 BOM \uFEFF
// 分隔符:Tab 数量 > 逗号 → 用 Tab,否则逗号
// 支持引号包裹、" "" 转义

同一套 parseDeliveryMatrix 处理两种来源,避免维护两套业务规则。


5. 表头识别 detectHeaderRow

在前 30 行扫描,某行 normalized 后与关键词表命中 ≥2 个 即视为表头:

const HEADER_KEYWORDS = [
  "fba", "reference", "warehouse", "仓库", "箱数", "carton", "柜号",
  "重量", "weight", "cbm", "体积", "派送", "delivery", "备注", "note",
];

function normalizeHeader(cell: string) {
  return cell.replace(/\s+/g, "").toLowerCase();
}

function detectHeaderRow(rows: string[][]): number {
  for (let i = 0; i < Math.min(rows.length, 30); i++) {
    const hits = rows[i]
      .map(normalizeHeader)
      .filter((cell) =>
        HEADER_KEYWORDS.some((kw) => cell.includes(normalizeHeader(kw)))
      ).length;
    if (hits >= 2) return i;
  }
  return 0; // 兜底:第 0 行
}

跳过汇总行:

function isSummaryRow(values: string[]) {
  const joined = values.join(" ").toLowerCase();
  return /合计|总计|total/.test(joined);
}

6. 列映射 FIELD_ALIASES

表头 normalized 后查别名表,映射到标准 key:

表头别名(部分) 标准字段 入库列
柜号 / containerno container_no container_no
FBA / FBA ID / fbaid fba_id fba_id
Reference / PO reference_id reference_id
箱数 / cartons carton_count carton_count
仓库 / 仓库代码 warehouse_code warehouse_code
CBM / 体积 cbm cbm
Weight / 重量 weight weight
客人备注 / 客户备注 customer_note customer_note
打板数量 pallet_count pallet_count
const FIELD_ALIASES: Record<string, keyof DeliveryItemParsed> = {
  fbaid: "fba_id",
  fba: "fba_id",
  "fbaid": "fba_id",
  warehouse: "warehouse_code",
  仓库: "warehouse_code",
  cartons: "carton_count",
  箱数: "carton_count",
  // ... 完整列表见 delivery-excel-parser.ts
};

function buildColumnMap(headerRow: string[]) {
  const map: Record<number, keyof DeliveryItemParsed> = {};
  headerRow.forEach((cell, colIndex) => {
    const key = FIELD_ALIASES[normalizeHeader(cell)];
    if (key) map[colIndex] = key;
  });
  return map;
}

未出现在映射表的列直接忽略——比强行解析未知列更安全。


7. 数据行规则

表头下一行 到矩阵末尾:

规则 行为
整行空 跳过
汇总行 跳过
无柜号 defaultContainerNo;仍无则跳过
FBA、Reference、仓库、箱数全空 跳过(无效行)
仓库为空 仍入库 + warning「仓库代码为空」
箱数为空 仍入库 + warning「箱数为空」

数值列走 cellToNumber(去千分位逗号);柜号列 toUpperCase()

if (!row.warehouse_code) {
  row.warnings.push("仓库代码为空");
  warnings.push(`${i + 1} 行:仓库代码为空`);
}

设计哲学: 尽量入库可挽救的数据,用 warnings 标记可疑行,而不是整表 reject。


8. 仓库汇总 aggregateWarehouseSummaries

warehouse_code(大写)分组:

{
  warehouse_code: "ONT8",
  total_cartons: 120,      // sum(carton_count),null 当 0
  item_count: 15,
}

写入 warehouse_summaries,与 delivery_items 共用同一 batch_no,便于按「一次检索」对账。


9. 与 Gmail 链路的衔接

调用方 文件 说明
订单「检索」 order-parse-service.ts 线 A,支持 csv + xlsx
按柜号解析 container-parse-service.ts 线 B,建议统一走 File 版入口
// order-parse-service.ts(示意)
const buffer = await downloadAttachmentBuffer(messageId, attachmentId, token);
const parsed = parseDeliveryFileBuffer(buffer, filename, containerNo);
await persistDeliveryItems(parsed.items, batchNo);
await persistWarehouseSummaries(parsed.summaries, batchNo);
await writeParseLogs(parsed.warnings);

10. 测试建议

  1. 表头在第 1 / 第 5 行 各准备一份样例
  2. 列名 alias 覆盖:FBA ID vs fba
  3. csv Tab 分隔 vs 逗号分隔
  4. 含合计行 的表,确认不被当数据行
  5. 缺仓库/缺箱数 行,确认有 warning 且仍入库

单元测试可对 detectHeaderRowbuildColumnMap 纯函数直接断言,无需 mock Gmail。


11. 小结

派送表解析的三板斧:

  1. 动态表头(关键词命中 ≥2,前 30 行扫描)
  2. FIELD_ALIASES(列名归一化,忽略未知列)
  3. warning 不阻断(可疑行标记,汇总仍可用)

配合上一篇 Gmail 检索,你就有了「搜邮件 → 下附件 → 解析入库」的核心闭环。后续可继续看 ETL 事务边界、RBAC、可编辑大表等篇章。


关于作者

devcfg 以已知,溯本源,探未知 · Next.js · Excel 解析 · 工程实战
专注全栈业务自动化,同系列文章收录于本人 CSDN 专栏。

本文为「Next.js Gmail 自动处理系统」第 2/8 篇。正文与代码可在 CSDN 独立阅读

本系列目录(专栏内按序阅读)

  1. Gmail API 按柜号检索邮件:OAuth 双 Cookie 与搜索 Fallback
  2. Excel 派送表动态解析(本篇)
  3. JWT 双 Token 认证与 RBAC 权限控制
  4. 文档解析 ETL 流水线与 Prisma 事务边界
  5. Next.js 可编辑大表与历史快照
  6. CRUD 列表模板(Zod + Mapper)
  7. 系统架构与 Prisma 数据模型
  8. Vercel 部署与派送表导出

欢迎在评论区交流 ExcelJS、CSV 分隔符或表头识别算法。觉得有帮助请 点赞 / 收藏 / 关注

下一篇预告:《JWT 双 Token 认证与 RBAC 权限控制》(专栏续更)

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐