Files
Yb-QaAutomationHub/output/make_docx_waybill_package.js
xst 901b6741ff test(运单资料文件包下载): 需求文档与 QE Fleet 全流程测试设计产物入库
- 需求 v1.0 定稿(md+docx)与 UI 原型入库;确认单判定 docx 为同文载体、安徽运八并行无冲突
- 全流程产物:分析 + 59 测试点 + 85 用例 + 测试数据 145 组;评审 3 阻断与覆盖缺口 GAP-001 已修复,裁决 PASS_WITH_FIX
- 导出 Excel(云效模型)+ XMind + 版本快照 v1;含 docx 生成脚本与转换校验产物
- .gitignore 排除 node_modules 与 Excel 锁文件
2026-09-09 09:31:55 +08:00

378 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 生成《运单资料文件包下载需求文档》Word 版
const fs = require("fs");
const path = require("path");
const {
Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
WidthType, AlignmentType, HeadingLevel, ShadingType, BorderStyle,
LevelFormat, VerticalAlign, Footer, PageNumber,
} = require("docx");
const FONT_BODY = { ascii: "Times New Roman", hAnsi: "Times New Roman", eastAsia: "宋体" };
const FONT_HEAD = { ascii: "Arial", hAnsi: "Arial", eastAsia: "黑体" };
const FONT_MONO = { ascii: "Consolas", hAnsi: "Consolas", eastAsia: "宋体" };
const T = (t, o = {}) => new TextRun({ text: t, ...o });
const B = (t, o = {}) => new TextRun({ text: t, bold: true, ...o });
function para(children, opts = {}) {
return new Paragraph({ spacing: { after: 140, line: 312 }, children: Array.isArray(children) ? children : [T(children)], ...opts });
}
function bullet(children) {
return new Paragraph({ numbering: { reference: "bul", level: 0 }, spacing: { after: 80, line: 300 }, children: Array.isArray(children) ? children : [T(children)] });
}
function numItem(children) {
return new Paragraph({ numbering: { reference: "num", level: 0 }, spacing: { after: 80, line: 300 }, children: Array.isArray(children) ? children : [T(children)] });
}
const h1 = (t) => new Paragraph({ heading: HeadingLevel.HEADING_1, children: [T(t)] });
const h2 = (t) => new Paragraph({ heading: HeadingLevel.HEADING_2, children: [T(t)] });
const h3 = (t) => new Paragraph({ heading: HeadingLevel.HEADING_3, children: [T(t)] });
const spacer = (after = 120) => new Paragraph({ spacing: { after }, children: [] });
function noteP(children) {
return new Paragraph({
spacing: { after: 160, line: 300 }, indent: { left: 200 },
shading: { type: ShadingType.CLEAR, fill: "F0F7FF" },
border: { left: { style: BorderStyle.SINGLE, size: 18, color: "1677FF", space: 6 } },
children: Array.isArray(children) ? children : [T(children)],
});
}
function monoBlock(lines) {
return lines.map((l) => new Paragraph({
spacing: { after: 0, line: 264 }, indent: { left: 240, right: 240 },
shading: { type: ShadingType.CLEAR, fill: "F7F7F7" },
children: [new TextRun({ text: l, font: FONT_MONO, size: 17 })],
})).concat([spacer(140)]);
}
function makeTable(headers, rows, widths, { size = 18, centerCols = [] } = {}) {
const cellPara = (txt, center, bold, fsize) => new Paragraph({
alignment: center ? AlignmentType.CENTER : AlignmentType.LEFT,
spacing: { after: 0, line: 264 },
children: [new TextRun({ text: txt, bold, size: fsize })],
});
const headerRow = new TableRow({
tableHeader: true,
children: headers.map((h, i) => new TableCell({
width: { size: widths[i], type: WidthType.DXA },
shading: { type: ShadingType.CLEAR, fill: "DCE6F1" },
verticalAlign: VerticalAlign.CENTER,
margins: { top: 70, bottom: 70, left: 110, right: 110 },
children: [cellPara(h, true, true, size)],
})),
});
const bodyRows = rows.map((r) => new TableRow({
children: r.map((c, i) => new TableCell({
width: { size: widths[i], type: WidthType.DXA },
verticalAlign: VerticalAlign.CENTER,
margins: { top: 60, bottom: 60, left: 110, right: 110 },
children: (Array.isArray(c) ? c : [String(c)]).map((line) => cellPara(line, centerCols.includes(i), false, size)),
})),
}));
return new Table({
width: { size: widths.reduce((a, b) => a + b, 0), type: WidthType.DXA },
columnWidths: widths,
rows: [headerRow, ...bodyRows],
});
}
const kids = [];
// ── 标题区 ──
kids.push(new Paragraph({
alignment: AlignmentType.CENTER, spacing: { before: 200, after: 80 },
children: [new TextRun({ text: "运单资料文件包下载 需求文档", bold: true, size: 36, font: FONT_HEAD, color: "1F3864" })],
}));
kids.push(new Paragraph({
alignment: AlignmentType.CENTER, spacing: { after: 60 },
children: [new TextRun({ text: "版本 v1.0(定稿) · 运八平台端 · 2026-09-08", size: 18, color: "888888" })],
}));
kids.push(new Paragraph({ spacing: { after: 240 }, border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: "1677FF", space: 4 } }, children: [] }));
// ── 文档信息 ──
kids.push(makeTable(
["项", "内容"],
[
["功能名称", "运单资料文件包下载(新增菜单)"],
["所属端", "运八平台端(Web 管理后台)"],
["版本", "v1.0(定稿)"],
["编写日期", "2026-09-08"],
["确认记录", "2026-09-08 需求方确认,13 项待确认事项全部采纳本文档建议方案(见第八章)"],
["需求来源", "运营口头需求(2026-09-08),本文档为整理稿"],
["关联术语", "以 knowledge_base/01_standards/terminology.md 为准"],
["原型", "resources/prototypes/anhuibaba_file_package.html(静态演示:任务列表 / 上传弹窗 / 文件包结构预览 / 失败清单)"],
],
[1800, 7226]
));
kids.push(spacer());
// ── 一、需求背景与目标 ──
kids.push(h1("一、需求背景与目标"));
kids.push(para("运营人员在线下归档、审计、对账等场景中,需要按批次收集运单资料:运单合同、电子回单,以及司机证件照片、车辆证件照片。目前只能逐单进入运单详情手工下载整理,效率低且易遗漏。"));
kids.push(para([B("目标:"), T("提供一个“上传运单清单 → 系统自动生成资料文件包 → 任务完成后下载”的批量导出能力,按“司机+身份证+车辆”自动归批,资料按运单分目录组织、按规则命名,一次下载即可获得全部资料。")]));
kids.push(para([B("本期范围:"), T("仅做资料导出打包;不新增数据采集,合同、电子回单、司机证件、车辆证件均读取运八平台现有数据。")]));
// ── 二、名词定义 ──
kids.push(h1("二、名词定义"));
kids.push(makeTable(
["术语", "定义"],
[
["批次", "上传清单中,运单在系统内的 司机姓名 + 司机身份证号 + 车牌号 三要素完全一致的一组运单。"],
["文件包(批次包)", "一个批次生成的目录,内含该批次全部运单的运单包。"],
["运单包", "批次包下按运单维度建立的子目录,内含该运单的合同、电子回单及(仅最小序号运单)司机、车辆证件照片。"],
["最小序号运单", "同一批次内,上传清单中“序号”最小的运单。该运单的运单包额外包含司机证件照片、车辆证件照片。"],
],
[1900, 7126]
));
kids.push(spacer());
// ── 三、整体流程 ──
kids.push(h1("三、整体流程"));
kids.push(...monoBlock([
"运营人员 系统",
" │ ① 下载模板,填写运单清单并上传 │",
" │ ────────────────────────────────────→ │ ② 校验模板与数据(见 4.2.2)",
" │ │ ③ 创建打包任务(待处理 → 处理中)",
" │ ④ 任务列表查看处理进度 │ ④ 按批次分组 → 逐运单下载关联文件 → 组装文件包",
" │ ←──────────────────────────────────── │ ⑤ 任务完成(已完成 / 已完成·部分失败 / 失败)",
" │ ⑥ 点击“下载”获取文件包 zip │",
" │ ←──────────────────────────────────── │ ⑦ 返回 zip,记录下载审计日志",
]));
kids.push(para([T("核心规则:", { bold: true }), B("司机+身份证+车辆算一批运单,生成一个文件包"), T(";文件包内按运单建运单包;批次内"), B("最小序号"), T("的运单包额外含司机及车辆证件照片,其余运单包只含合同与电子回单。")]));
// ── 四、功能需求 ──
kids.push(h1("四、功能需求"));
kids.push(h2("4.1 菜单入口"));
kids.push(bullet([T("运八平台端新增独立菜单,菜单名称 "), B("“运单文件包”"), T(",挂在运单管理相关模块下。")]));
kids.push(bullet("页面结构:上方“上传任务”入口(模板下载 + 上传按钮),下方“任务列表”。"));
kids.push(bullet([T("可见角色:"), B("运营人员"), T(";菜单需权限控制——文件包含身份证等敏感证件照片。")]));
kids.push(h2("4.2 文件上传"));
kids.push(h3("4.2.1 上传模板"));
kids.push(makeTable(
["列名", "类型", "必填", "说明"],
[
["序号", "整数", "是", "正整数,全文件内唯一;决定批次内“最小序号运单”的判定"],
["货源单号", "文本", "是", "系统内货源单号"],
["运单号", "文本", "是", "系统内运单号"],
],
[1300, 1100, 700, 5926],
{ centerCols: [1, 2] }
));
kids.push(spacer(80));
kids.push(bullet([T("文件格式:"), B(".xlsx"), T(";页面提供模板下载。")]));
kids.push(bullet([T("限制:单文件行数上限 "), B("2000 行"), T(";文件大小上限 "), B("10MB"), T("。")]));
kids.push(h3("4.2.2 校验规则"));
kids.push(makeTable(
["级别", "规则", "处理方式"],
[
["文件级", "非 xlsx 格式 / 列名缺失或不符 / 文件损坏", "上传即拒绝,提示具体原因,不创建任务"],
["文件级", "行数、大小超上限", "上传即拒绝"],
["数据级", "序号非正整数或重复", "该行计入失败清单"],
["数据级", "货源单号 / 运单号为空", "该行计入失败清单"],
["数据级", "运单号在系统中不存在", "该行计入失败清单"],
["数据级", "文件中货源单号与该运单实际所属货源不一致", "该行计入失败清单"],
["数据级", "同一运单号在文件中重复出现", "重复行计入失败清单"],
["数据级", "运单缺少司机或车辆信息,无法归批", "该行计入失败清单(原因:无法归批)"],
],
[900, 4400, 3726],
{ centerCols: [0] }
));
kids.push(spacer(80));
kids.push(noteP([B("处理原则:"), T("文件级错误整体拒绝;数据级错误剔除失败行后继续处理有效行,任务状态记为“已完成·部分失败”。")]));
kids.push(h2("4.3 任务"));
kids.push(h3("4.3.1 任务状态"));
kids.push(...monoBlock([
"待处理 → 处理中 → 已完成 (全部有效行成功)",
" → 已完成·部分失败 (部分行失败,可下载失败清单)",
" → 失败 (系统级错误导致整体未生成文件包)",
]));
kids.push(h3("4.3.2 任务列表"));
kids.push(makeTable(
["字段", "说明"],
[
["任务编号", "系统生成"],
["源文件名", "上传的清单文件名"],
["上传人 / 上传时间", "—"],
["任务状态", "待处理 / 处理中 / 已完成 / 已完成·部分失败 / 失败"],
["总行数 / 成功数 / 失败数", "处理进度与结果统计"],
["完成时间", "—"],
["操作", "下载文件包(任务成功后显示)、下载失败清单(存在失败行时显示,xlsx,含序号/货源单号/运单号/失败原因)"],
],
[2500, 6526]
));
kids.push(spacer());
kids.push(h2("4.4 文件包生成规则"));
kids.push(h3("4.4.1 批次划分"));
kids.push(para([T("按有效行运单在"), B("系统内"), T("的 司机姓名 + 司机身份证号 + 车牌号 三要素归组,三要素完全一致归为一批,每批生成一个文件包。同一司机换车、同一车辆换司机都会拆分为不同批次。")]));
kids.push(h3("4.4.2 目录结构与命名"));
kids.push(...monoBlock([
"运单资料_【任务编号】.zip ← 任务下载总包",
"├─ 【司机姓名】-【身份证号】-【车牌号】/ ← 批次文件包①",
"│ ├─ 1-YD20260901001-HY202609001-含司机及车辆证件/ ← 批次内最小序号运单包",
"│ │ ├─ 合同/(该运单全部合同文件)",
"│ │ ├─ 电子回单/(该运单全部支付记录对应回单)",
"│ │ ├─ 司机证件/(身份证、驾驶证、从业资格证等照片)",
"│ │ └─ 车辆证件/(行驶证、道路运输证等照片)",
"│ └─ 3-YD20260901003-HY202609001/ ← 批次内其余运单包",
"│ ├─ 合同/",
"│ └─ 电子回单/",
"└─ 【司机姓名】-【身份证号】-【车牌号】/ ← 批次文件包②",
" └─ 2-YD20260901002-HY202609002-含司机及车辆证件/ ← 单运单批次,仍含证件",
]));
kids.push(bullet([T("任务下载总包命名:"), B("运单资料_【任务编号】.zip"), T("。")]));
kids.push(bullet([T("批次文件包命名:"), B("【司机姓名】-【身份证号】-【车牌号】"), T("。")]));
kids.push(bullet([T("运单包命名(需求明确):最小序号运单 "), B("【序号】-【运单号】-【货源单号】-含司机及车辆证件"), T(";其余运单 "), B("【序号】-【运单号】-【货源单号】"), T("。")]));
kids.push(bullet("运单包内按 合同 / 电子回单 / 司机证件 / 车辆证件 建子目录;文件保留系统原文件名,重名时自动追加 (1)、(2)。"));
kids.push(h3("4.4.3 各运单包内容"));
kids.push(makeTable(
["内容", "最小序号运单", "其余运单", "数据来源"],
[
["运单相关合同", "√", "√", "运单关联的合同附件(以运单详情-合同附件为准,含承运合同;不含委托合同/框架合同)"],
["电子回单", "√", "√", "运单全部支付记录对应的银行电子回单"],
["司机证件照片", "√", "×", "该运单司机档案:身份证(人像面/国徽面)、驾驶证、从业资格证等"],
["车辆证件照片", "√", "×", "该运单车辆档案:行驶证、道路运输证、车辆照片等"],
],
[1700, 1400, 1200, 4726],
{ centerCols: [1, 2] }
));
kids.push(spacer(80));
kids.push(noteP("证件照片以批次内最小序号运单挂载的司机、车辆档案为准(同批次三要素一致,证件相同,故只随最小序号运单导出一份)。"));
kids.push(h3("4.4.4 文件缺失处理"));
kids.push(bullet("某运单无合同或无电子回单:不阻断任务,对应子目录不生成,原因记入失败清单(如“无电子回单:运单无支付记录”)。"));
kids.push(bullet([T("批次内最小序号运单因故剔除(校验失败/文件缺失):"), B("重新计算"), T("批次内最小序号,证件照片改挂到新的最小序号运单。")]));
kids.push(bullet("单个文件下载失败自动重试 3 次,仍失败则记入失败清单,不阻断其余文件。"));
kids.push(h2("4.5 下载"));
kids.push(bullet("任务完成后,任务列表该行显示“下载”按钮,下载内容为任务总包 zip(含全部批次文件包)。"));
kids.push(bullet("支持重复下载,不重复生成。"));
kids.push(bullet("下载行为记录审计日志(操作人、时间、任务编号)——文件包含身份证等敏感个人信息。"));
kids.push(bullet("按批次文件包单独下载:本期不做。"));
// ── 五、上传示例与生成结果 ──
kids.push(h1("五、上传示例与生成结果"));
kids.push(para([B("上传清单:")]));
kids.push(makeTable(
["序号", "货源单号", "运单号", "司机(系统内)", "车牌(系统内)"],
[
["1", "HY202609001", "YD20260901001", "张三", "皖A12345"],
["2", "HY202609002", "YD20260901002", "李四", "皖B67890"],
["3", "HY202609001", "YD20260901003", "张三", "皖A12345"],
],
[800, 2000, 2400, 1900, 1926],
{ centerCols: [0, 3, 4] }
));
kids.push(spacer(80));
kids.push(para([B("生成结果:")]));
kids.push(bullet([T("批次① = 序号 1、3(张三+同一身份证+皖A12345)→ 文件包“张三-340102199001011234-皖A12345”,内含运单包 "), B("1-…-含司机及车辆证件"), T("(合同+回单+张三证件+皖A12345 证件)和 "), B("3-…"), T("(仅合同+回单)。")]));
kids.push(bullet([T("批次② = 序号 2(李四)→ 文件包“李四-340102199001015678-皖B67890”,运单包 "), B("2-…-含司机及车辆证件"), T("(单运单批次,仍含司机及车辆证件)。")]));
// ── 六、异常与边界场景汇总 ──
kids.push(h1("六、异常与边界场景汇总"));
kids.push(makeTable(
["#", "场景", "处理"],
[
["1", "模板格式/列名错误", "上传即拒绝"],
["2", "序号重复、非正整数", "行级失败清单"],
["3", "运单号不存在 / 货源单号不匹配", "行级失败清单"],
["4", "运单被关闭、未产生支付记录导致无电子回单", "不阻断,记失败清单"],
["5", "运单缺司机或车辆信息无法归批", "行级失败清单"],
["6", "批次内最小序号运单被剔除", "证件改挂重新计算后的最小序号运单"],
["7", "同一文件重复上传", "允许,各自生成独立任务,不合并去重"],
["8", "多任务并发", "后台排队处理(并发上限 1~2),防磁盘/带宽压力"],
["9", "文件包超过保留期", "不可下载,需重新上传生成"],
["10", "任务处理中系统异常", "任务置“失败”,支持删除后重新上传"],
],
[500, 3900, 4626],
{ centerCols: [0] }
));
kids.push(spacer());
// ── 七、非功能需求 ──
kids.push(h1("七、非功能需求"));
kids.push(makeTable(
["项", "要求"],
[
["异步处理", "打包为后台异步任务,任务列表可见进度(成功/失败条数);上传后无需等待"],
["性能", "1000 单规模任务 30 分钟内完成"],
["保留期", "生成的文件包默认保留 7 天,过期自动清理"],
["敏感数据", "司机身份证号、证件照片属敏感个人信息:菜单需权限控制、下载记审计日志;批次包命名含完整身份证号,包内数据仅限授权人员获取"],
["幂等", "同一任务重复点击下载不重复生成文件包"],
],
[1600, 7426]
));
kids.push(spacer());
// ── 八、确认决议记录 ──
kids.push(h1("八、确认决议记录(2026-09-08)"));
kids.push(para([T("原 v0.1 草稿 13 项待确认事项,经需求方确认"), B("全部采纳文档建议方案"), T(",逐项记录如下:")]));
kids.push(makeTable(
["#", "事项", "确认结论"],
[
["1", "菜单名称与挂载位置", "“运单文件包”,挂在运单管理模块下"],
["2", "可见角色/权限", "运营人员"],
["3", "批次文件包命名", "司机姓名-身份证号-车牌号(含完整身份证号)"],
["4", "运单包内子目录", "按 合同/电子回单/司机证件/车辆证件 建子目录"],
["5", "“运单相关合同”范围", "以运单详情-合同附件为准(含承运合同),不含委托合同/框架合同"],
["6", "货源单号与系统不一致时", "该行失败"],
["7", "数据级校验失败行处理", "剔除后继续,任务记“已完成·部分失败”"],
["8", "失败行剔除后证件挂载", "挂到批次内剩余运单的最小序号运单"],
["9", "行数/大小上限", "2000 行 / 10MB"],
["10", "文件包保留期", "7 天"],
["11", "按批次包单独下载", "本期不做"],
["12", "zip 是否加密", "不加密(依赖权限+审计)"],
["13", "序号唯一性范围", "全文件唯一"],
],
[500, 2800, 5726],
{ centerCols: [0] }
));
kids.push(spacer());
// ── 九、验收要点 ──
kids.push(h1("九、验收要点"));
kids.push(numItem("上传合法清单后任务创建成功,完成后可下载 zip;zip 内批次划分与目录命名符合 4.4 规则。"));
kids.push(numItem("同一批次内仅最小序号运单包含司机、车辆证件照片,其余运单仅含合同与电子回单。"));
kids.push(numItem("单运单批次的运单包仍包含司机及车辆证件照片。"));
kids.push(numItem("各类非法数据按 4.2.2 处理,失败清单内容准确可下载。"));
kids.push(numItem("任务失败、部分失败、重复下载、保留期过期等边界行为符合第五、六章定义。"));
// ── 组装文档 ──
const doc = new Document({
styles: {
default: {
document: { run: { font: FONT_BODY, size: 21, color: "333333" } },
heading1: { run: { font: FONT_HEAD, size: 30, bold: true, color: "1F3864" }, paragraph: { spacing: { before: 360, after: 180 } } },
heading2: { run: { font: FONT_HEAD, size: 25, bold: true, color: "2E5395" }, paragraph: { spacing: { before: 280, after: 140 } } },
heading3: { run: { font: FONT_HEAD, size: 22, bold: true, color: "404040" }, paragraph: { spacing: { before: 240, after: 120 } } },
},
},
numbering: {
config: [
{ reference: "bul", levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 420, hanging: 210 } } } }] },
{ reference: "num", levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 420, hanging: 210 } } } }] },
],
},
sections: [{
properties: { page: { margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 } } },
footers: {
default: new Footer({
children: [new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ children: ["第 ", PageNumber.CURRENT, " 页 / 共 ", PageNumber.TOTAL_PAGES, " 页"], size: 16, color: "999999" })],
})],
}),
},
children: kids,
}],
});
const out = path.resolve(__dirname, "..", "requirements", "运单资料文件包下载需求.docx");
Packer.toBuffer(doc).then((buf) => {
fs.writeFileSync(out, buf);
console.log("OK ->", out, buf.length, "bytes");
});