R111/A1,A2 建立发票治理计划和海音达设计基线
这个提交包含在:
+12
-1
@@ -10,6 +10,17 @@
|
||||
- 所有业务对象使用业务编号或摘要展示,不把 UUID 当作主编号展示给用户。
|
||||
- 颜色以浅色工作台为主,黑色只用于正文或弱边框,不作为移动主按钮、底部导航、首屏卡片背景。
|
||||
|
||||
## 品牌视觉参考
|
||||
|
||||
OA 页面应参考公司主页 `www.hyinda.com` 的视觉气质,但不能做成营销页。R111 起以 2026-05-08 对官网首页“海音达 Hyinda”的实际视觉抓取作为基线,截图和颜色摘要保存在 `output/playwright/r111-hyinda-design/`:
|
||||
|
||||
- 品牌识别:使用“海音达 Hyinda”的稳重科技感,页面干净、留白充足、边界清晰。
|
||||
- 主色倾向:官网核心蓝约为 `rgb(0,57,138)`,用于主按钮、选中态、重点链接和关键状态。
|
||||
- 中性色:白色、浅灰蓝、浅边框和深 slate 文本为主,避免大面积黑色块。
|
||||
- 圆角与卡片:控制在工作台风格,卡片边界清晰,不使用营销式大面积渐变、装饰光斑或夸张大圆角。
|
||||
- 字体气质:官网使用偏现代无衬线风格;OA 继续使用系统无衬线字体,但字号、行距和按钮高度要克制。
|
||||
- 双端一致:PC 和企业微信/手机端都遵循同一品牌色和控件语义,只根据屏幕密度调整布局。
|
||||
|
||||
## 文案规则
|
||||
|
||||
禁止:
|
||||
@@ -72,7 +83,7 @@
|
||||
- 后端生成业务编号,例如 `BX20260506001`,前端不再用 UUID 作为主展示。
|
||||
- 草稿允许删除;审批中使用撤回;已通过和已支付不提供删除。
|
||||
- 报销明细存在且只有一条时,上传发票默认绑定该明细。
|
||||
- 多条明细时优先按金额匹配;无法匹配时允许用户选择明细或绑定整单。
|
||||
- 多条明细时优先按金额匹配;无法匹配时自动生成新明细,不向普通用户暴露“绑定整单”。
|
||||
- 发票上传支持批量,OCR 显示明确状态和可重试入口,不长期停留在“上传中/处理中”。
|
||||
- PC 端适合完整明细和对账;手机端适合拍照上传、补充说明、继续编辑草稿和查状态。
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
A1,"R111 国内发票报销主链路治理","按国内报销习惯完成公司级发票查重、已报销不可复用、上传状态自动刷新和 PC/手机添加发票一致。","R111 详细执行合同完成且发票上传、OCR、查重、添加到报销在 PC 与企业微信手机端均可回归。","读取并执行 R111 详细 CSV;业务流程:员工创建报销草稿、上传发票、等待 OCR、添加到明细、提交审批,重复票和已报销票被阻断。","node|prisma|typescript|playwright",DONE,DONE,TODO,"issues|server|client|scripts","[]","R111 详细合同已实现;全量 regression-all 仍阻塞于本地数据库迁移历史分叉,需单独修复环境后回归。"
|
||||
A2,"R112 报销单双端体验统一","统一 PC 与手机报销单能力,草稿编辑、删除、明细编辑、发票移除、提交审批能力一致,页面文案精简。","PC 和手机端都能完成同一套报销草稿操作;不直接展示长 UUID;移动端不误入 PC 页面。","Playwright PC/mobile/wecom UA;业务流程:新建草稿、再次编辑、删除草稿、增删明细、移除发票、提交审批。","typescript|playwright",TODO,TODO,TODO,"client|server","[""A1""]","PC 与手机功能一致,只允许布局和控件形态不同。"
|
||||
A3,"R113 报销附件材料","新增报销附件材料能力,用于支付截图、合同、订单、说明等非发票材料,PC 和手机都可上传、查看、草稿删除。","附件不参与发票 OCR 和查重;草稿可删除;提交后审批人可查看。","后端上传/下载/删除权限测试;Playwright PC/mobile 上传附件并在审批详情查看。","node|prisma|typescript|playwright",DONE,DONE,DONE,"server/prisma|server/src/routes/reimbursements.js|client/src/pages","[""A1"",""A2""]","R111 已提前吸收;轻量附件模型,不做复杂文档库。"
|
||||
A4,"R114 预算与审核提示降级","把风险与同步重命名为预算与审核提示,普通用户隐藏 ERP 同步,保留重复票、预算、缺材料等可理解提示。","报销详情普通用户不再看到 ERP、Outbox、同步队列、ID 映射等内部概念。","Playwright 权限分支验证;业务流程:普通员工、审批人、管理员分别访问报销详情,看到合适的提示区域。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/ReimbursementDetailPage.tsx|client/src/router|client/src/components","[""A2""]","R111 已提前吸收;不删除后端集成能力,只降级当前 UI。"
|
||||
A5,"R115 财务复核与付款收口","围绕小团队人工付款完善财务待办、付款凭证、付款日期、付款方式、报销台账和导出。","财务人员可筛选待付款报销、录入付款信息、导出台账,不依赖独立 ERP。","后端回归 + Playwright PC;业务流程:审批通过报销进入待付款,财务付款并导出台账。","node|typescript|playwright",TODO,TODO,TODO,"server/src/routes|client/src/pages|client/src/api","[""A1"",""A2""]","不接 ERP,先满足人工付款和记账。"
|
||||
A6,"R116 发票验真与电子档案增强","设计并实现发票验真抽象、验真记录、发票电子档案视角,优先支持可替换 provider。","发票可查看 OCR 字段、验真记录、原件和关联报销单;验真失败原因可见。","provider mock 测试 + 发票详情 Playwright;业务流程:财务打开发票电子档案并查看验真记录。","node|prisma|typescript|playwright",TODO,TODO,TODO,"server/src/services/verification|server/src/routes/invoicePool|client/src/pages/InvoicePoolPage.tsx","[""A1""]","不抓取税务网页;真实验真通道后续按合规接口接入。"
|
||||
A7,"R117 企业微信与移动全站收敛","企业微信内常用页面全部可用,首页、报销、请假、审批、我的、通知、附件查看不误入 PC 页面。","WeCom UA 全站巡检通过,移动端视觉无明显截断、遮挡、PC 壳误入。","Playwright mobile/wecom UA + 视觉截图;业务流程:企业微信工作台进入所有高频页面并完成关键动作。","typescript|playwright",TODO,TODO,TODO,"client/src/pages|client/src/router|client/tests/e2e","[""A2"",""A3""]","优先保证在线体验,离线深能力暂缓。"
|
||||
A8,"R118 小团队管理后台收敛","优化 10 人以下团队的用户、工号、角色、部门、费用类型、审批规则和企微绑定配置。","创建用户不强制手动工号;初始化配置更清晰;重模块默认隐藏。","Playwright 管理后台;业务流程:管理员完成新员工、角色、审批规则、企微绑定检查。","node|typescript|playwright",TODO,TODO,TODO,"server/src/routes/users.js|client/src/pages|docs","[""A7""]","减少小团队管理员配置负担。"
|
||||
A9,"R119 试运行治理与上线保障","完善自动化回归、视觉基线、日志、备份、部署、Caddy、数据清理和验收清单。","本地与公网试运行均有可重复验证脚本;备份与回滚路径明确。","server regression-all + client build + Playwright + 公网 ready + 部署 smoke;业务流程:试运行版本可部署、可回滚、可验收。","bash|docker|caddy|playwright",TODO,TODO,TODO,"deploy|scripts|artifacts|docs","[""A1"",""A2"",""A3"",""A4""]","远端目录不是 git 工作树,部署继续走 rsync。"
|
||||
A10,"R120 后置能力冻结","冻结 ERP、复杂差旅统付、企业卡、复杂采购/资产财务联动等后置能力,避免干扰小团队试运行。","后置能力在文档和导航中降级;当前主线只保留必要隐藏能力和边界说明。","人工阅读 docs/product-design-rules.md 与 feature-matrix;业务流程:普通试运行用户不被后置模块干扰。","manual",TODO,TODO,TODO,"docs|client/src/router|client/src/components/navigation","[""A4""]","不是删除能力,是冻结进入条件。"
|
||||
|
@@ -0,0 +1,12 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
A1,"现有发票数据与重复风险审计","只读审计当前发票池重复记录,按 file_sha256、invoice_code+invoice_number、owner、status、linked_reimbursement_id 输出影响范围。","1) 生成重复发票统计;2) 区分未用、已绑定、已归档、识别失败、跨人重复;3) 不修改数据库。","命令:只读 SQL 或 Node 脚本统计 invoice_pool;业务流程:确认当前重复发票是否会影响新报销单添加。","postgres:query|psql|node",DONE,DONE,DONE,"scripts|artifacts|server/src","[]","已执行 node src/scripts/audit-invoice-pool-duplicates.js;生产数据不自动删除。"
|
||||
A2,"国内报销双端能力契约","冻结 PC 与手机端报销功能一致规则,明确发票、附件、明细、删除、提交、风险提示在两端都必须具备,并参考 www.hyinda.com 的品牌视觉。","1) 文档列出 PC/移动同能力清单;2) 文案禁用发票池、绑定整单、ERP 同步等不适合普通用户的表达;3) 后续实现按此验收;4) 页面色彩和控件气质参考公司主页。","人工阅读 docs/product-design-rules.md;业务流程:同一报销草稿在 PC 和企业微信内可完成同样业务动作,视觉上符合海音达品牌。","manual",DONE,DONE,DONE,"docs/product-design-rules.md|issues","[]","已补 Hyinda 官网视觉基线;官网视觉抓取证据在 output/playwright/r111-hyinda-design。"
|
||||
A3,"公司级发票上传查重准入","上传阶段按公司级 file_sha256 查重,重复文件不再产生新的可用发票,已使用发票直接返回不可添加原因。","1) 同一公司内相同文件不会新增重复 available 发票;2) 已绑定或已报销文件再次上传显示已报销不可用;3) PC/手机响应一致。","后端测试:重复上传同一 PDF、上传已绑定发票、跨用户上传同一文件;业务流程:员工不能通过重复上传绕开发票规则。","node|prisma|supertest",DONE,DONE,DONE,"server/src/routes/invoicePool.js|server/src/routes/invoicePool|server/tests","[""A1"",""A2""]","已补单测;强 DB 约束等清理旧数据后再考虑。"
|
||||
A4,"OCR完成后发票号码查重","OCR 识别出发票代码、号码或数电票号码后进行公司级查重,重复、已报销、他人已用发票进入不可添加状态。","1) OCR 后重复票不会进入可添加列表;2) 他人已报销发票不可添加;3) 状态原因可被前端展示。","后端测试:模拟 OCR 返回重复发票号码;业务流程:OCR 完成后页面自动显示重复或已报销原因。","node|prisma|tencentcloud-sdk-nodejs",DONE,DONE,DONE,"server/src/jobs/ocrJob.js|server/src/services/ocr|server/src/routes/invoicePool","[""A3""]","管理员例外处理保留审计字段,但普通流程不开放。"
|
||||
A5,"添加发票后端强约束与自动明细","统一 PC/移动添加发票接口,服务端校验本人、状态、重复、已报销、明细归属,并支持自动生成或匹配报销明细。","1) available 且未使用发票才能添加;2) linkedItemId 必须属于当前报销单;3) 无明细时自动生成明细;4) 绕过前端也不能绑定非法发票。","后端回归:合法添加、已报销票、他人票、重复票、非法 linkedItemId、自动生成明细;业务流程:添加发票即形成可提交明细。","node|prisma|regression",DONE,DONE,DONE,"server/src/routes/invoicePool*.js|server/src/routes/reimbursements.js|server/scripts","[""A4""]","已用 invoicePool.travel-bind.test 覆盖自动明细和非法 linkedItemId。"
|
||||
A6,"统一发票状态查询与自动刷新","新增或完善批量发票状态查询,PC 和手机上传后都能看到识别中、可添加、已添加、重复、已报销不可用、识别失败。","1) 上传后不再只刷新可绑定列表;2) OCR 终态自动刷新;3) 失败或重复原因明确;4) 刷新按钮有明显反馈。","Playwright:mock pending、success、ocr_failed、duplicate、used;业务流程:企业微信上传后状态自动变化,不再长时间误显示上传中。","typescript|playwright",DONE,DONE,DONE,"client/src/api/invoicePool.ts|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx","[""A5""]","已新增 /api/invoice-pool/status 批量接口并补 Playwright。"
|
||||
A7,"PC端发票流程一致改造","PC 报销详情同步采用国内轻表达,支持上传、状态、重复提示、自动明细、移除发票,不暴露绑定整单等内部概念。","1) PC 可完成与手机一致的发票能力;2) 添加发票弹窗简洁;3) 已添加发票可读、可移除;4) 已报销和重复状态不可误操作。","Playwright PC:创建草稿、上传发票、查看状态、添加到明细、移除、重复票阻断;视觉截图检查文案和按钮一致性。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/invoice-pool|client/tests/e2e","[""A6""]","PC 截图证据在 output/playwright/r111-invoice-reimbursement。"
|
||||
A8,"手机企业微信发票流程一致改造","手机端去掉绑定整单和搜索式添加,改为添加发票、查看本次上传状态、自动生成明细、可用发票选择。","1) 企业微信内不出现绑定整单;2) 添加发票不会默认弹键盘搜索;3) 底部操作区紧凑;4) 上传后终态自动展示。","Playwright mobile/wecom UA:上传、状态轮询、重复票、失败票、自动明细、视觉截图。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/MobileReimbursementDetailPage.tsx|client/tests/e2e","[""A6""]","移动端截图证据在 output/playwright/r111-invoice-reimbursement。"
|
||||
A9,"报销附件材料轻量能力","新增报销附件材料能力,用于支付截图、合同、订单、说明等非发票材料;PC 和手机都可上传、查看、草稿删除。","1) 支持多附件上传;2) 附件不参与发票 OCR 和发票查重;3) 草稿可删除;4) 提交后审批人可查看;5) PC/手机能力一致。","后端测试:上传、列表、下载、删除权限;Playwright:PC 和手机上传附件、提交后审批详情可见。","node|prisma|typescript|playwright",DONE,DONE,DONE,"server/prisma/schema.prisma|server/src/routes/reimbursements.js|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx","[""A2"",""A5""]","新增迁移 20260508091500_reimbursement_attachments;回滚为删除附件表及 Employee/ReimbursementForm 关系字段。"
|
||||
A10,"风险与同步信息架构降级","把报销详情的风险与同步改为预算与审核提示,普通用户默认隐藏 ERP 同步;ERP 集成入口仅在配置开启且有权限时显示。","1) 报销详情不再对普通用户显示 ERP 同步;2) 风险项改成审核提示;3) 系统设置中的 ERP 入口默认隐藏或降级为外部系统集成;4) 后端集成能力保留。","Playwright PC/移动:普通用户、管理员、integration.manage 权限分别访问报销详情和系统入口;业务流程:无 ERP 公司不看到重型同步概念。","typescript|playwright",DONE,DONE,DONE,"client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/ErpIntegrationPage.tsx|client/src/router|client/src/components/navigation","[""A2""]","不删除后端 IntegrationOutbox;普通用户默认隐藏重型同步概念。"
|
||||
A11,"R111自动化视觉回归与中文分批提交","执行后端回归、前端 typecheck/build、PC/移动/企业微信 UA Playwright、视觉截图,并按中文分批提交。","1) Issue CSV 校验通过;2) server 回归通过;3) client typecheck/build 通过;4) PC 和移动视觉截图通过;5) 中文 commit 引用 R111/Ax。","命令:validate Issue CSV、server regression-all、client typecheck/build、Playwright PC/mobile/wecom、视觉截图;业务流程:发票、附件、重复票、已报销票、提交审批完整回归。","python|npm|playwright|git",DONE,DONE,TODO,"issues|server|client|scripts|artifacts","[""A3"",""A4"",""A5"",""A7"",""A8"",""A9"",""A10""]","Issue CSV 校验、单测、typecheck/build、Playwright 已通过;regression-all 阻塞于本地数据库迁移历史分叉和 employees.avatar_path 缺失,需单独处理后重跑;不提交历史脏 artifacts。"
|
||||
|
@@ -0,0 +1,109 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
|
||||
function printGroup(title, rows, keyFields) {
|
||||
process.stdout.write(`\n## ${title}\n`);
|
||||
if (!rows.length) {
|
||||
process.stdout.write("无重复记录\n");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const key = keyFields.map((field) => row[field]).join(" / ");
|
||||
process.stdout.write(`\n- ${key}:${row.count} 条\n`);
|
||||
for (const item of row.items) {
|
||||
process.stdout.write(
|
||||
` - ${item.id} status=${item.status} owner=${item.ownerName ?? "-"} form=${item.formNumber ?? "-"} file=${item.originalName ?? "-"} created=${item.createdAt ?? "-"}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDuplicateGroups(prisma, { byInvoiceNumber = false } = {}) {
|
||||
const groups = byInvoiceNumber
|
||||
? await prisma.$queryRaw`
|
||||
SELECT invoice_code, invoice_number, COUNT(*)::int AS count
|
||||
FROM "invoice_pool"
|
||||
WHERE invoice_code IS NOT NULL AND invoice_number IS NOT NULL
|
||||
GROUP BY invoice_code, invoice_number
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY COUNT(*) DESC, invoice_code ASC, invoice_number ASC
|
||||
LIMIT 100
|
||||
`
|
||||
: await prisma.$queryRaw`
|
||||
SELECT file_sha256, COUNT(*)::int AS count
|
||||
FROM "invoice_pool"
|
||||
GROUP BY file_sha256
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY COUNT(*) DESC, file_sha256 ASC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const rows = [];
|
||||
for (const group of groups) {
|
||||
const where = byInvoiceNumber
|
||||
? {
|
||||
invoice_code: group.invoice_code,
|
||||
invoice_number: group.invoice_number,
|
||||
}
|
||||
: { file_sha256: group.file_sha256 };
|
||||
|
||||
const items = await prisma.invoicePool.findMany({
|
||||
where,
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
original_name: true,
|
||||
created_at: true,
|
||||
owner_employee: { select: { name: true } },
|
||||
linked_reimbursement: { select: { form_number: true } },
|
||||
},
|
||||
});
|
||||
|
||||
rows.push({
|
||||
...group,
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
ownerName: item.owner_employee?.name ?? null,
|
||||
formNumber: item.linked_reimbursement?.form_number ?? null,
|
||||
originalName: item.original_name ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
const statusRows = await prisma.invoicePool.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
orderBy: { status: "asc" },
|
||||
});
|
||||
process.stdout.write("# 发票池重复审计\n\n## 状态分布\n");
|
||||
for (const row of statusRows) {
|
||||
process.stdout.write(`- ${row.status}: ${row._count._all}\n`);
|
||||
}
|
||||
|
||||
const [fileGroups, numberGroups] = await Promise.all([
|
||||
loadDuplicateGroups(prisma),
|
||||
loadDuplicateGroups(prisma, { byInvoiceNumber: true }),
|
||||
]);
|
||||
|
||||
printGroup("按文件 SHA256 重复", fileGroups, ["file_sha256"]);
|
||||
printGroup("按发票代码/号码重复", numberGroups, ["invoice_code", "invoice_number"]);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户