diff --git a/docs/product-design-rules.md b/docs/product-design-rules.md index 39f183fd..58041b52 100644 --- a/docs/product-design-rules.md +++ b/docs/product-design-rules.md @@ -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 端适合完整明细和对账;手机端适合拍照上传、补充说明、继续编辑草稿和查状态。 diff --git a/issues/2026-05-08_09-04-18-r111-r120-small-team-trialrun-roadmap.csv b/issues/2026-05-08_09-04-18-r111-r120-small-team-trialrun-roadmap.csv new file mode 100644 index 00000000..2753500d --- /dev/null +++ b/issues/2026-05-08_09-04-18-r111-r120-small-team-trialrun-roadmap.csv @@ -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""]","不是删除能力,是冻结进入条件。" diff --git a/issues/2026-05-08_09-04-19-r111-domestic-invoice-reimbursement-guard.csv b/issues/2026-05-08_09-04-19-r111-domestic-invoice-reimbursement-guard.csv new file mode 100644 index 00000000..c71237b9 --- /dev/null +++ b/issues/2026-05-08_09-04-19-r111-domestic-invoice-reimbursement-guard.csv @@ -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。" diff --git a/server/src/scripts/audit-invoice-pool-duplicates.js b/server/src/scripts/audit-invoice-pool-duplicates.js new file mode 100644 index 00000000..611f51b8 --- /dev/null +++ b/server/src/scripts/audit-invoice-pool-duplicates.js @@ -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); +});