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
统一入口(推荐):
typescript
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 --- 按仓库汇总箱数
返回值结构:
typescript
interface DeliveryParseResult {
headerRow: number; // 1-based,给人看
items: DeliveryItemParsed[];
summaries: WarehouseSummaryComputed[];
warnings: string[]; // 如「第 5 行:箱数为空」
}
4. 读矩阵:xlsx 与 csv
4.1 xlsx(ExcelJS)
typescript
const workbook = await loadWorkbook(buffer);
const sheet = workbook.worksheets[0]; // 只读第一个工作表
const rows = getWorksheetRows(sheet, 30); // 每行最多 30 列,去尾空
4.2 csv
typescript
// 优先 UTF-8,乱码 fallback latin1
// 去 BOM \uFEFF
// 分隔符:Tab 数量 > 逗号 → 用 Tab,否则逗号
// 支持引号包裹、" "" 转义
同一套 parseDeliveryMatrix 处理两种来源,避免维护两套业务规则。
5. 表头识别 detectHeaderRow
在前 30 行 扫描,某行 normalized 后与关键词表命中 ≥2 个 即视为表头:
typescript
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 行
}
跳过汇总行:
typescript
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 |
typescript
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()。
typescript
if (!row.warehouse_code) {
row.warnings.push("仓库代码为空");
warnings.push(`第 ${i + 1} 行:仓库代码为空`);
}
设计哲学: 尽量入库可挽救的数据,用 warnings 标记可疑行,而不是整表 reject。
8. 仓库汇总 aggregateWarehouseSummaries
按 warehouse_code(大写)分组:
typescript
{
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 版入口 |
typescript
// 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 / 第 5 行 各准备一份样例
- 列名 alias 覆盖:
FBA IDvsfba - csv Tab 分隔 vs 逗号分隔
- 含合计行 的表,确认不被当数据行
- 缺仓库/缺箱数 行,确认有 warning 且仍入库
单元测试可对 detectHeaderRow、buildColumnMap 纯函数直接断言,无需 mock Gmail。
11. 小结
派送表解析的三板斧:
- 动态表头(关键词命中 ≥2,前 30 行扫描)
- FIELD_ALIASES(列名归一化,忽略未知列)
- warning 不阻断(可疑行标记,汇总仍可用)
配合上一篇 Gmail 检索,你就有了「搜邮件 → 下附件 → 解析入库」的核心闭环。后续可继续看 ETL 事务边界、RBAC、可编辑大表等篇章。
关于作者
devcfg 以已知,溯本源,探未知 · Next.js · Excel 解析 · 工程实战
专注全栈业务自动化,同系列文章收录于本人 CSDN 专栏。
本文为「Next.js Gmail 自动处理系统」第 2/8 篇 。正文与代码可在 CSDN 独立阅读。
本系列目录(专栏内按序阅读)
- Gmail API 按柜号检索邮件:OAuth 双 Cookie 与搜索 Fallback
- Excel 派送表动态解析(本篇)
- JWT 双 Token 认证与 RBAC 权限控制
- 文档解析 ETL 流水线与 Prisma 事务边界
- Next.js 可编辑大表与历史快照
- CRUD 列表模板(Zod + Mapper)
- 系统架构与 Prisma 数据模型
- Vercel 部署与派送表导出
欢迎在评论区交流 ExcelJS、CSV 分隔符或表头识别算法。觉得有帮助请 点赞 / 收藏 / 关注。
下一篇预告:《JWT 双 Token 认证与 RBAC 权限控制》(专栏续更)