fix: 完善回单失败引导及真实认证验证(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -2,6 +2,11 @@
|
||||
|
||||
## Receipt Verification Dialog
|
||||
|
||||
- When readiness is unmet and an active receipt has a system/key/snapshot failure,
|
||||
the batch banner must direct prerequisite repair, not an unavailable verification
|
||||
dialog. This is display only: a retained failed attachment must not block other
|
||||
sufficient evidence. Refresh stays read-only; controlled reupload retains history.
|
||||
|
||||
- Reuse the receipt card and existing Dialog/Input/Button patterns. No separate
|
||||
receipt center. Show original and proposed summaries; account fields remain masked.
|
||||
Original-file preview keeps its existing authorized path.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 业务闭环方案(分阶段实施,发布状态见执行记录)
|
||||
|
||||
## 2026-09-15 系统失败引导最小收口
|
||||
|
||||
从未归档附件投影是否存在system_failed/key_failed/snapshot_failed;仅在现有readiness未满足横幅中优先展示管理员排障引导,不修改canRegisterPaymentComplete或服务端资格。其他可用证据满足readiness时不因旧失败附件新增阻断。重新核对按钮仍只读refetch,不发OCR。恢复沿用修复前提后另批重新上传原件的现有路径,旧附件与审计保留,不引入新的管理员角色权限或批量SQL重排队。
|
||||
|
||||
## 2026-09-13 回单核对增量设计
|
||||
|
||||
### 2026-09-14 人工核验执行细化
|
||||
|
||||
@@ -1,5 +1,98 @@
|
||||
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
|
||||
|
||||
## 2026-09-15 浏览器同链与恢复指引收口
|
||||
|
||||
新增test-payment-receipt-browser.cjs作为既有DB脚本显式RUN_RECEIPT_BROWSER=1模式;要求RUN_ISOLATED_DB_TEST=1及RUN_RECEIPT_REAL_AUTH=1。正式buildApp/auth/me/付款路由、真实本地HTTP与Vite代理,不替换业务API。实际密码登录→上传合成PDF→真实文件读取及worker领取/匹配落库→UI明确登记→同库paid/claim/审计;另测错金额、缺密钥、重复交易不登记及财务退出→复核人登录→财务再登录。创建单据/批次复核预置,OCR provider合成,worker手动调用,不证明腾讯/pg-boss调度/生产全链。
|
||||
|
||||
账号切换仅验证正常退出再登录、真实/me身份及无残留对话框,不冒充迟到401竞争或全站缓存隔离。缺密钥浏览器反例是key_failed,不是数据库故障;SQL事务中止的独立失败回写由既有DB测试覆盖。负例浏览器未主动调用隐藏的execute接口,后端拒绝由既有HTTP/DB测试覆盖。首页portal开关未启用的409及服务测试残留不完整来源夹具导致的偶发列表404不计入付款链验收,不静默mock为成功。
|
||||
|
||||
前几轮曾发现preview导入形式、refresh Origin配置及来源夹具缺项,均在测试工具修正,未修改生产认证。正常链和三个反例、切账号在同轮43项通过,最终输入再验见后续记录。两套视口系统失败横幅不再引导禁用的详情,权限/状态机/readiness不变;管理员恢复按手册先修前提、另批原件重上传,保留失败历史,无新重排队接口。
|
||||
|
||||
只读独立复核发现app dotenv与Vite默认环境文件读取违反隔离合同,已将dotenv固定到本轮0600空文件、拒绝dotenv_config_*参数、删除override环境选项、Vite envFile=false。未据此认定曾读取生产秘密或发生云调用。禁用真实jobs/OCR/外部通知,清理WECOM/TENCENT/SMTP/LIVE_SMOKE环境并在finally恢复;随机口令/token不输出。重跑需先client npm run build,绑定当前dist摘要,不以旧dist证明新源码。
|
||||
|
||||
当前不提交推送、不部署、不生产写入。后续真实业务剩余:新回单实际队列消费、合格独立复核人安排、密钥高熵来源、汇总回单形态/存量legacy处理仍不可用合成测试替代;人员职责由业务决定,真实原件外调/重传另批。项目其他存量任务不因本轮局部完成批量归档。
|
||||
|
||||
最终源码输入绑定:DB脚本SHA256=cfbae0100e5b312bbd768c60c9604ad7770709230e086d2664d652a1b7040485,browser脚本=7f1fc1c5d264e8cc86e551ca233f45b21a9203c45b8335884119d3c2723a024a,PaymentBatchesPage=f62cbbfecc62df3d2d2ef0fa57b3192c0e34dff1006bb8f16c247ab9a3263786,system-action-confirmation.spec=511ff3d840335d76ba524ac754dbb40a9657d3d377c012c5bcf07976729e2242。client npm run build(含typecheck)通过后,按client/dist递归排序路径与文件SHA逐行拼接所得SHA256=e9b6ac54e53ee6dbd59180fb2c1106441db7b1de7d7ccb0a4c3a3717d08b0b1f;后续浏览器均使用该构建,未再改产品源码。
|
||||
|
||||
独立只读复核最终确认dotenv/Vite两条隔离阻断已闭合,无剩余静态阻断,未运行测试;上述覆盖自限保留。首次扩大UI回归23项中21通过、2项制单因未指定既有BIFF8解释器失败;改用既有PAYMENT_ORDER_PYTHON_BIN重新跑同23项,不改产品生成器。治理/process/diff通过;文档registry零错误、保留22项未登记文档的既有告警,不扩修。
|
||||
|
||||
最终执行结果(以上源码摘要输入):RUN_ISOLATED_DB_TEST=1 RUN_RECEIPT_REAL_AUTH=1 RUN_RECEIPT_BROWSER=1 node scripts/qa/test-payment-receipt-db.cjs为43/43 PASS;默认RUN_ISOLATED_DB_TEST=1 node scripts/qa/test-payment-receipt-db.cjs为36/36 PASS,两模式不相加为79个不同场景。一次性DB/存储/浏览器清理完成,无生产访问或云调用。client中以既有BIFF8解释器运行E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:4318 playwright test tests/e2e/system-action-confirmation.spec.ts --grep '回单' --workers=1 --reporter=line,23/23 PASS(桌面及390视口);测试preview已停止。
|
||||
|
||||
本机证据SHA256:receipt-browser-final.log=30442ae9452f29dcd80331f2952bf721fb3d5b7ea1af53e600f203e5413de2e1;receipt-default-final.log=bde4416ec62e0a56c4feccc66d6e477902896e37bfd2787084ecce5b2905e224;receipt-followup-ui-final.log=0c70c6f033af62814ef1504ff998a71c7ebc5797b60ea30b8a010bac259a13e4;receipt-followup-build.log=2ba7c8d9a3f1cb7a545579e6a143d4e7b7199c9b34ebfecb2c35627c21ef6d14。暂存在/tmp,未形成正式冻结发布包;不能沿用R3宣称新增代码已获发布授权。
|
||||
|
||||
下一步:按当前任务路径整理本轮提交范围(排除09-04两文件与统一审批中心文档旧改动),获得明确提交/推送/部署授权后冻结新候选、重新只读核发布前置、部署并由执行端做公网验收。若只批准部分阶段则只做对应阶段;不让员工代跑软件回归。真实复核人/密钥来源/新回单队列验收仍单列,不用本地PASS填平。
|
||||
|
||||
## 2026-09-15 隔离正式认证第一段实施
|
||||
|
||||
在现有test-payment-receipt-db.cjs增加RUN_RECEIPT_REAL_AUTH=1显式模式,默认保留原合成actor夹具。新模式注册真实cookie/auth插件及auth路由,不覆盖authenticate或hasPermission;随机合成密码使用正式hashPassword落一次性数据库,通过POST /api/auth/login获得JWT和refresh cookie后用于既有付款业务HTTP链。伪造原synthetic-finance字符串在新模式下401。无生产.env、真实票据、云调用或外部通知;不用预签token绕过登录。
|
||||
|
||||
执行RUN_ISOLATED_DB_TEST=1 RUN_RECEIPT_REAL_AUTH=1 node scripts/qa/test-payment-receipt-db.cjs,38项通过、数据库容器清理完成。结果authentication=actual_password_login_and_jwt、browserTested=false;真实密码/鉴权到手工核验/独立复核/付款登记与重放拒绝已在同一数据库验证,使用Fastify inject而非浏览器网络,不称真实JWT浏览器全链。随机密码/JWT未输出。源码语法通过,默认模式另做回归。
|
||||
|
||||
当前仅测试工具修改,未改产品源码、未提交推送/部署。剩余实施是可选浏览器模式、真实本地API代理及上传→worker链;本轮通过不能消除浏览器全链缺口,也不改变生产已发布版本8d08d46d。实际入口继续复用本脚本,不扩第三笔产品改动。
|
||||
|
||||
默认模式回归36项通过、清理完成;真实认证38项与默认36项是两个模式,不相加成74个不同场景。git diff检查、node语法及Trellis治理通过。两轮安全回执分别为本机/tmp/receipt-real-auth.json和/tmp/receipt-auth-default-regression.json,尚未形成冻结交审包。
|
||||
|
||||
## 2026-09-15 正式认证浏览器全链准备
|
||||
|
||||
用户同意下一阶段先做隔离验证准备。官方指针仍为09-08,不因注入09-04换任务。此轮只检查本地测试入口并补当前PRD/执行合同,未访问生产、未新调用OCR、未创建测试数据库或运行产品测试。
|
||||
|
||||
已核代码入口:scripts/qa/test-invoice-first-http.cjs已有真实hashPassword、auth插件、auth路由、浏览器及Vite代理;但存在自定义/api/me和辅助API拦截,且ocrProvider默认选择tencent,不原样跑整套脚本冒充本轮无云验证。scripts/qa/test-payment-receipt-db.cjs目前真实DB/Fastify但authenticate是合成actor注入,不能继续称真实JWT链。server/src/app.js导出buildApp并提供startJobs依赖注入,正式auth路由POST /api/auth/login提供密码登录。
|
||||
|
||||
准备采用路径:
|
||||
1. 在现有回单隔离脚本中添加显式可选浏览器模式,保留默认36项DB/HTTP验收;先搭loopback正式app/auth与随机合成账号,不拷贝生产环境、不启动真实通知/ERP或未授权worker。
|
||||
2. 注入外部识别响应仅用于合成样本,复用runReceiptOcrOnce的真实认领/版本/落库流程;不得直接写matched/claim模拟后台完成。是否覆盖pgboss调度另列,手动触发一次worker不算调度端到端。
|
||||
3. Vite代理全部业务API到真实本地Fastify,浏览器实际密码登录、上传、等待匹配并确认登记;不拦截业务API、不改/api/me输出伪造权限。明细和批次等前置业务数据允许由合成夹具建立,哪些前置跳过UI明确记录。
|
||||
4. 先单笔正常链,再错金额/系统失败/切身份/重复交易反例。只有对应脚本完成并实跑才增加“真实JWT+浏览器+DB同链通过”,否则继续保留缺口。
|
||||
5. 清理本轮浏览器、preview/app、DB连接/容器与临时文件,摘要记录exit和cleanup;真实生产单据及历史回单不碰。最终独立复核与提交按既有明确授权边界,不把测试准备当产品再次发布。
|
||||
|
||||
下一实际实现入口为现有test-payment-receipt-db.cjs的可选真实认证浏览器模式,不新建Trellis任务,不混做管理员恢复功能。发现必须改产品合同才重新讨论;既定范围内本地测试实现不反复索要确认。
|
||||
|
||||
## 2026-09-15 发布完成与上线验收
|
||||
|
||||
授权候选8d08d46d16bad2a571e034a6c02ab20801273be4发布命令退出0,build-init/run-init/build-server/build-switch-server均PASS,应用发布及备份合同刷新完成。部署后正式预检pending=none;三份迁移finished=true/rolled_back=false,数据库记录checksum与候选SQL逐一一致。没有执行初始化seed或业务付款。
|
||||
|
||||
公网验收PASS_PUBLIC_READ_ONLY_ACCEPTANCE:ready短SHA与候选一致、首页正常、bundle扫描105份/重点2份/buildMarker PASS/findings0;server及db运行、数据库容器身份与启动时间未变、env文件摘要未变。新增receipt-readiness及manual-verification两个GET接口匿名访问均401;这是权限入口拒绝验证,不是正式JWT业务链。
|
||||
|
||||
00:13:29Z首次队列检查:payment.receipt.ocr唯一队列、cron=*/5 * * * * *、时区Asia/Shanghai,pgboss+jobsEnabled+tencent,已见completed任务1条。两份未归档旧回单仍legacy,无queued/processing;最后15分钟最多2000条日志中tick failure/attention均0。不能因completed就宣称真实回单已消费;本轮没有手动enqueue、上传或发送OCR,调度仅正常空轮询。第二次跨调度观察结果另记。
|
||||
|
||||
00:15:10Z第二次只读观察完成:completed从1增至3,最新完成00:15:08Z,未见其他job状态;旧回单仍legacy两份,受限日志内tick failure/attention仍0。确认调度持续执行空轮询,不推断配置为五秒就实际每五秒产生任务,不把空轮询计作三份真实回单消费。上线技术验收完成,未让员工操作测试;未发起付款或历史回单重传。下一步不再重复部署,后续只处理真实使用中出现的例外与尚未覆盖项。
|
||||
|
||||
证据在artifacts/qa-layout/receipt-release-preconditions:deploy-result.json、public-acceptance.json、runtime-after-deploy.json、migration-acceptance.json、queue-first.json/queue-second.json、anonymous-access.json。R3旧冻结包和独立旧镜像归档保留,验收文档修改未自动提交。
|
||||
|
||||
边界继续保留:真实JWT浏览器DB同链、真实新队列消费、多笔/汇总回单形态及密钥高熵来源不因发布成功变成已验证;真实一份逐笔回单仅证明R3匹配器适配该样本。无待登记对象时不伪造真人流程,未来人工例外按实际参与人核独立资格。新门禁写入数据后旧镜像不具语义兼容,不能自动回退。当前验证是授权发布后的技术只读验收,不宣称整个业务任务归档完成。
|
||||
|
||||
## 2026-09-15 8d08d46d部署授权与执行中
|
||||
|
||||
用户对明确范围回复“是”:部署8d08d46d,执行本批3份附加迁移、启动回单OCR队列,并由执行端上线检查;不代付、不操作历史已付款单据、不自动重传旧回单,异常停止放行。此授权绑定本候选及已披露回退/验证边界,不引用旧发布例外作为放行依据。
|
||||
|
||||
执行前再次确认干净published-main副本及远端main均为8d08d46d;生产仍229867f5,备份未超过24小时且库/附件摘要正确,部署账号当前镜像归档校验通过,余量超过15GiB,runtime/env摘要与归档后状态一致。正式来源/目标/迁移预检通过后进入build-init;existing模式、SYNC_ENV=0,不初始化seed、不重启数据库、不启用额外容器服务。部署脚本完成和上线验收分开记录,不在执行中宣称上线成功。
|
||||
|
||||
执行证据入口artifacts/qa-layout/receipt-release-preconditions/deploy-fresh-preconditions.json、deploy-result.json(仅执行结束形成)。部署输出只保留白名单阶段事件和整体摘要,不落原始敏感输出。若返回失败先回读实际版本与迁移,不自动重跑有副作用阶段或退旧镜像。
|
||||
|
||||
## 2026-09-15 当前线上版本独立镜像归档完成
|
||||
|
||||
用户明确批准仅创建当前线上镜像独立归档并回读。复用既有save-image/read-image操作,限定build 229867f5fd4223d6efadb543a134696f65095763及image sha256:a4824e86baf34271eb32164b0eec1c1ec56f80924f743638eabbd0c7b94b615c,按当前余量检查后写部署账号标准目录的新唯一子目录;旧归档不删、不覆盖。
|
||||
|
||||
2026-09-14T23:49:05Z(北京时间09-15)创建完成:archiveBytes=1119437824,SHA256 e1dc2bf3ac8933bb21013bf39708b9ef7eb560967360b646ffa6a73526b38668。归档config摘要对应预期image,14/14层摘要匹配;目录0700、文件0600,归档独立于Docker存储,但仍是同机副本,不是异机灾备。
|
||||
|
||||
另起SSH连接从磁盘完整回读,于23:49:35Z返回PASS_IMAGE_INDEPENDENT_READBACK,大小/SHA/权限再次通过;未docker load、未恢复演练。归档前后容器身份/启动时间不变,后置server与db均运行,server仍229867f5,未切换镜像、未部署或执行迁移。写后可用33882103808字节(约31.6GiB)。
|
||||
|
||||
证据:artifacts/qa-layout/receipt-release-preconditions/rollback-created.json、rollback-readback.json、runtime-after-archive.json。原先当前版本独立归档缺口已闭合;不等于附加回单证据产生后可随意退旧代码。既有未覆盖项和发布风险边界不取消,部署仍需针对8d08d46d另行批准;正式执行前重核生产/备份时效,不沿用旧授权。
|
||||
|
||||
## 2026-09-15 推送与发布只读预检
|
||||
|
||||
用户明确批准推送8d08d46d16bad2a571e034a6c02ab20801273be4并做发布前预检。远端main原为229867f5,祖先核验后普通快进push成功,ls-remote确认精确目标SHA;未force、未部署。
|
||||
|
||||
只读生产元数据观察2026-09-14T23:42–23:43Z(北京时间09-15早间):生产仍229867f5、容器运行且检查期间未变;数据库/存储/备份文件系统可用32.6GiB、使用率44.7%。最近备份完成2026-09-14T18:30:50Z,约5.2小时,同生产build/image,manifest及数据库/附件大小SHA均匹配;本轮未恢复测试。部署账号不能读取备份,root视角完成验证。
|
||||
|
||||
独立回滚制品:root标准目录ACCESS_UNAVAILABLE,部署账号标准目录NO_MATCHING_ARTIFACT,已有其他旧版本归档,不存在当前build/image匹配记录。结论限定已检查的既有约定目录,不宣称整机任何位置绝无制品;不能用运行镜像缓存或旧版本包替代独立当前版本回滚保障。未创建归档、未清理空间或旧制品。
|
||||
|
||||
正式预检初次在detached干净副本因upstream_missing停在来源校验,未进迁移检查;未绕过校验。改为克隆远端现有main并核HEAD精确等于已推送SHA后重跑,13项聚合检查全通过,来源/目标/DB身份通过,待迁移精确为本批三份,rollbackWindow=OPEN、terminatedRows=0,退出0且明确未进入deploy.sh。此脚本的回滚窗口状态不等于本批附加门禁已能安全退镜像,也不替代独立归档。
|
||||
|
||||
证据在artifacts/qa-layout/receipt-release-preconditions:root-metadata.json、deploy-user-metadata.json、preflight.log(初次失败)、preflight-published.log(最终通过)。检查副本保留用于可追溯发布准备,不擅自删旧副本。
|
||||
|
||||
当前结论:推送完成,正式只读身份/迁移预检通过;完整发布仍待补齐当前线上版本独立回滚归档及其摘要回读,并处理既有密钥来源/真实消费与例外资格等发布边界。下一建议仅创建并验证当前线上镜像的独立归档,需明确授权写入归档;不加载、不切镜像、不写业务、不清理旧包。部署仍另批,生产三份迁移未执行。
|
||||
|
||||
## 2026-09-15 本地提交授权
|
||||
|
||||
用户对33路径一笔本地提交明确回复“是”。提交前实际HEAD为229867f5,真实暂存区为空,R3清单33份中仅本implement.md有已披露的复核/样本/发布准备记录差异,其余32份内容与冻结摘要一致。只按R3 manifest.files逐路径暂存;排除的09-04两份文档及统一审批中心文档不纳入,不提交artifacts或workspace。此次授权不包含推送、部署、归档或更改分支;实际提交SHA以git回执为准,不预先声称成功。
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 报销付款与银行制单业务闭环
|
||||
|
||||
## 2026-09-15 后续收口范围
|
||||
|
||||
用户要求自主继续后续本地任务。本轮完成正式认证浏览器验证后,继续修复回单系统失败横幅与卡片引导矛盾,并将现有受控恢复路径写入操作手册。保持现有布局、权限、付款门禁和状态机;不新增管理员一键重排队或自动重传。失败前提由管理员定位修复,另获原件重传授权后由有上传权限的人员重新上传,旧失败证据保留。无法修复快照等前提时停止,不以重上传承诺恢复成功。
|
||||
|
||||
验收包括三种系统失败在桌面/手机均不引导不可用的核验详情,不出现普通重试/登记旁路;记录回写失败超时例外、恢复边界与真实队列/复核人待核事实。此范围不授权新的生产配置写入、云调用、提交、推送或部署。
|
||||
|
||||
## 2026-09-15 上线后隔离全链验证增量
|
||||
|
||||
用户同意先准备正式登录浏览器全链,复用本任务,不重新部署或让员工测试。已上线8d08d46d不因下述测试准备回退;管理员恢复出口/横幅统一和人员安排仍另按原任务后续阶段实施,本轮不混入产品改动。
|
||||
|
||||
验收:一次性Postgres及本地独立存储、随机合成账号/密码/密钥,通过实际auth路由和JWT cookie进入浏览器;同一浏览器上传合成回单,经真实附件存储和job版本状态流程至核对结果,财务明确登记,在同一数据库验证paid、claim及审计。浏览器不得mock付款/auth/readiness/核验业务API,不得直接注入employee或用预签token代替登录。仅外部腾讯响应可替换为明确标识的合成provider边界,不能声称这证明真实云调用。
|
||||
|
||||
反例至少包括错金额不得登记、系统保存失败没有重试/人工旁路、账号切换隔离及重复交易拒绝;复用既有单测并逐项区分哪些在浏览器全链覆盖。日志不含随机口令/JWT/完整账号或原payload;关闭邮件/企微/ERP等外部副作用、只监听loopback,测试失败仍清理本轮自有资源。真实票据/腾讯调用/生产写入须另批,不自动读取input或.env里的生产配置。
|
||||
|
||||
## 2026-09-13 银行回单自动核对(本地实施及定向验证完成,待整批独立复核与发布)
|
||||
|
||||
用户反馈已完成一次付款及回单上传,暂未发现问题,三张退回报销已重提;此为业务反馈,尚未独立回读生产付款登记状态。既有回单仅附件安全/存在性检查,付款登记比较报销金额与冻结批次明细金额,不核验回单内交易。本增量复用本任务,不重开任务、不追改已付款历史。
|
||||
|
||||
@@ -295,6 +295,9 @@ export function PaymentBatchesPage() {
|
||||
const canUploadReceipt =
|
||||
canEditReceiptAttachments && requiresBankReceipt && receiptAttachmentsQuery.isSuccess;
|
||||
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
|
||||
const hasReceiptSystemFailure = (receiptAttachmentsQuery.data?.items ?? []).some((item) =>
|
||||
!item.archivedAt && ["system_failed", "key_failed", "snapshot_failed"].includes(item.ocrState ?? ""),
|
||||
);
|
||||
const needsBankReceipt =
|
||||
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
|
||||
const receiptReadinessQuery = useQuery({
|
||||
@@ -1032,7 +1035,8 @@ export function PaymentBatchesPage() {
|
||||
</div>
|
||||
{canExecuteSelectedBatch && requiresBankReceipt && activeReceiptCount > 0 && !canRegisterPaymentComplete ? (
|
||||
<div className="space-y-2 rounded-lg border p-3 text-sm" role="status">
|
||||
<p>{receiptReadinessQuery.isFetching ? "正在核对回单…" : receiptReadinessQuery.isError ?
|
||||
<p>{receiptReadinessQuery.isFetching ? "正在核对回单…" : hasReceiptSystemFailure ?
|
||||
"回单尚未满足付款登记条件,存在系统核验故障,请联系管理员排查下方失败原因。修复并获原件重传授权后,可重新上传回单;保留原失败记录,不要直接重复识别或登记付款。" : receiptReadinessQuery.isError ?
|
||||
resolveError(receiptReadinessQuery.error) : "回单尚未满足付款登记条件,请查看核验详情,完成识别、更正或独立复核。"}</p>
|
||||
<Button type="button" size="sm" variant="secondary" disabled={receiptReadinessQuery.isFetching}
|
||||
onClick={() => { void receiptReadinessQuery.refetch(); void receiptAttachmentsQuery.refetch(); }}>
|
||||
|
||||
@@ -1364,6 +1364,8 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
await page.goto(`/payment-batches?id=${paymentBatchId}`);
|
||||
const receipt = page.locator(".detail-evidence-card").filter({ hasText: "合成银行回单.pdf" });
|
||||
await expect(receipt).toContainText("请联系管理员");
|
||||
await expect(page.getByRole("status").filter({ hasText: "回单尚未满足付款登记条件" })).toContainText("存在系统核验故障");
|
||||
await expect(page.getByText("请查看核验详情", { exact: false })).toHaveCount(0);
|
||||
await expect(receipt.getByRole("button", { name: "重新识别", exact: true })).toHaveCount(0);
|
||||
await expect(receipt.getByRole("button", { name: "核验详情", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "登记付款完成", exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
**文档状态**:当前操作手册,用于本地、公网、企业微信和真实 OCR 试运行验证。
|
||||
|
||||
## 银行回单系统失败恢复
|
||||
|
||||
- `system_failed`、`key_failed`、`snapshot_failed`不是普通识别失败。管理员先依据安全原因码排查数据库可用性、密钥配置/版本或付款业务与银行快照;不输出账号、密钥或原始识别响应,不随意生成新密钥替换旧密钥。
|
||||
- 当前没有管理员重排队接口。修复前提后,另获真实原件重传和云识别授权,由有上传权限的经办人从原批次重新上传原件;保留旧失败附件及审计,不直接SQL改状态、不删除失败证据、不批量重试。前提无法修复时停止,重上传不是修复快照或密钥的替代方案。
|
||||
- 新附件重新经过安全检查、OCR、逐笔匹配、交易去重及必要的独立复核。旧失败件不单独阻止已有合格证据的登记;必须以实时readiness和登记事务结果为准,不以上传成功判断付款成功。批次已执行/取消不得借恢复入口重传重付。
|
||||
- `failure_recording_failed`表示连独立失败回写也失败,不能声称失败态已落库。附件可能仍为processing,满15分钟后普通重试可能重新可用;管理员应先修复前提,不能将此例外描述为“所有系统故障绝不会重传”。重新核对按钮只刷新查询,不等于重新识别。
|
||||
- 全人工录入是「原件留存 + 经办录入 + 独立人员复核」,不是机器核实内容一致,更不是银行验真或到账证明。找不到合格独立复核人时停止并由业务负责人安排,不能给通配权限或让本人自审绕过。
|
||||
|
||||
## 日常发布与初始化分离(本地候选,未声明已部署)
|
||||
|
||||
09-09-existing-deploy-seed-separation 将 init 统一为 `npm run deploy:init`:
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs');
|
||||
const { tmpdir } = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
module.exports = async function receiptBrowser({ root, prisma, config, employee, reviewer, manualFixture, serverRequire, passed }) {
|
||||
assert.equal(process.env.RUN_ISOLATED_DB_TEST, '1');
|
||||
assert.ok(!process.argv.some(argument => /^dotenv_config_/i.test(argument)));
|
||||
assert.match(process.env.DATABASE_URL, /^postgresql:\/\/postgres:[^@]+@127\.0\.0\.1:\d+\/postgres$/);
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'receipt-browser-'));
|
||||
const previous = { ...process.env };
|
||||
let app;
|
||||
let browser;
|
||||
let preview;
|
||||
let stage = 'setup';
|
||||
try {
|
||||
const envFile = path.join(directory, 'synthetic.env');
|
||||
writeFileSync(envFile, '', { mode: 0o600 });
|
||||
process.env.DOTENV_CONFIG_PATH = envFile;
|
||||
delete process.env.DOTENV_CONFIG_OVERRIDE;
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (/^(WECOM_|TENCENT_|SMTP_|LIVE_SMOKE_)/.test(key)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, { NODE_ENV: 'test', JWT_SECRET: randomUUID(), AUTH_CODE_PEPPER: randomUUID(),
|
||||
FIELD_ENCRYPTION_KEY: config.fieldEncryptionKey, STORAGE_DIR: directory, STORAGE_DRIVER: 'local',
|
||||
JOBS_ENABLED: 'false', OCR_PROVIDER: 'off', EMAIL_ENABLED: 'false', IM_NOTIFIER_ENABLED: 'false',
|
||||
ERP_SYNC_ENABLED: 'false', SERVE_CLIENT_DIST: 'false', COOKIE_SECURE: 'false', APP_ORIGIN: '',
|
||||
PAYMENT_BATCH_ENABLED: 'true', OPENAPI_DOCS_ENABLED: 'false' });
|
||||
const password = `Synthetic!${randomUUID()}`;
|
||||
await prisma.employee.updateMany({ where: { id: { in: [employee.id, reviewer.id] } }, data: {
|
||||
password_hash: await serverRequire('./src/utils/crypto').hashPassword(password), must_change_password: false,
|
||||
} });
|
||||
const context = await manualFixture('SYN-BROWSER');
|
||||
await prisma.paymentBatch.update({ where: { id: context.batch.id }, data: {
|
||||
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: 'bank',
|
||||
} });
|
||||
await prisma.paymentBatchReceiptAttachment.updateMany({ data: { archived_at: new Date() } });
|
||||
const sourceItems = await prisma.paymentBatchItem.findMany({ where: { payment_batch: { entity_type: 'reimbursement' } } });
|
||||
for (const item of sourceItems) {
|
||||
await prisma.reimbursementForm.upsert({ where: { id: item.entity_id }, update: {}, create: {
|
||||
id: item.entity_id, submitter_id: employee.id, owner_id: employee.id,
|
||||
department_id: employee.department_id, reimbursement_date: new Date(),
|
||||
status: 'approved', total_amount: item.amount,
|
||||
} });
|
||||
}
|
||||
const plugin = serverRequire('fastify-plugin');
|
||||
app = await serverRequire('./src/app').buildApp({
|
||||
prismaPlugin: plugin(async instance => { instance.decorate('prisma', prisma); }),
|
||||
startJobs: async () => {},
|
||||
});
|
||||
app.log.level = 'silent';
|
||||
await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const apiOrigin = `http://127.0.0.1:${app.server.address().port}`;
|
||||
const clientRequire = createRequire(path.join(root, 'client/package.json'));
|
||||
const vite = await import(clientRequire.resolve('vite'));
|
||||
stage = 'preview';
|
||||
preview = await (vite.default ?? vite).preview({ configFile: false, envFile: false, root: path.join(root, 'client'),
|
||||
preview: { host: '127.0.0.1', port: 0, proxy: { '/api': apiOrigin } } });
|
||||
const origin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||
app.config.appOrigin = origin;
|
||||
const { chromium, expect } = clientRequire('@playwright/test');
|
||||
stage = 'browser_launch';
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ serviceWorkers: 'block', viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('response', async response => {
|
||||
if (response.status() >= 400 && response.url().includes('/api/')) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
console.error(JSON.stringify({ status: 'SYNTHETIC_API_DIAGNOSTIC',
|
||||
endpoint: new URL(response.url()).pathname.replace(/[0-9a-f-]{36}/g, '[id]'),
|
||||
httpStatus: response.status(), code: body.error?.code, reason: body.error?.details?.reasonCode }));
|
||||
}
|
||||
});
|
||||
let externalRequests = 0;
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== origin && !['data:', 'blob:'].includes(url.protocol)) {
|
||||
externalRequests += 1;
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
stage = 'login';
|
||||
await page.goto(origin + '/login');
|
||||
await page.getByTestId('login-identifier').fill(employee.email);
|
||||
await page.getByTestId('login-password').fill(password);
|
||||
await page.getByTestId('login-submit').click();
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 20000 });
|
||||
console.error(JSON.stringify({status: 'SYNTHETIC_COOKIE_DIAGNOSTIC',
|
||||
refreshPresent: (await page.context().cookies()).some(cookie => cookie.name === 'refresh_token')}));
|
||||
stage = 'upload';
|
||||
await page.goto(origin + '/payment-batches?id=' + context.batch.id);
|
||||
const document = await serverRequire('pdf-lib').PDFDocument.create();
|
||||
document.addPage().drawText('Synthetic receipt only');
|
||||
await page.getByTestId('payment-batch-receipt-file-input').setInputFiles({
|
||||
name: 'synthetic-receipt.pdf', mimeType: 'application/pdf', buffer: Buffer.from(await document.save()),
|
||||
});
|
||||
await expect.poll(async () => prisma.paymentBatchReceiptAttachment.count({
|
||||
where: { payment_batch_id: context.batch.id, archived_at: null, receipt_ocr_state: 'queued' },
|
||||
})).toBe(1);
|
||||
const { runReceiptOcrOnce } = serverRequire('./src/jobs/paymentReceiptOcrJob');
|
||||
const { normalizeBankSlip } = serverRequire('./src/services/paymentReceiptMatching');
|
||||
let providerCalls = 0;
|
||||
stage = 'worker';
|
||||
const result = await runReceiptOcrOnce({ prisma, config: app.config, recognize: async () => {
|
||||
providerCalls += 1;
|
||||
return { status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1,
|
||||
...normalizeBankSlip(Object.entries({ 小写金额: '10.00', 币别: '人民币', 付款人账号: '000011112222',
|
||||
收款人账号: '000033334444', 单据状态: '交易成功', 凭证号: 'SYN-BROWSER' })
|
||||
.map(([Name, Value]) => ({ Name, Value }))) }] };
|
||||
} });
|
||||
assert.equal(result.status, 'matched');
|
||||
assert.equal(providerCalls, 1);
|
||||
stage = 'register';
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: '登记付款完成', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: /确认/ }).click();
|
||||
await expect.poll(async () => (await prisma.reimbursementForm.findUnique({
|
||||
where: { id: context.batch.items[0].entity_id },
|
||||
})).status).toBe('paid');
|
||||
assert.ok(await prisma.paymentReceiptClaim.findUnique({ where: { item_id: context.batch.items[0].id } }));
|
||||
assert.ok(await prisma.auditLog.count({ where: { action: 'PAYMENT_BATCH_EXECUTED', target_id: context.batch.id } }));
|
||||
assert.equal(externalRequests, 0);
|
||||
passed.push('browser_password_login_upload_worker_match_and_payment_same_database');
|
||||
for (const scenario of ['wrong_amount', 'system_failure', 'duplicate_transaction']) {
|
||||
stage = scenario;
|
||||
const blocked = await manualFixture('SYN-' + scenario);
|
||||
await prisma.paymentBatch.update({ where: { id: blocked.batch.id }, data: {
|
||||
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: 'bank',
|
||||
} });
|
||||
await prisma.paymentBatchReceiptAttachment.update({ where: { id: blocked.attachment.id }, data: { archived_at: new Date() } });
|
||||
await page.goto(origin + '/payment-batches?id=' + blocked.batch.id);
|
||||
await page.getByTestId('payment-batch-receipt-file-input').setInputFiles({
|
||||
name: 'synthetic-' + scenario + '.pdf', mimeType: 'application/pdf', buffer: Buffer.from(await document.save()),
|
||||
});
|
||||
await expect.poll(async () => prisma.paymentBatchReceiptAttachment.count({ where: {
|
||||
payment_batch_id: blocked.batch.id, archived_at: null, receipt_ocr_state: 'queued',
|
||||
} })).toBe(1);
|
||||
const outcome = await runReceiptOcrOnce({ prisma,
|
||||
config: scenario === 'system_failure' ? { ...app.config, fieldEncryptionKey: '' } : app.config,
|
||||
recognize: async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1,
|
||||
...normalizeBankSlip(Object.entries({ 小写金额: scenario === 'wrong_amount' ? '11.00' : '10.00',
|
||||
币别: '人民币', 付款人账号: '000011112222', 收款人账号: '000033334444',
|
||||
单据状态: '交易成功', 凭证号: 'SYN-BROWSER' }).map(([Name, Value]) => ({ Name, Value }))) }] }),
|
||||
});
|
||||
assert.equal(outcome.status, scenario === 'wrong_amount' ? 'needs_review' : scenario === 'system_failure' ? 'key_failed' : 'matched');
|
||||
await page.reload();
|
||||
await expect(page.getByText('回单尚未满足付款登记条件', { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '登记付款完成', exact: true })).toHaveCount(0);
|
||||
if (scenario === 'system_failure') {
|
||||
await expect(page.getByRole('status').filter({ hasText: '回单尚未满足付款登记条件' })).toContainText('存在系统核验故障');
|
||||
await expect(page.getByText('请查看核验详情', { exact: false })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '重新识别', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '核验详情', exact: true })).toHaveCount(0);
|
||||
}
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: blocked.batch.items[0].entity_id } })).status, 'approved');
|
||||
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: blocked.batch.items[0].id } }), 0);
|
||||
passed.push('browser_' + scenario + '_does_not_record_payment');
|
||||
}
|
||||
stage = 'identity_switch';
|
||||
for (const actor of [reviewer, employee]) {
|
||||
await page.getByTestId('topbar-profile-trigger').click();
|
||||
await page.getByRole('menuitem', { name: '退出登录', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByTestId('payment-batch-receipt-file-input')).toHaveCount(0);
|
||||
await page.getByTestId('login-identifier').fill(actor.email);
|
||||
await page.getByTestId('login-password').fill(password);
|
||||
const profileResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/me' && response.status() === 200);
|
||||
await page.getByTestId('login-submit').click();
|
||||
const profile = await (await profileResponse).json();
|
||||
assert.equal(profile.data.id, actor.id);
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
}
|
||||
assert.equal(externalRequests, 0);
|
||||
passed.push('browser_real_logout_reviewer_login_and_finance_relogin');
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ status: 'RECEIPT_BROWSER_FAILED', stage,
|
||||
errorType: error.name, httpStatus: typeof error.actual === 'number' ? error.actual : null }));
|
||||
throw error;
|
||||
} finally {
|
||||
try { if (browser) await browser.close(); }
|
||||
finally {
|
||||
try { if (preview) await new Promise((resolve, reject) => preview.httpServer.close(error => error ? reject(error) : resolve())); }
|
||||
finally {
|
||||
try { if (app) await app.close(); }
|
||||
finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in previous)) delete process.env[key];
|
||||
Object.assign(process.env, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,7 @@ const docker = (args, input) => {
|
||||
|
||||
async function main() {
|
||||
assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1");
|
||||
const realAuthentication = process.env.RUN_RECEIPT_REAL_AUTH === "1";
|
||||
let container;
|
||||
let prisma;
|
||||
let pool;
|
||||
@@ -428,7 +429,15 @@ async function main() {
|
||||
const app = serverRequire("fastify")();
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", { ...config, paymentBatchEnabled: true, appTimezone: "Asia/Shanghai",
|
||||
erpSyncEnabled: false, emailEnabled: false, imNotifierEnabled: false, appOrigin: "http://127.0.0.1" });
|
||||
erpSyncEnabled: false, emailEnabled: false, imNotifierEnabled: false, appOrigin: "http://127.0.0.1",
|
||||
jwtSecret: randomUUID(), accessTokenTtl: "15m", refreshTtlSeconds: 3600,
|
||||
refreshCookieName: "synthetic_refresh", cookieSecure: false,
|
||||
loginFailMaxAttempts: 5, loginFailWindowSeconds: 300, loginLockSeconds: 60 });
|
||||
if (realAuthentication) {
|
||||
await app.register(serverRequire("@fastify/cookie"));
|
||||
await app.register(serverRequire("./src/plugins/auth"));
|
||||
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
|
||||
} else {
|
||||
app.decorate("authenticate", async (request) => {
|
||||
const actorId = request.headers.authorization === "Bearer synthetic-finance" ? employee.id :
|
||||
request.headers.authorization === "Bearer synthetic-reviewer" ? reviewer.id : null;
|
||||
@@ -437,11 +446,12 @@ async function main() {
|
||||
request.employee = { ...actor, roleCodes: actor.roles.map((role) => role.role_code) };
|
||||
});
|
||||
app.decorate("hasPermission", (actor, permission) => hasPermission(resolvePermissionsForRoles(actor.roleCodes), permission));
|
||||
}
|
||||
app.setErrorHandler((error, request, reply) => reply.code(error instanceof AppError ? error.statusCode : 500)
|
||||
.send({ error: { code: error.code ?? "unexpected", details: error instanceof AppError ? error.details : undefined } }));
|
||||
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
|
||||
const financeHeaders = { authorization: "Bearer synthetic-finance" };
|
||||
const reviewerHeaders = { authorization: "Bearer synthetic-reviewer" };
|
||||
let financeHeaders = { authorization: "Bearer synthetic-finance" };
|
||||
let reviewerHeaders = { authorization: "Bearer synthetic-reviewer" };
|
||||
async function readyHttp(context) {
|
||||
await prisma.paymentBatch.update({ where: { id: context.batch.id }, data: {
|
||||
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: "bank",
|
||||
@@ -449,6 +459,30 @@ async function main() {
|
||||
return `/api/payment-batches/${context.batch.id}`;
|
||||
}
|
||||
try {
|
||||
if (realAuthentication) {
|
||||
const loginPassword = `Synthetic!${randomUUID()}`;
|
||||
const passwordHash = await serverRequire("./src/utils/crypto").hashPassword(loginPassword);
|
||||
await prisma.employee.updateMany({ where: { id: { in: [employee.id, reviewer.id] } },
|
||||
data: { password_hash: passwordHash, must_change_password: false } });
|
||||
async function loginActor(actorId) {
|
||||
const actor = await prisma.employee.findUniqueOrThrow({ where: { id: actorId } });
|
||||
const response = await app.inject({ method: "POST", url: "/api/auth/login",
|
||||
headers: { "x-device-fingerprint": `synthetic-${actorId}` },
|
||||
payload: { identifier: actor.email, password: loginPassword } });
|
||||
assert.equal(response.statusCode, 200, "synthetic password login failed");
|
||||
const token = response.json().data.accessToken;
|
||||
assert.equal(typeof token, "string");
|
||||
assert.ok(response.cookies.some(cookie => cookie.name === "synthetic_refresh"));
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
financeHeaders = await loginActor(employee.id);
|
||||
reviewerHeaders = await loginActor(reviewer.id);
|
||||
passed.push("actual_password_login_jwt_and_refresh_cookie");
|
||||
const forged = await app.inject({ method: "GET", url: `/api/payment-batches/${randomUUID()}/receipt-readiness`,
|
||||
headers: { authorization: "Bearer synthetic-finance" } });
|
||||
assert.equal(forged.statusCode, 401);
|
||||
passed.push("synthetic_actor_header_rejected_by_real_authentication");
|
||||
}
|
||||
const httpManual = await manualFixture("SYN-HTTP-ORIGINAL");
|
||||
const httpBase = await readyHttp(httpManual);
|
||||
const httpProposalUrl = `${httpBase}/receipt-attachments/${httpManual.attachment.id}/manual-verification`;
|
||||
@@ -489,6 +523,12 @@ async function main() {
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollback.batch.items[0].entity_id } })).status, "approved");
|
||||
passed.push("real_db_http_source_amount_change_rolls_back_claim_alias_and_payment");
|
||||
} finally { await app.close(); }
|
||||
if (process.env.RUN_RECEIPT_BROWSER === "1") {
|
||||
assert.ok(realAuthentication);
|
||||
await require("./test-payment-receipt-browser.cjs")({
|
||||
root, prisma, config, employee, reviewer, manualFixture, serverRequire, passed,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try { if (prisma) await prisma.$disconnect(); }
|
||||
finally {
|
||||
@@ -504,7 +544,9 @@ async function main() {
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ status: "PASS_ISOLATED_RECEIPT_DB", passed, cleanupComplete: true,
|
||||
productionAccess: false, cloudCalls: false, httpTested: true, authentication: "synthetic_actor_fixture" }));
|
||||
productionAccess: false, cloudCalls: false, httpTested: true,
|
||||
authentication: realAuthentication ? "actual_password_login_and_jwt" : "synthetic_actor_fixture",
|
||||
browserTested: process.env.RUN_RECEIPT_BROWSER === "1" }));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户