diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md index 02b234b4..e05d2afd 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -1,3 +1,53 @@ +## 2026-10-09 N5-P 原借入回单识别预填设计(待批准实施) + +### P-A 现有能力与方案选择 + +对应 PRD P1–P6、AC32–AC39。基线为 `45737ccb`,沿 `CompanyLoansPage` 登记入口及 `CompanyLoan` / `LoanEvidence` 模型实施,不新建凭证中心、银行流水系统或业务草稿表。现有创建后的 `confirmation_status=unconfirmed`、三类附件余额确认及公司代表授权继续使用。 + +| 已检查来源 | 事实与设计影响 | +| --- | --- | +| `client/src/pages/CompanyLoansPage.tsx:176`;`server/src/routes/companyLoans.js:262`、`:314` | 创建与上传分离;新入口需一个有原件的保存动作,不能由浏览器串行两次写入冒充原子成功。 | +| `server/src/services/paymentReceiptDocument.js:101`;`paymentReceiptDocumentWorker.js:3` | 已有受限 PDF 文字读取。抽出或复用受限读取层供本地字段提取,旧 `inspectReceiptDocument` 输出与付款分类语义保持。 | +| `server/src/services/paymentReceiptMatching.js:41`;`paymentReceiptOcr.js:19` | 付款归一化包含金额/账户/编号,但没有到账日期/双方名称,且字段识别依赖腾讯。新增借入专用白名单解析,复用底层客户端及值解析;不改变旧付款输出与判定。 | +| `server/prisma/schema.prisma:3973`、`:4063` | 已有经办请求键、同主体/出借人/原引用唯一约束及附件摘要,可实现本期保存和幂等,不计划迁移或新任务队列。 | +| `server/src/services/crypto/index.js:103`;`purchaseAttachmentPolicy.js` | 复用认证加密及实际字节验证;预填结果只经短期、专用用途的加密上下文往返,不能把完整 OCR 结果塞入可读签名 token。 | + +采用“浏览器暂存所选文件 → 无业务持久化识别 → 核对 → 携同一文件保存”的方案。用户只选一次原件;网络上识别与保存各传一次,换取无需新建临时附件池、清理任务或跨设备草稿。关闭前尚未保存的内容不续存,成功保存的文件仍按原借款附件治理。 + +### P-B 识别合同与最小字段 + +拟增加 `POST /company-loans/incoming-receipt/recognize`,严格 multipart 一份 PDF。授权沿财务角色加 `loan.manage`;涉及员工银行卡比对时追加既有银行卡权限,不授予经理全局借款读取能力。先执行文件扩展名/MIME/实际字节与大小检查,再在事务外执行受限解析:5 MiB、20 页、既有 5 秒/128 MB 工作者上限。损坏、加密不可读或不安全文件不能保存为正常识别附件。 + +1. 本地提取页面文本后,按明确字段标签与版面行关系提取,不使用任意数字猜测。金额保持十进制字符串/最小单位,日期用真实日历校验,不以打印时间、导出时间代到账日期;两个同类值矛盾则留空。标识保留字符串及前导零,流水号和凭证号分别取值,稳定排序选唯一引用。多页重复同一交易可合并;不能证明同一笔时拒绝预填,不只处理首页。 +2. 名称、币种、付款/收款账号用于判断候选及冲突;原始文本仅内存处理。给浏览器的字段是最小业务白名单、遮罩账号、`source=pdf_text|ocr|manual`、逐字段缺项/歧义说明、凭证性质和入账方向提示,不返回完整账号、原文或提供商响应。凭证正式性和成功状态分别表示,识别成功不称为银行验真。 +3. 本地必需字段充分时零云调用;不足时只使用既有已配置银行 OCR,在每份文件的一次受限识别请求内处理必要页,每页最多一次,整请求有总时限,不能随轮询或组件重挂自动重试。复用客户端时读取借入所需日期/名称后立即白名单压缩,不让旧付款归一化丢掉这些字段;不因提供商结果覆盖本地已发现的非最终/失败/多交易限制。OCR关闭或失败保留本地有效字段,并允许财务手工补齐;刷新识别是显式用户动作。 +4. 借入按付款人→个人、收款人→公司判断,不能复用会拒绝贷方的 `paymentDocumentReady`。公司支出方向是相对于所选公司判断,不能仅见票面“借方”就拒绝个人付款方出具的回单。完整名称/唯一关系可建议匹配已有公司与出借人,歧义不默认选第一条;历史付款账号与员工当前收款账号不同只提示核对,不自动刷新或否定历史关系。确认为其他收款主体、非人民币或明确银行失败/处理中/非最终时,此文件不能经新入口保存为有效借入原件。 +5. 初步识别与选择主体后的校验可共享同一上下文,无需为切换出借人再次调用 OCR。服务端以最小加密上下文保存字段/性质供下一请求核对,绑定用途 `loan_incoming_prefill`、经办员工、文件 SHA-256、解析版本、签发时间和到期时间(30 分钟),只经请求体传输且 `no-store`;不放 URL、localStorage 或日志。解密后校验用途/时限/身份,不能借字段加密的完整性省略业务校验。 + +### P-C 确认保存、重复与原件 + +拟增加 `POST /company-loans/from-incoming-receipt`,严格 multipart:同一原件、加密预填上下文、请求键、现有登记字段和明确核对声明。人工改正仅允许本金/日期/引用及用户本来就需填写的登记字段;身份只能选择现有出借人,不接受账户替换、`confirmed` 或银行成功状态。未知结果可人工补填,token中明确矛盾不能被浏览器删字段消除;OCR关闭的人工路径同样需要文件校验和身份绑定。 + +- 保存先重验实际原件、摘要、上下文及登记字段,再校验所选出借人/公司和其版本;要求已查看原件、核对借款性质和主体。`openingOutstandingPrincipal` / `balanceAsOf` 必填且不由识别预填为已核实事实;仍执行本金上限、日期顺序和非未来截止日。 +- 文件写存储放在短事务外。事务内创建借款、`kind=incoming` 的受控附件及两项必要审计;对外只在全部成功后返回。失败只删除本次新写对象;重试读取已成功事务并核验相同字段和文件摘要,不重新上传关联。对象存储与 PG 无分布式事务,进程崩溃可留未引用对象:不将其暴露为成功记录,清理失败留脱敏告警与原存储运维处置边界,不新增通用扫盘/删除服务。 +- 沿用 `(created_by, request_key)` 幂等与 `(legal_entity_id, lender_id, origin_reference)` 唯一约束;请求等价比较必须包含原件摘要。新保存以出借人行锁串行,同主体/出借人已存在相同 `incoming` 摘要时返回已有登记提示,不新建。普通原件追加路径也参加同一重复核对,覆盖新保存与旧上传的竞争,既有历史重复不自动合并/删除。 +- 保持“出借人 → 借款 → 编号”方向,与还款创建一致。已有借款原件上传先只读定位出借人,再锁出借人、锁借款并重验归属;不能在已持借款锁后新增出借人锁。新建持出借人锁后只创建新借款/编号,不再锁或修改已有借款,不与付款批次建立反向依赖。实施前用确定性交错验证该顺序,覆盖原件上传与还款创建;发现与既有约束冲突时先修订设计,不靠加长超时。重复摘要仅证明相同文件;不同扫描件且引用不同时,不能宣称已具备全银行交易去重。 +- 成功仍为余额待核实,原件可按既有用途下载接口读取,审计只留来源、摘要、解析版本和修正字段名等必要元数据,不保存明文 OCR 输入或完整账号。协议/余额核对/调整依据沿原入口,识别不推进授权、余额确认、付款或核销。 + +### P-D 页面与兼容 + +`CompanyLoansPage` 的登记借入弹窗增加“上传回单预填/手工登记”路径,复用原 Dialog/Input/Button 和现有字段。上传处理中、待核对、需人工补充、文件不适用和保存失败均有就地提示;能纠正的字段可修改,识别结果须明确仍待核对。当前缺少 `frontend-design` skill,本阶段仅复用既有组件及样式,不做新视觉裁决。 + +文件只存在当前表单内存,预填结果按身份/文件请求序号隔离;选择新文件显式重置其提取字段,任何迟到返回都不能覆盖新文件或已手改值。识别/保存都传 AbortSignal,卸载/身份变化取消并清理;保持同身份合法401恢复,禁止新身份重放旧写请求。待保存时阻止重复提交,提交结果丢失时以同请求键回读原结果。 + +银行候选匹配不更改员工资料;缺出借人使用现有登记出借人入口。原手工 JSON 登记及材料上传仍可用,不强制所有历史借款都经过 OCR。新入口仅支持 PDF,原图片附件能力和旧付款回单支持范围保持。 + +### P-E 运行边界与回退 + +本期不计划数据库迁移,不增加提供商、生产密钥、全局文件预览或长期识别存储。保存结果使用旧模型,可回退新入口及专用接口后继续按原页面管理,不删除已登记借款或原件;回退仍遵守原关联方模式最低兼容版本限制。 + +本地测试只用合成 PDF/人员和提供商替身。真实云识别、当前银行产品额度与费用边界须在首次获准实样验收时确认;不从发票 OCR 的额度结论推定 BankSlipOCR 可用,也不自动启用服务或修改计费。未知可人工填写是本期明确降级,不以真实材料未调用为由伪报自动识别已验。发布按最终候选门禁和另行部署授权执行。 + ## 2026-10-09 N5 员工出借人选择方案(已批准,已本地实现) 本节对应 PRD 的 AC28–AC31。员工资料已存在于 `Employee`;现有 `loan-lenders/identity-options` 仅提供身份选择,借款登记尚未复用银行卡。目标是让财务选择现有员工并核对资料,服务端建立或复用借款出借人关系,保持现有贷款、授权和付款快照模型。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index 62f690b4..6c39a118 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -1,3 +1,198 @@ +## 2026-10-10 N5-P 第五轮独立复核通过(待提交授权) + +第五轮复核(`independent-review-r5-20261010.md`)结论为 PASS_WITH_NOTES:此前要求修改的问题均可关闭,没有阻断项。复核方独立验证: +- 相关单测 100 项通过; +- 前几轮的 27 个探针,以及新增的 14 项关联与边界检查通过; +- 两份正式回单在本机复测通过,云端调用 0。 + +非阻断观察 N1:OCR 读出无效流水号或凭证号时,值会被拒绝,但字段提示显示为 `missing`(未识别);本地读出同类无效值时显示为 `invalid`。两种情况都不会预填错误值,也不会绕过多页拦截。本轮不改代码,下节“无效编号记 invalid”的说法仅适用于本地文字层。 + +AC39 的独立复核部分已完成。下面几项仍需另行处理或单独授权: +- 提交、最终候选完整门禁、推送和部署; +- 真实回单上的姓名、账号、流水号逐值核对,由财务保存前对照原件完成; +- 员工授权翻页超时(根因未定)。 + +## 2026-10-10 N5-P 第四轮复核修复(第五轮复核已通过) + +第四轮复核(`independent-review-r4-20261010.md`)结论为 CHANGES_REQUESTED,剩 2 处,都涉及多页文件能否确认为同一笔交易。 + +- R4-1 跨页关联: + - 每页分别记录带类型的编号(`pageIds`)。两页共享同类型、同值的编号才算关联;同一页上的流水号和凭证号视为属于同一笔交易。全部页面必须连成一组(`unlinkedPages`),否则拦截为 `pages_unverified`。 + - OCR 读出的页按同一规则加入。已知编号冲突仍判为 `multiple_transactions` 并拦截。 + - 文字相同但编号类型不同(一个是流水号、一个是凭证号),不算关联。 +- R4-2 编号整段校验:`parseReference` 要求整段匹配,不再截取前缀参与比较。本地文字层读到带未知后缀、超长或相同前缀不同后缀的编号时,记为 `invalid`;OCR 读出的编号也做整段校验,无效值被拒绝,但提示为 `missing`(见第五轮 N1)。 + +验证: + +- 复核方三轮探针共 27 个,用当前代码全部符合预期:第四轮 10 个,第三轮 8 个,第二轮 9 个。 +- 单测:借入回单 71 项(新增 8 项)。 +- 变异:新增 3 个,连同 2 个重跑的,共 5 个全部捕获。旧的“页面覆盖”两项变异所针对的代码已被关联逻辑取代、锚点已不存在,已从清单删除。 +- `browser-r7.log`:HTTP 9 项、浏览器 8 项、数据库 9 项通过。 +- `regression-r11.log`:完整回归通过,检查项相加 248,去重 189。 +- 两份真实样本:编号改为整段校验后,流水号仍正常提取,9 个字段齐全,无拦截,云端调用 0,原件不变。 + +仍开放: + +- 员工授权翻页超时根因未定。 +- 待第五轮独立复核。 +- 未提交、未推送、未部署。 + +## 2026-10-10 N5-P 第三轮复核修复(未提交;后续修复见上节) + +第三轮复核(`independent-review-r3-20261010.md`)结论为 CHANGES_REQUESTED,剩 2 项,均属 RR1;RR2、RR3、RR4 已由复核方关闭。 + +- R3-1:一页只有读到能通过解析的流水号或凭证号,才算属于本笔交易。文字层与 OCR 使用同一判定标准:无效值、OCR 返回空,以及只有金额相同,都不能解除“多页无法核实”的限制。单页扫描件仍可人工补填。 +- R3-2:内部保留本地读到的全部流水号和凭证号(`identifiers`),跨来源时按同类编号逐一比较。仅凭证号不同(包括流水号相同的情况)也判为多笔交易。表单预填仍优先使用流水号,这只是显示顺序,不影响比较。 + +验证: + +- 复核方第三轮 8 个探针、第二轮 9 个探针,用当前代码重跑全部符合预期(拦截项已拦截、对照项正常放行)。 +- 单测:借入回单 63 项(新增 6 项);回单分类与 OCR 测试仍通过。 +- 变异:新增 4 个单元变异全部捕获。“文字层只有金额的页面”首轮未捕获,原因是缺少可读的只有金额页面;在生成器中加入“无流水号”选项并补测后已捕获。 +- `browser-r6.log`:HTTP 9 项、浏览器 8 项、数据库 9 项通过。 +- `regression-r10.log`:完整回归通过,检查项相加 248、去重 189。 +- 两份真实样本:9 个字段全部提取,无拦截,云端调用 0,原件不变。 + +仍开放:员工授权翻页超时根因未定(r8–r10 均通过,但没有确定性触发失败分支);待第四轮独立复核;未提交、未推送、未部署。 + +## 2026-10-10 N5-P 第二轮复核修复(未提交;后续修复见上节) + +第二轮复核(`independent-review-r2-20261010.md`)结论为 CHANGES_REQUESTED,共 4 类问题,本节记录修复结果。 + +- RR1 混合页与跨来源交易: + - 按页记录是否读到本页自己的金额、流水号或凭证号;没读到的页单独送一次 OCR(仍受总时限约束、不重试)。 + - 多页文件仍有页面无法核实时,记 `pages_unverified` 并拦截;单页扫描件仍可人工补录。 + - OCR 读到的金额或同类编号与本地不同,记 `multiple_transactions` 并拦截。 +- RR2 乱序说明:凭证限制同时检查原始存储顺序与按位置重建的阅读顺序,结果取并集。共享的付款分类代码不变。 +- RR3 打印日期:顶行无标签日期规则跳过紧跟在“日期/时间”类标签后的日期;`parseDate` 改为整段校验,可带时刻,也接受 8 位数字日期。上一节写“到账日期同样处理”时,这一项其实尚未完成,以本节为准。 +- RR4 前端版本:对话框冻结选择出借人时的身份版本和账户版本。列表刷新后版本变化,会清除两个确认勾选并提示重新核对;保存使用冻结的版本。 + +验证(证据目录同上): + +- 复核方的 9 个解析探针在修复后全部得到正确结果(会拦截的已拦截,日期留空或标为无效)。 +- 单测:借入回单 57 项,加回单分类与 OCR 共 86 项通过。 +- 变异:新增 6 个单元变异全部捕获(`mutations-summary-*.json`)。 +- 浏览器 `browser-r5.log` 共 8 项,新增电脑与手机各 1 项:断网后恢复触发列表重新获取,勾选被清除;重新核对后保存,提交的是新版本,数据库中借款 1 笔。 +- 完整回归 `regression-r9.log` 通过,检查项相加 248、去重 189。 +- 两份真实样本用当前代码复测:9 个字段全部提取,无拦截,云端调用 0,原件摘要不变。 + +仍开放: + +- 员工授权翻页超时根因未定。r8、r9 均通过,但都没有确定性触发失败分支。该项属于原有员工场景,不在 N5-P 改动范围内,保留为开放项。 +- 待第三轮独立复核。 +- 未提交、未推送、未部署。 + +## 2026-10-10 N5-P 复核修复与真实版式适配(未提交;后续修复见上节) + +独立复核(`artifacts/qa-flow/loan-incoming-prefill-20261009/independent-review-20261010.md`)结论为 CHANGES_REQUESTED,4 项 P2 均已复现。本节记录修复结果;下面首轮实现记录中的统计和基线写法有误,以本节为准。 + +### 更正 + +- 实施基线是 `45737ccb`,不是首轮写的 `c1034bbe`。 +- 首轮称 r5 共 247 项,实际是各层检查数组直接相加,含重复,去重后为 188 项。r5 也没有运行新入口的浏览器场景,那 6 项来自单独的 `browser-r2.log`。 + +### 复核问题修复 + +- R1 多笔交易:金额、流水号、凭证号任一出现不同值,即判为多笔交易,本地与 OCR 一致处理,人工补号也无法解除。同一交易跨页重复仍允许。 +- R2 外币:本地发现非人民币时,在调用 OCR 前就拦截;OCR 币种与本地不一致记为 `currency_conflict` 并拦截,不再把已知币种清空成“未知”。 +- R3 OCR 时限:每页发送前检查共同截止时刻,超时后不再发送后续页。 +- R4 金额:整段值必须是一个合法的人民币金额,千分位错误或超过两位小数记为 `invalid`,不再截断成另一个金额。到账日期同样处理。 +- AC37 来源变更:保存时携带所选出借人的身份版本和账户版本,识别后出借人被修改即返回 `loan.version_changed`。 + +### 真实建行版式适配 + +经用户授权,本机只读运行脱敏结构探查:已知标签保留,数字改为 9、字母改为 A、其他汉字改为“某”。不调用云端、不写库、不保存文件,原件摘要前后一致。两份借入回单是“单位客户专用回单”表格版式:PDF 文字顺序与阅读顺序不同,付款人、收款人是竖排栏头,日期在顶行且没有标签,金额分大写和小写,右侧竖排联次说明中也含“收款人”。 + +据此新增以下处理(`PARSER_VERSION` 改为 2): + +- PDF 工作线程按需返回文字位置(付款回单分类不使用,结果不变);按纵坐标归行、行内按横坐标排序;竖排文字不并入横向行。 +- 顶行日期规则;`(小写)` 金额标签。 +- 付款人、收款人栏头须恰为该词且付款人在左。 + +新增同版式的合成生成器 `syntheticCcbIncomingPdf`,内容全部虚构。 + +最终代码下两份真实样本:金额、到账日期、流水号、币种、双方名称及账号全部提取,方向为入账,无拦截项,云端调用 0。具体数值是否正确,仍由财务保存前对照原件核对。 + +### 验证 + +- 单测 46 项(较首轮新增 24 项);回单分类相关测试仍通过。 +- 隔离 HTTP 9 组,新增建行版式识别、主体建议与保存;浏览器电脑/手机 6 组(`browser-r4.log`)。 +- 变异 18 个全部捕获(HTTP 12 个、单元 6 个,`mutations-summary.json`)。其中“行内按横坐标排序”首次未捕获,原因是合成文字同一行的基线完全相同,补充基线偏移用例后已捕获。 +- 借款完整回归 `regression-r8.log` 通过,检查项去重后 189 项(相加为 248)。 +- r6、r7 均在员工出借人浏览器场景的手机端翻页处超时:等待响应在点击完成前超时,未处理的异常导致进程退出,未留截图。r8 改为点击与等待一起处理并设 30 秒点击时限后通过,但当次没有触发失败分支,无法确认是修复生效还是恰好不需翻页,**根因未定**。 +- 工作区另有一处测试调整:原对账浏览器场景固定 `timezoneId: 'UTC'`(见首轮记录)。 + +### 仍开放 + +- 需要再次独立只读复核本节改动(AC39)。 +- 真实样本只验证了字段能提取出来,没有核对数值。真实云端 OCR 未调用。 +- 未提交、未推送、未部署。 + +## 2026-10-10 N5-P 本地实现与验证完成(首轮,未提交;统计与基线见上节更正) + +用户先听取“暂缓”建议后明确要求实施,选择**完整方案**并指定由 Claude 会话实施。基线为本地 `c1034bbe`(线上 `45737ccb`),已执行 `task.py start`。本节记录实际结果;下方原计划保留规划时点。 + +### 实现 + +- 服务端:新增 `loanIncomingReceipt.js`。它复用受限 PDF 文字工作线程(从 `inspectReceiptDocument` 抽出 `extractReceiptTexts`,付款回单分类语义不变),按白名单标签逐行读取金额、记账/交易日期、流水号/凭证号、币种、双方名称与账号;同一字段出现不同值时留空并标为“歧义”。多页同一交易可合并,多笔交易、非最终、失败/处理中、非人民币一律拦截。本地缺必填字段时,才调用一次现有腾讯 BankSlipOCR:每页最多一次、总时限 20 秒、不重试,结果只补空缺,不覆盖本地值,也不能解除限制。 +- 预填上下文用 AES-GCM 字段加密封装,绑定用途、经办人、文件 SHA-256、解析版本和 30 分钟有效期;只经请求体传递,响应 `no-store`,账号只返回遮罩值。 +- 新接口:`POST /company-loans/incoming-receipt/recognize` 只做识别,不写任何数据;`POST /company-loans/from-incoming-receipt` 先在事务外写入原件,再在同一事务内锁出借人、创建借款、创建 `incoming` 原件并写两条审计,失败或幂等重试时删除本次新写的对象。保存时重验收款主体:公司支出或收款方为其他主体的回单拒绝保存;审计只记录字段来源和被更正的字段名。 +- 同一出借人的 `incoming` 原件按摘要唯一:新入口与原有附件上传都检查,原上传路径改为先锁出借人、再锁借款,与还款创建的锁序一致。 +- 前端:新增“上传回单登记借入”入口和 `LoanIncomingReceiptDialog`,复用现有组件。迟到的识别结果不覆盖人工修改或新选的文件;切换账号、卸载时中止请求;原“登记借入”手工入口保持不变。新增 4 个错误码,三处已同步登记。无新增迁移,仍为 82 项。 + +### 验证(证据目录 `artifacts/qa-flow/loan-incoming-prefill-20261009/`) + +- 单元测试:新增 22 项;相关付款回单、报销路由及授权策略合计 349 项通过(BIFF8 需指定 `PAYMENT_ORDER_PYTHON_BIN`,不设时有 2 项报环境不可用)。Prisma 校验、前端类型检查与构建、错误码合同、UI 规则均通过。 +- 隔离 PG HTTP 8 组:识别不落数据、保存后余额待核实且带原件、重试与请求键、跨入口原件重复、上下文绑定(他人、换文件、篡改、过期)、受限/支出/他方收款拦截、无 OCR 时人工补录、审计失败整体回滚并删除对象、并发与新旧入口竞争只成功一个。 +- 浏览器电脑/手机共 6 组(`browser-r2.log`):预填、人工更正、同账号令牌过期只重试一次后成功、受限文件拦截、迟到结果不覆盖新文件、真实切换账号后迟到 401 被中止且不以新身份重发。 +- 变异 10 个全部被测试抓到(`mutations-summary*.json`)。首轮“公司支出”未被抓到:原用例同时触发了“收款主体不符”,已改为只写付款方后单独验证。 +- 借款完整回归 `regression-r5.log`:247 项检查全部通过,含 HTTP 116 组、浏览器 45 组。r1–r4 的失败保留:r1–r3 是原有对账浏览器场景用 UTC 时间填表、浏览器却沿用本机 CST,北京时间 0–8 点运行必然失败,已在该场景显式固定 `timezoneId: 'UTC'`(r3 的补丁实际未生效,r4 才生效);r4 是新场景多建的出借人挤动了员工场景的分页,已把借入回单场景移到完整运行最后。未改任何业务断言。 +- 既有测试素材调整:同一出借人多笔借款原先上传同一份 `incoming` 测试 PDF,现在每笔各自生成唯一内容,其他材料类型不变。 + +### 未完成与边界 + +- **独立复核未做**:实施与自检出自同一会话,不能算独立复核(AC39 保持未勾选),需另行只读复核。 +- **真实建行版式未验证**:解析只在合成的“标签 值”同行版式上验证过。真实借入回单若采用标签与数值不同行的表格版式,本地可能读不出字段:OCR 未启用时转人工填写,启用时会调用云端。两份真实借入回单的本地只读试解析,或真实材料的云端识别,都需另行授权。 +- 未提交、未推送、未部署;未调用真实 OCR,也未访问生产。 + +## 2026-10-09 N5-P 原借入回单识别预填执行计划(规划时点) + +### 当前阶段与授权 + +用户已要求规划,复用本任务,当前仅修改 PRD/design/implement。2026-10-09 在 `main`、本地基线 `45737ccb` 核对 `task.py current --source` 为 none;规划阶段不调用 start,不手写运行时指针。父任务的 `in_progress` 代表历史已批准工作,不代表 N5-P 实施已批准。用户转交上一会话的部署结果已在 PRD Current Status 标记来源,本轮未连接生产重验。 + +无原生 update_plan,以下为本阶段执行清单。inline 模式由主会话实现和检查,不派 implement/check 子代理;稳定 spec 直接按需加载,不把临时计划、日志或本文件登记进 jsonl。 + +### 顺序与验收映射 + +- [x] P0 规划:核对入口、文件策略、PDF 工作者、银行 OCR 字段、数据模型与隐私规则;形成 PRD P1–P6 / AC32–AC39、design P-A–P-E 和本清单。保留原阶段结果;实施批准仍待用户给出。 +- [x] P1 启动:收到本方案实施批准后,官方 `python3 .trellis/scripts/task.py start 09-17-company-loan-principal-repayment`,读回 current 与 phase,再加载 trellis-before-dev。记录批准范围、实际基线及执行位置;若届时 N5 已人工登记,不迁移或重建既有数据。 +- [x] P2 解析与预填(AC32、34、35):在受限文字提取上加借入字段白名单、金额/日期/引用歧义处理及相对公司方向判断;补扫描 PDF 的受控 OCR 适配及关闭/失败降级。复用底层,保留旧付款凭证分类和归一化结果。完成正常、非最终/失败/支出、多交易、格式/字节限制、OCR 零调用/有界调用的单元反例。 +- [x] P3 保存合同(AC33、36、37、38):添加无业务持久化识别接口与带同一原件的原子登记接口;实现专用加密上下文、身份/文件/时效绑定、最终主体重验、请求键及摘要去重、旧上传竞争和失败补偿。审计与业务同事务;保存不确认余额。扩展现有真实隔离 HTTP/PG fixture,证明两种顺序、故障回滚和旧路径兼容。 +- [x] P4 电脑手机入口(AC32、33、35、37):沿原弹窗复用组件实现上传、识别预填、人工更正和一次保存;保留手工登记。覆盖换文件、取消、失败重试、手改保护、来源缺项、迟到401换账号及同身份重试。用户只需选择一次文件,保存后原件已在该笔材料中。 +- [ ] P5 整体验证与只读复核(AC38、39):运行最小完整检查集和原借款专项回归,按变化范围覆盖共享付款解析。权限与保存流程变化需要独立只读复核;本会话 inline 限制下不派检查代理,准备精确交审范围并标为待外部独立复核,不能沿用 AC28–AC31 的复核结论。有可用且合规的独立只读会话后接收实际结果。 +- [ ] P6 文档与交接:按实际证据更新各 AC、PRD 状态及本清单;经 trellis-update-spec 判断后补既有借款/附件规约,不提前把提案写成已生效事实。报告本地实现、独立复核、实样/OCR及公网状态。提交、推送、部署、真实业务与真实材料云识别分别按明确授权办理。 + +### 预计修改范围 + +- Backend:`server/src/routes/companyLoans.js`、`server/src/services/companyLoans.js`,新增借入识别/保存专用服务;必要时对 `paymentReceiptDocument.js` / `paymentReceiptDocumentWorker.js` / `paymentReceiptOcr.js` 做最小底层提取复用。现有 `paymentDocumentReady` 的付款方向、成功及核销规则不改变。错误理由新增时同步注册、客户端映射及既有合同测试。 +- Frontend:`client/src/pages/CompanyLoansPage.tsx`、`client/src/api/companyLoans.ts`,按可读性抽出与借入登记直接相关的弹窗组件;不重构整页或员工档案。 +- Tests:现有 `scripts/qa/test-company-loan-db.cjs`、HTTP/browser fixture 接入新增合成场景;共享解析测试与新借入服务测试按实际职责分文件。新增代码文件名在实施时固定,本计划不预称其已经存在。 +- 不改 schema/migrations、生产配置、付款状态机、公司授权规则或既有员工资料。若证据证明必须改这些边界,先补风险/兼容设计并取得范围批准。 + +### 最小验证集(实施后执行) + +1. 后端改动 `node --check`;`cd server && npm run prisma:validate`;运行新增借入服务测试及已有 `tests/unit/services/paymentReceiptDocument.test.js`、`paymentReceiptOcr.test.js`,若碰共享归一化再覆盖对应消费者。使用项目 Vitest 实際脚本,不能以一份针对性测试冒称全量通过。 +2. `cd client && npm run typecheck && npm run build`;错误理由/UI合同沿现有 `check-error-reason-contract.mjs`、`check-ui-design-rules.mjs` 按影响执行。 +3. `RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`:仅新建一次性数据库、合成身份/回单,沿现有受控 Python/浏览器缓存环境;保留原借款/员工/关联方场景。执行前确认适用环境,禁止指向本地业务库或生产库;清理只针对本轮 owner。 +4. 必须有确定性交错:重复保存、同请求不同文件、相同原件不同请求、识别后出借人资料变更、旧上传与新保存、审计失败/存储失败。读取原件哈希、借款/附件/审计数量及权限结果,不只断言 HTTP 200。必要的权限/绑定守卫做少量定向变异,环境失败不算捕获。 +5. 浏览器电脑/手机各实际完成上传预填、人工修正、保存后附件与待核实余额;无 OCR 路径可完成手工登记。覆盖多笔/非最终拒绝、迟到响应与换账号取消,且保存未触发余额确认/授权/付款。合成 PDF 和 provider 替身明确标记,不调用真实云端或保留真实银行资料。 +6. 任务文档检查:`python3 .trellis/scripts/task.py validate 09-17-company-loan-principal-repayment`、`node scripts/qa/check-trellis-governance.mjs`、`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-sensitive-content.mjs`、`git diff --check`。未改历史 Issue CSV,不新建 CSV 执行合同。 + +### 规划交付检查 + +2026-10-09 规划文档检查:任务 validate、Trellis 治理、流程合同 baseline、文档索引、敏感内容扫描及 `git diff --check` 均通过。文档索引仍有既有 22 份未登记文件的 1 条汇总警告;inline 可选的 implement/check jsonl 缺失按规则跳过,不宣称注入已配置。新增 P1–P6、AC32–AC39 与设计/清单已收敛核对,现有历史验收和开放项保留;只有本阶段实施批准待取得。没有运行产品测试、调用真实 OCR 或实施/部署;不把本节计划命令当作已执行证据。 + ## 2026-10-09 N5 员工出借人选择调整(本地实现与验证完成,未提交) 用户提出从现有员工选择出借人并复用资料,避免重复手录。已只读核对员工主档、敏感银行卡维护接口、借款登记/身份/账户接口及电脑手机共用页面;实现基线为本地 `c1034bbe`,线上仍为 `beb9f02c`。用户已明确确认方案,本轮接续本地实现及针对性验证;不连接生产、不代录真实数据,提交、推送和部署不在本轮授权内。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index 78dd8aee..f037fb7a 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -1,5 +1,28 @@ # 公司借款台账与本金归还 +## 2026-10-09 N5-P 原借入回单识别预填(2026-10-10 第五轮独立复核通过,待提交授权与最终门禁) + +### 目标与依据 + +用户在办理 N5 时提出先识别凭证或流水、再自动填写,并明确要求“规划改进任务”。本阶段复用当前借款任务,减少已持有原借入回单时重复录入金额、日期、凭据号及再次上传同一文件的操作。此次仅授权规划;原任务 `in_progress` 和此前员工选择功能的实施授权不代表本阶段已获实施批准。 + +当前页面先手填登记借入、保存后再上传材料(`client/src/pages/CompanyLoansPage.tsx:176`、`:121`);服务端创建借款和上传凭据是分开的动作(`server/src/routes/companyLoans.js:262`、`:314`)。现有本地 PDF 工作者能读取文字,但对外只返回凭证分类;现有银行 OCR 归一化未包含到账日期和双方名称(`server/src/services/paymentReceiptDocument.js:101`、`server/src/services/paymentReceiptMatching.js:41`)。本阶段需要新增借入预填合同,不能把现有付款核验通过条件直接用于借入。 + +### 范围与要求 + +- **P1 单笔原件优先**:登记借入增加“上传原借入回单”路径,一次处理一份、仅含一笔交易的银行回单 PDF,包括扫描 PDF;新识别入口上限 5 MiB、20 页,多页必须属于同一交易。保留手工登记和既有附件能力。默认“历史借入登记”,财务明确确认登记性质;文件日期不能自行产生当天新增现金流。 +- **P2 字段预填**:识别原始本金、实际到账日期、原凭据唯一引用、人民币币种与付款/收款方摘要。可靠字段预填,缺失或歧义字段留空并说明;流水号与凭证号分别辨识,优先稳定流水号,无则使用明确凭证号,不以文件名或随机编号补造。公司和出借人只从已有记录中建议匹配并由财务确认;没有出借人登记时沿已实现的员工选择入口办理,不依据回单新建员工或替换收款账户。 +- **P3 识别与业务确认分开**:回单只支持证明历史收款,不推断借款性质、未还本金、余额核对截止日、代账确认或公司代表授权。财务补充余额字段并明确核对原件及借款性质后,才能保存为“余额待核实”。余额确认仍要求三类材料及代账引用;关联审批、在途控制、还本与核销规则保持现有合同。 +- **P4 降级与方向**:可读 PDF 优先本地提取;文字缺失或必要字段不足时,在现有银行 OCR 配置与调用边界允许的条件下补充识别。关闭、不可用、超时或额度不足时显示人工核对入口,不伪造字段、不切换提供商、不自动反复调用。公司入账回单可用于借入;已知公司支出、失败/处理中、非最终回执、多交易文件或非人民币不作为此路径的有效借入回单。无法判断与已知矛盾分别显示,未知可核对补录,明确矛盾需更换材料。 +- **P5 核对后保存一次**:识别本身不创建借款、出借人、付款或正式业务附件。确认保存时,借款与其 `incoming` 原件关联一并成立,原件不要求用户再次选择;附件或审计失败不留下可用的半条借款。双击、重试、重复引用、已有相同原件和并发重复需有明确结果。未保存即取消、换账号或离开时清除预填内容;本期不提供跨设备或关闭后续填草稿。 +- **P6 保留人工修改与来源**:允许核对后改正预填字段,迟到结果不能覆盖人工已改字段或新选择的文件。保存留存识别方式、原件摘要、修正字段及实际经办审计,不把原始票面/OCR payload或完整银行账号写入日志、任务或浏览器长期存储。既有材料下载权限、用途与审计继续适用。 + +### 本阶段不包含 + +整份银行流水/Excel 批量导入、多交易拆分、自动建立借还对应、自动会计凭证、银行验真、自动确认余额/授权/付款、新银行或 OCR 提供商、图片直传识别、历史记录批量重识别。既有图片附件上传不受影响。规划和本地合成验收不包含真实票据调用云 OCR、生产配置、生产录入或发布。 + +验收新增 AC32–AC39,技术合同及执行清单分别见 design、implement 顶部 N5-P。以上为待批准的完整提案,暂无需单独澄清的阻塞问题;用户批准后才进入实施。 + ## 2026-10-09 N5 员工出借人选择调整(本地验证完成,未发布) 用户要求员工出借人从已有员工中选择,复用系统中已维护的资料,不重复录入姓名和银行卡。已核对:员工主档存在姓名、公司关联、银行机构及加密账号;原借款入口要求手填,再另行核实员工关联。用户已确认本方案,授权本地实现与验证;不包含提交、推送、部署或真实业务录入。 @@ -197,6 +220,15 @@ R3 的模式同时作用于付款审批、提交清单、成功/失败归属、 - [x] AC30(重复与版本,本地验收完成):重复或并发选择同公司同员工复用明确的已有登记,不产生新员工或无声合并历史记录;预览至确认间来源变更时要求重新核对。已有出借人刷新保持既有锁序、版本、在途限制和审计;员工主档变化不改写已批准付款、已发生银行事实或本金。 - [x] AC31(办理验证,本地验收完成):合成数据贯通选择员工、登记历史借入、确认余额、登记公司授权及经理确认;电脑/手机入口和缺资料提示可用。验证来源变更、并发重复、无权限和旧快照保持的反例,不用真实员工资料或付款作为开发测试。 +- [x] AC32(单笔预填):合成可读入账 PDF 正确预填金额、日期和唯一引用,选择公司/出借人后可核对保存;扫描 PDF 的受控 OCR 返回同样合同。文件中有多笔交易、歧义金额/日期或缺字段时,不取第一笔、不取最大金额、不补造值。 +- [x] AC33(确认边界):识别和退出不产生业务借款或正式附件;保存后仅为余额待核实且带原件。未还本金、截止日、借款性质由财务确认,缺协议/余额核对材料时不能确认余额或申请还本,不生成付款或改写公司授权。 +- [x] AC34(主体与方向):公司入账可预填,公司支出、明确失败/处理中、非最终回执、非人民币及多交易文件受阻;付款方同名、旧银行账号或主体不唯一时只提示人工核对,不新增身份、不更改员工/出借人账户,不放宽现有还款回单规则。 +- [x] AC35(失败可继续):OCR 关闭、缺配置、超时、额度不足、字段冲突和本地无法提取文字均有可理解结果;未知可人工补录,已知矛盾不能靠普通确认消除。断言无虚构字段、无自动重试风暴,充分可读 PDF 不调用云 OCR。 +- [x] AC36(保存一致性):保存成功同时存在一笔借款及对应 `incoming` 原件;相同请求重试仅返回原结果,换文件/字段复用请求键被拒;同公司同出借人的重复引用、相同原件及并发新入口保存不会生成重复可用借款。存储、附件或审计失败时正确补偿,不虚报成功。 +- [x] AC37(身份与原件绑定):无财务权限、跨身份结果复用、令牌过期/篡改、确认前换文件及来源变更受阻;电脑/手机换文件、卸载及同文档换账号时取消旧请求,迟到响应不覆盖新表单或以新身份重发保存;保留同身份正常会话恢复。 +- [x] AC38(原流程兼容与隐私):原手工登记、员工关联、非员工登记、三类材料余额确认、关联授权与还款路径仍通过相关回归;原件下载沿既有权限/用途/审计,无完整账号或原始识别 payload外泄。PDF/文件不安全或超限在持久化前拒绝。 +- [x] AC39(证据边界;第五轮独立复核 PASS_WITH_NOTES,真实样本仅确认字段已提取、未逐值核对,云端 OCR 与发布另行验收):完成单元、真实隔离 HTTP/数据库及电脑/手机浏览器验证,覆盖失败、并发和清理;完成涉及入口权限/保存流程的独立只读复核,受 inline 限制时明确待独立复核,不冒用旧结论。真实样本与云 OCR 可用性、公开发布分别验收,不以合成结果勾 AC5 或 N5/N6 实际业务完成。 + ## Out of Scope - 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、一般退款或债务冲销;仅保留本任务已定义的超额还款实际追回,不做冲抵后续本金。 @@ -210,9 +242,9 @@ R3 的模式同时作用于付款审批、提交清单、成功/失败归属、 ## Current Status -N5 办理前新增“员工出借人从已有员工选择并复用资料”的调整要求,方案已列于本任务顶部,尚待批准实施。线上仍为原登记入口;本轮没有真实业务登记,也不重复索取已确认的借款事实。 +当前工作为顶部 N5-P“原借入回单识别预填”的规划,PRD、设计及执行清单已形成,待用户批准实施。本轮没有修改产品代码、启动任务执行或进行真实业务登记;父任务继续保持 `in_progress`,不把此前授权套用于本阶段。 -当前线上及 `origin/main` 为 `beb9f02c`;N3 独立复核和 N4 本次发布检查已完成,实际发布过程及证据见 implement 顶部。完整门禁为 82 迁移、服务端 4558 通过/1 项既有可选跳过、浏览器 372 全通过且零失败/跳过/重试;生产迁移、部署后复核、公网基础检查及合成 PDF 解析已核对。本轮只更新任务文档,未重跑应用测试或操作生产。 +员工选择阶段 AC28–AC31 已本地验收。用户在上一会话回报 `45737ccb` 已推送部署、公网检查正常、生产仍为 82 迁移,并明确尚未在线上登录办理新入口;本会话只核对本地版本和转交记录,未重新连接生产或复跑发布检查。下方及前述 `beb9f02c`、“未发布”等记录保留其历史时点,不能作为 N5-P 的验证证据。 整体任务保持 `in_progress`。真实 OA 台账及关联授权尚未登记,首笔真实还款的网银提交、正式回单、核销及会计交接尚未验收;AC5 及未覆盖的真实材料边界继续开放。无需重新索取已确认的借款事实或要求技术员工参与;真实录入和付款仍按各自授权执行,不从未还本金推定本次全额支付。 diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts index 0c5326fb..f6df7a1b 100644 --- a/client/src/api/companyLoans.ts +++ b/client/src/api/companyLoans.ts @@ -73,6 +73,30 @@ export function createLoanRepayment(body: { requestKey: string; lenderId: string allocations: Array<{ loanId: string; amount: string }> }) { return saveLoanAction<{ item: PaymentBatchDetail }>("/loan-repayments", body); } +export type IncomingReceiptPrefill = { + context: string; expiresAt: string; ocr: "not_needed" | "used" | "unavailable" | "failed"; pageCount: number; + documentKind: "bank_receipt" | "unknown"; direction: "credit" | "debit" | null; + blocks: string[]; warnings: string[]; + fieldIssues: Partial>; + sources: Partial>; + fields: { originalPrincipal: string | null; receivedDate: string | null; originReference: string | null; + referenceKind: "serial" | "voucher" | null; currency: string | null; payerName: string | null; payerAccountMasked: string | null; + payeeName: string | null; payeeAccountMasked: string | null }; + suggestions: { legalEntityId: string | null; lenderId: string | null }; +}; +export function recognizeIncomingReceipt(file: File, signal?: AbortSignal) { + const body = new FormData(); body.append("file", file); + return apiRequestData("/api/company-loans/incoming-receipt/recognize", { method: "POST", body, signal }); +} +export function saveLoanFromIncomingReceipt(file: File, payload: { + context: string; requestKey: string; lenderId: string; expectedLenderIdentityVersion: number; expectedLenderAccountVersion: number; + originReference: string; originalPrincipal: string; + openingOutstandingPrincipal: string; receivedDate: string; balanceAsOf: string; historicalEntry: boolean; + originalChecked: true; natureConfirmed: true; +}, signal?: AbortSignal) { + const body = new FormData(); body.append("payload", JSON.stringify(payload)); body.append("file", file); + return apiRequestData<{ item: CompanyLoan; created: boolean }>("/api/company-loans/from-incoming-receipt", { method: "POST", body, signal }); +} export function uploadLoanEvidence(id: string, kind: string, file: File) { const body = new FormData(); body.append("file", file); return apiRequestData(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body }); diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index b572f563..ab383d3f 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -1,4 +1,8 @@ export const ERROR_REASON_MESSAGES: Record = { + "loan.incoming_receipt_invalid": "请上传 5MB 以内、不超过 20 页的单份 PDF 原借入回单;文件损坏或无法安全读取时请改用手工登记。", + "loan.incoming_receipt_blocked": "该回单不能作为本笔借入原件:可能是非最终回执、失败或处理中、公司支出、多笔交易、非人民币或收款主体不符。请更换正式入账回单或改用手工登记。", + "loan.incoming_receipt_context_invalid": "识别结果已过期,或与当前账号、所选文件不一致,请重新上传并识别。", + "loan.incoming_receipt_duplicate": "该原件已用于本出借人的其他借款登记,不能重复登记;请核对已有借款。", "loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。", "loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。", "loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。", diff --git a/client/src/components/common/LoanIncomingReceiptDialog.tsx b/client/src/components/common/LoanIncomingReceiptDialog.tsx new file mode 100644 index 00000000..23e7d65b --- /dev/null +++ b/client/src/components/common/LoanIncomingReceiptDialog.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "../../auth/AuthContext"; +import { recognizeIncomingReceipt, saveLoanFromIncomingReceipt, type IncomingReceiptPrefill, type LoanLender } from "../../api/companyLoans"; +import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; + +const selectClass = "min-h-11 min-w-0 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm"; +const moneyPattern = "[0-9]{1,12}(\\.[0-9]{1,2})?"; +const blockLabels: Record = { + non_final: "回单载明不作为最终依据", transaction_failed: "回单显示交易失败或已撤销", transaction_pending: "回单显示交易仍在处理中", + multiple_transactions: "文件包含多笔交易或多张回单(金额、流水号或凭证号不一致)", non_cny: "币种不是人民币", + currency_conflict: "回单中的币种互相矛盾", pages_unverified: "多页文件中有页面无法确认属于同一笔交易,请上传单笔回单", +}; +const warningLabels: Record = { + text_unavailable: "未能读取 PDF 文字,可能是扫描件", direction_ambiguous: "收付方向无法判断", + company_not_unique: "收款方名称对应多个公司", lender_not_unique: "付款方名称对应多个出借人", + payer_account_differs: "付款账号与出借人当前收款账号不同,请核对是否为原借入账户", +}; +const ocrLabels: Record = { + not_needed: "已从 PDF 文字读取", used: "已结合银行回单识别补充", unavailable: "银行回单识别未启用,请对照原件填写", failed: "银行回单识别未完成,请对照原件填写", +}; +type Field = "principal" | "received" | "origin"; +type Values = Record; +const empty: Values = { principal: "", received: "", origin: "", opening: "", asOf: "", lender: "" }; + +// One original file per registration: recognition proposes values, finance checks +// them against the original, and the same file is saved with the loan in one step. +export function LoanIncomingReceiptDialog({ lenders, onClose, onManual, onSaved }: { + lenders: LoanLender[]; onClose: () => void; onManual: () => void; onSaved: () => void; +}) { + const { me } = useAuth(); + const cache = useQueryClient(); + const [file, setFile] = useState(null); + const [prefill, setPrefill] = useState(null); + const [values, setValues] = useState(empty); + const [edited, setEdited] = useState>(new Set()); + const editedRef = useRef(edited); editedRef.current = edited; + const [historical, setHistorical] = useState(true); + const [originalChecked, setOriginalChecked] = useState(false); + const [natureConfirmed, setNatureConfirmed] = useState(false); + const [requestKey, setRequestKey] = useState(() => crypto.randomUUID()); + // The lender version finance checked is frozen; a refreshed list never + // silently replaces it. A change clears the checks and asks for a new review. + const [checkedLender, setCheckedLender] = useState<{ id: string; identityVersion: number; accountVersion: number } | null>(null); + const [lenderChanged, setLenderChanged] = useState(false); + const sequence = useRef(0); + const alive = useRef(true); + const pending = useRef(null); + const actor = useRef(me?.id); actor.current = me?.id; + useEffect(() => { + alive.current = true; + return () => { alive.current = false; pending.current?.abort(); }; + }, [me?.id]); + const listed = lenders.find((l) => l.id === values.lender); + useEffect(() => { + if (!listed) { setCheckedLender(null); return; } + if (!checkedLender || checkedLender.id !== listed.id) { + setCheckedLender({ id: listed.id, identityVersion: listed.identityVersion, accountVersion: listed.accountVersion }); return; + } + if (checkedLender.identityVersion !== listed.identityVersion || checkedLender.accountVersion !== listed.accountVersion) { + setCheckedLender({ id: listed.id, identityVersion: listed.identityVersion, accountVersion: listed.accountVersion }); + setOriginalChecked(false); setNatureConfirmed(false); setLenderChanged(true); + } + }, [listed, checkedLender]); + const current = (seq: number, actorId: string | undefined) => alive.current && sequence.current === seq && actor.current === actorId; + + const recognition = useMutation({ mutationFn: async ({ selected, seq }: { selected: File; seq: number }) => { + const actorId = me?.id; + const controller = new AbortController(); pending.current?.abort(); pending.current = controller; + try { + const result = await recognizeIncomingReceipt(selected, controller.signal); + if (!current(seq, actorId)) return; + setPrefill(result); + // A late result never overwrites what finance already typed for this file. + setValues((previous) => { + const next = { ...previous }; + const fill = (key: keyof Values, value: string | null | undefined) => { if (value && !editedRef.current.has(key)) next[key] = value; }; + fill("principal", result.fields.originalPrincipal); fill("received", result.fields.receivedDate); + fill("origin", result.fields.originReference); + if (result.suggestions.lenderId && lenders.some((l) => l.id === result.suggestions.lenderId)) fill("lender", result.suggestions.lenderId); + return next; + }); + } finally { if (pending.current === controller) pending.current = null; } + } }); + + const save = useMutation({ mutationFn: async () => { + const lender = checkedLender; + if (!file || !prefill || prefill.blocks.length || !lender || lender.id !== values.lender) throw new Error("请先上传可用的原借入回单并完成识别。"); + const actorId = me?.id; const seq = sequence.current; + const controller = new AbortController(); pending.current = controller; + try { + await saveLoanFromIncomingReceipt(file, { + context: prefill.context, requestKey, lenderId: lender.id, expectedLenderIdentityVersion: lender.identityVersion, + expectedLenderAccountVersion: lender.accountVersion, originReference: values.origin.trim(), + originalPrincipal: values.principal.trim(), openingOutstandingPrincipal: values.opening.trim(), + receivedDate: values.received, balanceAsOf: values.asOf, historicalEntry: historical, originalChecked: true, natureConfirmed: true, + }, controller.signal); + if (!current(seq, actorId)) return; + await Promise.all(["company-loans", "loan-lenders"].map((key) => cache.invalidateQueries({ queryKey: [key, actorId] }))); + if (current(seq, actorId)) onSaved(); + } finally { if (pending.current === controller) pending.current = null; } + } }); + + function choose(selected: File | null) { + sequence.current += 1; + pending.current?.abort(); + // A new file starts a new registration: its values, checks and request key reset. + setFile(selected); setPrefill(null); setValues((previous) => ({ ...empty, opening: previous.opening, asOf: previous.asOf })); + setEdited(new Set()); setOriginalChecked(false); setNatureConfirmed(false); setRequestKey(crypto.randomUUID()); + recognition.reset(); save.reset(); + if (selected) recognition.mutate({ selected, seq: sequence.current }); + } + function change(key: keyof Values, value: string) { + if (key === "lender") { setLenderChanged(false); setOriginalChecked(false); setNatureConfirmed(false); } + setValues((previous) => ({ ...previous, [key]: value })); + setEdited((previous) => new Set(previous).add(key)); + } + function submit(event: FormEvent) { event.preventDefault(); save.mutate(); } + + const busy = recognition.isPending || save.isPending; + const blocked = Boolean(prefill?.blocks.length); + const lenderChoice = lenders.find((l) => l.id === values.lender); + const issue = (key: string) => prefill?.fieldIssues[key]; + const hint = (key: string, field: Field) => { + if (!prefill) return null; + if (issue(key) === "ambiguous") return "原件中存在多个不同值,未预填,请对照原件填写"; + if (issue(key) === "missing") return "未识别,请对照原件填写"; + if (issue(key) === "invalid") return "原件中的值格式无法确认,未预填,请对照原件填写"; + if (edited.has(field)) return "已人工修改"; + return prefill.sources[key] === "ocr" ? "来自银行回单识别,请核对" : "来自 PDF 文字,请核对"; + }; + const error = save.error ?? recognition.error; + return { if (!open && !busy) onClose(); }}> + + 上传回单登记借入 + 上传一份原借入银行回单 PDF,系统预填金额、到账日期和凭据号,请对照原件核对。保存后借款为“余额待核实”,原件自动归入该笔借款材料。 + +
+
+ + {recognition.isPending ?

正在识别回单…

: null} + {error ?

{resolveErrorMessage(error)}

: null} + {prefill ?
+

{ocrLabels[prefill.ocr]}。识别结果不是银行验真,须以原件为准。

+ {blocked ?

该文件不能作为原借入回单:{prefill.blocks.map((b) => blockLabels[b] ?? "回单存在限制").join(";")}。请更换正式入账回单,或改用手工登记。

: null} + {prefill.warnings.length ?

{prefill.warnings.map((w) => warningLabels[w] ?? "请人工核对").join(";")}。

: null} +
+
付款方:
{prefill.fields.payerName ?? "未识别"} {prefill.fields.payerAccountMasked ?? ""}
+
收款方:
{prefill.fields.payeeName ?? "未识别"} {prefill.fields.payeeAccountMasked ?? ""}
+
收付方向:
{prefill.direction === "credit" ? "入账(贷方)" : prefill.direction === "debit" ? "付款(借方),请核对付款方是否为出借人" : "未识别"}
+
+
: null} + {prefill && !blocked ? <> + + {lenderChanged ?

所选出借人的资料已更新,请重新核对后再勾选确认。

: null} + {!values.lender ?

没有对应出借人时,请先用“登记出借人”从员工选择或登记,再回来上传。

: null} + {lenderChoice && prefill.fields.payeeName && prefill.fields.payeeName !== lenderChoice.legalEntityName + ?

回单收款方与所选出借人的借款公司不一致,保存时将被拒绝。

: null} + {([["principal", "amount", "原始本金", "decimal"], ["received", "receivedDate", "实际到账日期", "date"], ["origin", "reference", "原借入凭据唯一引用", "text"]] as const) + .map(([field, key, label, kind]) => )} + + + + + + : null} + + +
+
+
+
; +} diff --git a/client/src/pages/CompanyLoansPage.tsx b/client/src/pages/CompanyLoansPage.tsx index e1dc7253..a7280e30 100644 --- a/client/src/pages/CompanyLoansPage.tsx +++ b/client/src/pages/CompanyLoansPage.tsx @@ -9,6 +9,7 @@ import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoan fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans"; import { LoanRelatedAuthorizations } from "../components/common/LoanRelatedAuthorizations"; import { LoanEmployeeLenderDialog } from "../components/common/LoanEmployeeLenderDialog"; +import { LoanIncomingReceiptDialog } from "../components/common/LoanIncomingReceiptDialog"; import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -21,7 +22,7 @@ import { downloadBlob } from "../lib/download"; const evidenceLabels: Record = { agreement: "协议或借款确认书", incoming: "原借入凭据", accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" }; const stateLabels: Record = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" }; -type Mode = "lender" | "employeeLender" | "employeeRefresh" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null; +type Mode = "lender" | "employeeLender" | "employeeRefresh" | "loanReceipt" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null; const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm"; function Field({ label, children }: { label: string; children: ReactNode }) { const id = useId(); @@ -105,6 +106,7 @@ export function CompanyLoansPage() { return + } />
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) => @@ -156,7 +158,9 @@ export function CompanyLoansPage() { {mode === "employeeLender" || mode === "employeeRefresh" ? setMode(null)} onExternal={() => open("lender")} onRefresh={(lender) => open("employeeRefresh", lender)} onSaved={() => { setMode(null); setTab("lenders"); setLenderPage(1); }} /> : null} - { if (!opened && !mutation.isPending) setMode(null); }}> + {mode === "loanReceipt" ? setMode(null)} onManual={() => open("loan")} onSaved={() => { setMode(null); setTab("loans"); setPage(1); }} /> : null} + { if (!opened && !mutation.isPending) setMode(null); }}> {({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record)[mode ?? ""]} {mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" : diff --git a/scripts/qa/company-loan-browser.cjs b/scripts/qa/company-loan-browser.cjs index eb3e46df..c81e6fba 100644 --- a/scripts/qa/company-loan-browser.cjs +++ b/scripts/qa/company-loan-browser.cjs @@ -79,7 +79,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) { const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST'); await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf', - buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') }); + buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) }); assert.equal((await received).status(), 200); } await card.getByRole('button', { name: '确认期初余额' }).click(); @@ -182,7 +182,9 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa for (const [index, batchId] of reconciliationUi.batches.entries()) { const mobile = index === 1; async function session(who) { - const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); + // Reconciliation forms fill UTC wall-clock times; pin the browser to UTC + // instead of inheriting the host timezone (fails 00:00–08:00 in CST). + const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block', timezoneId: 'UTC' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) }))); @@ -236,7 +238,9 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa for (const recovery of [false, true]) for (const [index, batch] of (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).entries()) { const mobile = index === 1; async function session(who) { - const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); + // Reconciliation forms fill UTC wall-clock times; pin the browser to UTC + // instead of inheriting the host timezone (fails 00:00–08:00 in CST). + const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block', timezoneId: 'UTC' }); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) }))); diff --git a/scripts/qa/company-loan-employee-browser.cjs b/scripts/qa/company-loan-employee-browser.cjs index 89d2516c..b051a6e6 100644 --- a/scripts/qa/company-loan-employee-browser.cjs +++ b/scripts/qa/company-loan-employee-browser.cjs @@ -147,7 +147,7 @@ module.exports = async function employeeBrowser(h) { for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) { const result = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST'); await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf', - buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') }); + buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) }); assert.equal((await result).status(), 200); } await loanCard.getByRole('button', { name: '确认期初余额', exact: true }).click(); @@ -159,8 +159,9 @@ module.exports = async function employeeBrowser(h) { await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached(); for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) { assert.ok(i < 20); - const response = page.waitForResponse(r => r.url().includes('/loan-lenders?page=')); - await dialog.getByRole('button', { name: '下一页出借人', exact: true }).click(); await response; + // Await both together so a timeout reaches the catch below (and its screenshot). + await Promise.all([page.waitForResponse(r => r.url().includes('/loan-lenders?page=')), + dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout: 30000 })]); } await dialog.getByLabel('授权出借人').selectOption(lender.id); await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check(); diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs index 9d7831c9..5e2dbf21 100644 --- a/scripts/qa/company-loan-http-fixture.cjs +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -111,6 +111,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required'); const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n'); async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) { + // An original borrow-in is unique per lender; each synthetic loan has its own. + if (kind === 'incoming' && data === material) data = Buffer.from(`%PDF-1.4\n% synthetic-incoming-${loanId}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`); const boundary = `synthetic-${randomUUID()}`; const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`, @@ -416,6 +418,14 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { } const employeeHarness = { prisma, pool, call, app, req, finance, manager, ordinary, config, password, actor, company, bank, confirmedLoan, repay, exportOrder, receipt }; + // Incoming-receipt scenarios add lenders; in a full run they go last so they + // cannot shift the pagination other browser scenarios rely on. + const runIncoming = () => require('./company-loan-incoming-fixture.cjs')({ ...employeeHarness, verifiedLender }); + if (process.env.RUN_ISOLATED_LOAN_INCOMING_ONLY === '1') { + const incomingReceipt = await runIncoming(); + if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-incoming-browser.cjs')({ app, finance, otherFinance: await actor(['finance']), password, prisma, config, company, incomingReceipt }); + return; + } const employeeSources = await require('./company-loan-employee-fixture.cjs')(employeeHarness); if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS === '1') await require('./company-loan-employee-mutations.cjs')( employeeHarness, require('./company-loan-employee-fixture.cjs')); @@ -1391,6 +1401,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { } await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty, employeeSources }); } + await runIncoming(); console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks })); } finally { await app.close(); await prisma.$disconnect(); } }; diff --git a/scripts/qa/company-loan-incoming-browser.cjs b/scripts/qa/company-loan-incoming-browser.cjs new file mode 100644 index 00000000..d2cbf1a3 --- /dev/null +++ b/scripts/qa/company-loan-incoming-browser.cjs @@ -0,0 +1,186 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createHash, randomUUID } = require('node:crypto'); +const { mkdtempSync, mkdirSync } = require('node:fs'); +const { createRequire } = require('node:module'); +const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs'); +const root = path.resolve(__dirname, '../..'); +const clientRequire = createRequire(path.join(root, 'client/package.json')); +const { chromium, expect } = clientRequire('@playwright/test'); + +// N5-P desktop and mobile: upload once, check prefilled values, correct, save +// loan and original together; restricted files, late results and actor switches. +module.exports = async function incomingBrowser(h) { + const { app, finance, otherFinance, password, prisma, config, company, incomingReceipt } = h; + const lender = incomingReceipt.lender; + const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); + const origin = await app.listen({ host: '127.0.0.1', port: 0 }); + const base = path.join(root, 'artifacts/qa-flow/loan-incoming-prefill-20261009'); mkdirSync(base, { recursive: true }); + const output = mkdtempSync(path.join(base, 'browser-')); + const oldOrigin = config.appOrigin; config.appOrigin = origin; + const checks = []; + const sha = (buffer) => createHash('sha256').update(buffer).digest('hex'); + const pdf = (options = {}) => syntheticBankReceiptPdf({ direction: 'credit', status: 'booked', amount: '50,000.00', + reference: `UI-IN-${randomUUID().slice(0, 8)}`, bookedOn: '2025-03-14', + extraLines: [`付款人名称 ${lender.name}`, `收款人名称 ${company.name}`], ...options }); + try { + for (const mode of ['desktop', 'mobile']) { + const mobile = mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; + const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); + await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); + const page = await context.newPage(); + try { + await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${prefix}/company-loans`)}`); + await page.getByTestId('login-identifier').fill(finance.email); await page.getByTestId('login-password').fill(password); + await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); + const openDialog = async () => { + await page.getByRole('button', { name: '上传回单登记借入', exact: true }).click(); + const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: '上传回单登记借入' })).toBeVisible(); + return dialog; + }; + const choose = async (dialog, buffer) => dialog.getByLabel('原借入回单', { exact: true }) + .setInputFiles({ name: 'incoming.pdf', mimeType: 'application/pdf', buffer }); + const recognized = () => page.waitForResponse(r => r.url().endsWith('/api/company-loans/incoming-receipt/recognize') && r.request().method() === 'POST'); + + // Normal path: prefilled values are checked, one corrected, saved with the original. + const reference = `UI-IN-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase(); + const file = await pdf({ reference }); + let dialog = await openDialog(); + const first = recognized(); await choose(dialog, file); assert.equal((await first).status(), 200); + await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('50000.00'); + await expect(dialog.getByLabel('实际到账日期', { exact: true })).toHaveValue('2025-03-14'); + await expect(dialog.getByLabel('原借入凭据唯一引用', { exact: true })).toHaveValue(reference); + await expect(dialog.getByLabel('出借人', { exact: true })).toHaveValue(lender.id); + await expect(dialog.getByLabel('回单识别结果')).toContainText('已从 PDF 文字读取'); + await dialog.getByLabel('实际到账日期', { exact: true }).fill('2025-03-15'); + await expect(dialog.getByText('已人工修改')).toBeVisible(); + await dialog.getByLabel('核对日未还本金', { exact: true }).fill('30000.00'); + await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01'); + const saveButton = dialog.getByRole('button', { name: '保存借款及原件', exact: true }); + await expect(saveButton).toBeDisabled(); + await dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }).check(); + await dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }).check(); + // The same actor's expired access token is refreshed once and the save completes. + let saveAttempts = 0; + const expireOnce = async route => { + saveAttempts++; + if (saveAttempts === 1) return route.fulfill({ status: 401, contentType: 'application/json', + body: JSON.stringify({ requestId: 'synthetic-expired-access', error: { code: 'AUTH_001', message: '合成过期令牌' } }) }); + return route.continue(); + }; + await page.route('**/api/company-loans/from-incoming-receipt', expireOnce); + try { + const saved = page.waitForResponse(r => r.url().endsWith('/api/company-loans/from-incoming-receipt') && r.request().method() === 'POST' && r.status() === 200); + await saveButton.click(); await saved; + await expect(page.getByRole('dialog')).not.toBeVisible(); + assert.equal(saveAttempts, 2, 'same-actor expiry keeps the normal single refresh retry'); + } finally { await page.unroute('**/api/company-loans/from-incoming-receipt', expireOnce); } + const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference, lender_id: lender.id }, include: { evidences: true } }); + assert.equal(loan.confirmation_status, 'unconfirmed'); + assert.equal(loan.received_date.toISOString().slice(0, 10), '2025-03-15'); + assert.deepEqual(loan.evidences.map(e => [e.kind, e.file_sha256]), [['incoming', sha(file)]]); + const card = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${loan.loan_no}`, exact: true }) }); + await expect(card).toContainText('余额待核实'); + await card.locator('summary').click(); await expect(card.getByRole('button', { name: 'incoming.pdf' })).toBeVisible(); + const scroll = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + assert.ok(scroll <= 1, `${mode} has no horizontal overflow`); + await page.screenshot({ path: path.join(output, `incoming-${mode}-saved.png`), fullPage: true }); + checks.push(`${mode}_upload_prefill_correct_same_actor_refresh_and_save_with_original`); + + // A restricted document cannot be saved through this entry. + dialog = await openDialog(); + const restricted = recognized(); await choose(dialog, await pdf({ final: false })); await restricted; + await expect(dialog.getByRole('alert')).toContainText('不能作为原借入回单'); + await expect(dialog.getByRole('button', { name: '保存借款及原件', exact: true })).toBeDisabled(); + await page.screenshot({ path: path.join(output, `incoming-${mode}-blocked.png`), fullPage: true }); + // A late result for a replaced file never fills the new form. + let release; const gate = new Promise(resolve => { release = resolve; }); + let held = 0; + const delay = async route => { held++; if (held === 1) await gate; return route.continue(); }; + await page.route('**/api/company-loans/incoming-receipt/recognize', delay); + try { + const late = pdf({ amount: '11.00' }); const latest = pdf({ amount: '12.00' }); + await choose(dialog, await late); + await expect.poll(() => held).toBe(1); + const second = recognized(); await choose(dialog, await latest); await second; + release(); + await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('12.00'); + await page.waitForTimeout(300); + await expect(dialog.getByLabel('原始本金', { exact: true })).toHaveValue('12.00'); + } finally { release(); await page.unroute('**/api/company-loans/incoming-receipt/recognize', delay); } + checks.push(`${mode}_restricted_blocked_and_late_result_cannot_overwrite_new_file`); + + // A refreshed lender list with a new version clears the checks; saving + // needs a fresh review and then carries the new version. + const versionRef = `UI-VER-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase(); + const versionFile = await pdf({ reference: versionRef, amount: '14.00' }); + const fourth = recognized(); await choose(dialog, versionFile); await fourth; + await dialog.getByLabel('核对日未还本金', { exact: true }).fill('14.00'); + await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01'); + const originalBox = dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }); + const natureBox = dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }); + await originalBox.check(); await natureBox.check(); + const row = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } }); + await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: { increment: 1 } } }); + await page.waitForTimeout(5500); + const refetched = page.waitForResponse(r => new URL(r.url()).pathname === '/api/loan-lenders' && r.request().method() === 'GET'); + await context.setOffline(true); await context.setOffline(false); await refetched; + await expect(dialog.getByText('所选出借人的资料已更新,请重新核对后再勾选确认。')).toBeVisible(); + await expect(originalBox).not.toBeChecked(); await expect(natureBox).not.toBeChecked(); + await expect(dialog.getByRole('button', { name: '保存借款及原件', exact: true })).toBeDisabled(); + await originalBox.check(); await natureBox.check(); + const versionSave = page.waitForResponse(r => r.url().endsWith('/api/company-loans/from-incoming-receipt') && r.request().method() === 'POST'); + await dialog.getByRole('button', { name: '保存借款及原件', exact: true }).click(); + const versionResponse = await versionSave; + assert.equal(versionResponse.status(), 200); + assert.equal(JSON.parse(versionResponse.request().postDataBuffer().toString('latin1').match(/name="payload"\r\n\r\n(.*?)\r\n/s)[1]).expectedLenderAccountVersion, row.account_version + 1); + assert.equal(await prisma.companyLoan.count({ where: { origin_reference: versionRef } }), 1); + await expect(page.getByRole('dialog')).not.toBeVisible(); + checks.push(`${mode}_refreshed_lender_version_clears_checks_and_requires_new_review`); + dialog = await openDialog(); + + // A held save answered with a late 401 after a real actor switch is aborted, not replayed. + const switchRef = `UI-SWITCH-${mode}-${randomUUID().slice(0, 6)}`.toUpperCase(); + const switchFile = await pdf({ reference: switchRef, amount: '13.00' }); + const third = recognized(); await choose(dialog, switchFile); await third; + await dialog.getByLabel('核对日未还本金', { exact: true }).fill('13.00'); + await dialog.getByLabel('余额核对截止日', { exact: true }).fill('2026-01-01'); + await dialog.getByRole('checkbox', { name: '已查看完整原件,金额、日期和凭据号与原件一致' }).check(); + await dialog.getByRole('checkbox', { name: '已核对这笔入账是向该出借人的借款,且收款主体为所选公司' }).check(); + await page.evaluate(() => { window.__syntheticIncomingDocument = 'same-document'; }); + const within = (to) => page.evaluate(url => { window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, to); + let releaseSave; const saveGate = new Promise(resolve => { releaseSave = resolve; }); + let started; const holding = new Promise(resolve => { started = resolve; }); + let attempts = 0; let firstRequest; let aborted = false; + const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); }; + const handler = async route => { + attempts++; + if (attempts > 1) return route.continue(); + firstRequest = route.request(); started(); await saveGate; + await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late-401', + error: { code: 'AUTH_001', message: '合成旧会话迟到响应' } }) }).catch(() => {}); + }; + page.on('requestfailed', onFailed); await page.route('**/api/company-loans/from-incoming-receipt', handler); + try { + await dialog.getByRole('button', { name: '保存借款及原件', exact: true }).click(); await holding; + await within('/mobile/me'); + await page.getByRole('button', { name: '退出登录', exact: true }).click(); + await expect(page.getByTestId('login-submit')).toBeVisible(); + await page.getByTestId('login-identifier').fill(otherFinance.email); await page.getByTestId('login-password').fill(password); + await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-submit')).toHaveCount(0); + assert.equal(await page.evaluate(() => window.__syntheticIncomingDocument), 'same-document'); + releaseSave(); + await expect.poll(() => aborted, { message: 'leaving the form must abort its old save' }).toBe(true); + assert.equal(attempts, 1, 'a late 401 must never replay the old save as the new actor'); + assert.equal(await prisma.companyLoan.count({ where: { origin_reference: switchRef } }), 0); + } finally { releaseSave(); page.off('requestfailed', onFailed); await page.unroute('**/api/company-loans/from-incoming-receipt', handler); } + checks.push(`${mode}_late_401_save_aborted_across_real_actor_switch`); + } catch (error) { + await page.screenshot({ path: path.join(output, `incoming-${mode}-failure.png`), fullPage: true, timeout: 5000 }).catch(() => {}); + throw error; + } finally { await context.close(); } + } + } finally { config.appOrigin = oldOrigin; await browser.close(); } + console.log(JSON.stringify({ stage: 'loan_incoming_browser', status: 'PASS', checks, output: path.relative(root, output) })); + return checks; +}; diff --git a/scripts/qa/company-loan-incoming-fixture.cjs b/scripts/qa/company-loan-incoming-fixture.cjs new file mode 100644 index 00000000..2c37c861 --- /dev/null +++ b/scripts/qa/company-loan-incoming-fixture.cjs @@ -0,0 +1,207 @@ +const assert = require('node:assert/strict'); +const { randomUUID, createHash } = require('node:crypto'); +const { existsSync, readdirSync } = require('node:fs'); +const path = require('node:path'); +const { syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require('./synthetic-bank-receipt.cjs'); + +// N5-P: original borrow-in receipt prefill. Synthetic PDFs only; the provider is +// unconfigured here, so scanned/unreadable files take the manual path. +module.exports = async function incomingReceiptFixture(h) { + const { prisma, pool, call, app, req, finance, manager, ordinary, config, actor, company, verifiedLender } = h; + const incoming = req('./src/services/loanIncomingReceipt'); + const checks = []; + const sha = (buffer) => createHash('sha256').update(buffer).digest('hex'); + const storedOriginals = () => { + const dir = path.join(config.storageDir, 'company-loan-evidence', 'incoming'); + return existsSync(dir) ? readdirSync(dir).length : 0; + }; + function multipart(who, url, file, payload, { name = 'receipt.pdf', type = 'application/pdf' } = {}) { + const boundary = `synthetic-${randomUUID()}`; + const parts = []; + if (payload !== undefined) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="payload"\r\n\r\n${JSON.stringify(payload)}\r\n`)); + parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`), file, + Buffer.from(`\r\n--${boundary}--\r\n`)); + return app.inject({ method: 'POST', url: `/api${url}`, headers: { ...(who?.headers ?? {}), 'content-type': `multipart/form-data; boundary=${boundary}` }, + payload: Buffer.concat(parts) }); + } + async function expectStatus(promise, status, reason) { + const response = await promise; + assert.equal(response.statusCode, status, `${response.statusCode} ${response.json().error?.details?.reasonCode ?? ''}`); + if (reason) assert.equal(response.json().error?.details?.reasonCode, reason); + return response.json().data; + } + const recognize = (file, who = finance, status = 200, reason) => + expectStatus(multipart(who, '/company-loans/incoming-receipt/recognize', file), status, reason); + const save = (file, payload, who = finance, status = 200, reason) => + expectStatus(multipart(who, '/company-loans/from-incoming-receipt', file, payload), status, reason); + const receiptPdf = (lenderName, options = {}) => syntheticBankReceiptPdf({ direction: 'credit', status: 'booked', + amount: '50,000.00', reference: `SYN-IN-${randomUUID().slice(0, 8)}`, bookedOn: '2025-03-14', + extraLines: [`付款人名称 ${lenderName}`, `收款人名称 ${company.name}`], ...options }); + + const lender = await verifiedLender('合成借入回单出借人', null, '6'); + const file = await receiptPdf(lender.name); + const before = { loans: await prisma.companyLoan.count(), evidences: await prisma.loanEvidence.count(), stored: storedOriginals() }; + for (const [who, status] of [[null, 401], [manager, 403], [ordinary, 403]]) await recognize(file, who, status); + await recognize(Buffer.from('%PNG not a receipt'), finance, 400, 'loan.incoming_receipt_invalid'); + await expectStatus(multipart(finance, '/company-loans/incoming-receipt/recognize', Buffer.alloc(5 * 1024 * 1024 + 10, 37)), 400); + const prefill = await recognize(file); + assert.deepEqual(prefill.blocks, []); assert.equal(prefill.ocr, 'not_needed'); assert.equal(prefill.direction, 'credit'); + assert.deepEqual({ ...prefill.fields, originReference: undefined }, { originalPrincipal: '50000.00', receivedDate: '2025-03-14', + originReference: undefined, referenceKind: 'voucher', currency: 'CNY', payerName: lender.name, + payerAccountMasked: prefill.fields.payerAccountMasked, payeeName: company.name, payeeAccountMasked: prefill.fields.payeeAccountMasked }); + assert.deepEqual(prefill.suggestions, { legalEntityId: company.id, lenderId: lender.id }); + assert.ok(!JSON.stringify(prefill).includes('000011112222'), 'recognition returns masked accounts only'); + assert.deepEqual({ loans: await prisma.companyLoan.count(), evidences: await prisma.loanEvidence.count(), stored: storedOriginals() }, before, + 'recognition never creates a loan, an original or a stored object'); + checks.push('incoming_recognition_prefill_masked_and_non_persistent'); + + const payload = { context: prefill.context, requestKey: randomUUID(), lenderId: lender.id, + expectedLenderIdentityVersion: lender.identityVersion, expectedLenderAccountVersion: lender.accountVersion, + originReference: prefill.fields.originReference, originalPrincipal: prefill.fields.originalPrincipal, + openingOutstandingPrincipal: '30000.00', receivedDate: '2025-03-15', balanceAsOf: '2026-01-01', historicalEntry: true, + originalChecked: true, natureConfirmed: true }; + for (const change of [{ originalChecked: false }, { natureConfirmed: undefined }, { confirmationStatus: 'confirmed' }, + { balanceAsOf: '2999-01-01' }, { openingOutstandingPrincipal: '60000.00' }]) { + await save(file, { ...payload, ...change }, finance, 400); + } + for (const who of [manager, ordinary]) await save(file, payload, who, 403); + const saved = await save(file, payload); + assert.equal(saved.created, true); + const loan = saved.item; + assert.equal(loan.confirmationStatus, 'unconfirmed'); assert.equal(loan.available, '0.00'); + assert.equal(loan.receivedDate, '2025-03-15'); + const originals = loan.evidences.filter((e) => e.kind === 'incoming'); + assert.equal(originals.length, 1); + const row = await prisma.loanEvidence.findUniqueOrThrow({ where: { id: originals[0].id } }); + assert.equal(row.file_sha256, sha(file)); assert.equal(row.governance_status, 'approved'); + const audit = await prisma.auditLog.findFirstOrThrow({ where: { target_id: loan.id, action: 'COMPANY_LOAN_REGISTERED' } }); + assert.equal(audit.extra.source, 'incoming_receipt'); + assert.deepEqual(audit.extra.correctedFields, ['receivedDate']); + assert.ok(!JSON.stringify(audit.extra).includes('000011112222'), 'audit keeps no account or raw text'); + assert.equal(await prisma.auditLog.count({ where: { target_id: loan.id, action: 'COMPANY_LOAN_EVIDENCE_UPLOADED' } }), 1); + assert.equal(storedOriginals(), before.stored + 1); + checks.push('incoming_save_creates_unconfirmed_loan_with_original_and_correction_audit'); + + const retried = await save(file, payload); + assert.equal(retried.created, false); assert.equal(retried.item.id, loan.id); + assert.equal(await prisma.loanEvidence.count({ where: { loan_id: loan.id } }), 1); + assert.equal(storedOriginals(), before.stored + 1, 'an idempotent retry removes its own new object'); + const otherFile = await receiptPdf(lender.name, { amount: '10.00' }); + const otherPrefill = await recognize(otherFile); + await save(otherFile, { ...payload, context: otherPrefill.context }, finance, 409, 'loan.duplicate_registration'); + await save(file, { ...payload, requestKey: randomUUID(), originReference: '合成另一引用' }, finance, 409, 'loan.incoming_receipt_duplicate'); + const manualLoan = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成手工登记', + originalPrincipal: '10.00', openingOutstandingPrincipal: '10.00', receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true }); + await expectStatus(multipart(finance, `/company-loans/${manualLoan.id}/evidences/incoming`, file), 409, 'loan.incoming_receipt_duplicate'); + assert.equal(storedOriginals(), before.stored + 1); + checks.push('incoming_retry_request_key_and_cross_entry_original_duplicates'); + + const otherFinance = await actor(['finance']); + await save(file, { ...payload, requestKey: randomUUID() }, otherFinance, 409, 'loan.incoming_receipt_context_invalid'); + await save(otherFile, { ...payload, requestKey: randomUUID() }, finance, 409, 'loan.incoming_receipt_context_invalid'); + await save(file, { ...payload, requestKey: randomUUID(), context: `${prefill.context.slice(0, -6)}AAAAAA` }, finance, 409, 'loan.incoming_receipt_context_invalid'); + const expired = incoming.sealContext(config, { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION, + actorId: finance.id, fileSha256: sha(otherFile), issuedAt: Date.now() - 3_600_000, expiresAt: Date.now() - 1000, fields: {}, blocks: [] }); + await save(otherFile, { ...payload, requestKey: randomUUID(), originReference: '合成过期', context: expired }, finance, 409, 'loan.incoming_receipt_context_invalid'); + // The lender checked in the form must still be the same version at save time. + const changedFile = await receiptPdf(lender.name, { amount: '15.00' }); + const changedPrefill = await recognize(changedFile); + const changedInput = { ...payload, requestKey: randomUUID(), context: changedPrefill.context, originReference: '合成出借人已变更', + originalPrincipal: '15.00', openingOutstandingPrincipal: '15.00' }; + await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: { increment: 1 } } }); + try { + await save(changedFile, changedInput, finance, 409, 'loan.version_changed'); + await save(changedFile, { ...changedInput, expectedLenderAccountVersion: lender.accountVersion + 1, expectedLenderIdentityVersion: lender.identityVersion + 1 }, + finance, 409, 'loan.version_changed'); + } finally { await prisma.loanLender.update({ where: { id: lender.id }, data: { account_version: lender.accountVersion } }); } + assert.equal(await prisma.companyLoan.count({ where: { origin_reference: '合成出借人已变更' } }), 0); + checks.push('incoming_context_bound_to_actor_file_integrity_lifetime_and_lender_version'); + + for (const [options, block] of [[{ final: false }, 'non_final'], [{ status: 'failed' }, 'transaction_failed'], + [{ currency: 'USD' }, 'non_cny']]) { + const restricted = await receiptPdf(lender.name, options); + const result = await recognize(restricted); + assert.ok(result.blocks.includes(block), `${block} reported`); + await save(restricted, { ...payload, requestKey: randomUUID(), originReference: `合成受限-${block}`, context: result.context }, finance, 409, 'loan.incoming_receipt_blocked'); + } + // Company expense has no payee line, so only the expense rule can refuse it. + for (const extraLines of [[`付款人名称 ${company.name}`], [`付款人名称 ${lender.name}`, '收款人名称 合成另一收款主体']]) { + const misdirected = await receiptPdf(lender.name, { direction: extraLines[0].includes(company.name) ? 'debit' : 'credit', extraLines }); + const result = await recognize(misdirected); + await save(misdirected, { ...payload, requestKey: randomUUID(), originReference: `合成方向-${randomUUID().slice(0, 6)}`, context: result.context }, + finance, 409, 'loan.incoming_receipt_blocked'); + } + assert.equal(storedOriginals(), before.stored + 1, 'refused saves leave no stored object'); + checks.push('incoming_non_final_failed_foreign_currency_company_expense_and_other_payee_blocked'); + + const { PDFDocument } = req('pdf-lib'); + const blankDoc = await PDFDocument.create(); blankDoc.addPage(); blankDoc.setTitle(`synthetic-scan-${randomUUID()}`); + const scanned = Buffer.from(await blankDoc.save()); + const manual = await recognize(scanned); + assert.equal(manual.ocr, 'unavailable'); + assert.deepEqual(manual.fieldIssues, { amount: 'missing', receivedDate: 'missing', reference: 'missing' }); + assert.equal(manual.fields.originalPrincipal, null); + const manualSaved = await save(scanned, { ...payload, requestKey: randomUUID(), context: manual.context, originReference: '合成扫描人工补录', + originalPrincipal: '20.00', openingOutstandingPrincipal: '20.00', receivedDate: '2025-04-01' }); + assert.equal(manualSaved.created, true); + assert.deepEqual((await prisma.auditLog.findFirstOrThrow({ where: { target_id: manualSaved.item.id, action: 'COMPANY_LOAN_REGISTERED' } })).extra.correctedFields, []); + checks.push('incoming_unreadable_without_provider_completes_by_manual_entry'); + + // The CCB table layout of the real borrow-in receipts (synthetic replica). + const ccbFile = await syntheticCcbIncomingPdf({ payerName: lender.name, payeeName: company.name, serial: `CCB${randomUUID().replace(/-/g, '').slice(0, 16)}`, + amount: '12,345.67', capital: '壹万贰仟叁佰肆拾伍元陆角柒分' }); + const ccb = await recognize(ccbFile); + assert.deepEqual(ccb.blocks, []); assert.equal(ccb.ocr, 'not_needed'); assert.equal(ccb.direction, 'credit'); + assert.equal(ccb.fields.originalPrincipal, '12345.67'); assert.equal(ccb.fields.receivedDate, '2025-03-14'); + assert.equal(ccb.fields.referenceKind, 'serial'); assert.equal(ccb.fields.payerName, lender.name); assert.equal(ccb.fields.payeeName, company.name); + assert.deepEqual(ccb.suggestions, { legalEntityId: company.id, lenderId: lender.id }); + const ccbSaved = await save(ccbFile, { ...payload, requestKey: randomUUID(), context: ccb.context, originReference: ccb.fields.originReference, + originalPrincipal: ccb.fields.originalPrincipal, openingOutstandingPrincipal: '12345.67', receivedDate: ccb.fields.receivedDate }); + assert.equal(ccbSaved.created, true); + assert.deepEqual((await prisma.auditLog.findFirstOrThrow({ where: { target_id: ccbSaved.item.id, action: 'COMPANY_LOAN_REGISTERED' } })).extra.correctedFields, []); + checks.push('incoming_ccb_table_layout_prefills_suggests_parties_and_saves_without_provider'); + + // Real rollback: the second audit fails inside the transaction. + const rollbackFile = await receiptPdf(lender.name, { amount: '30.00' }); + const rollbackPrefill = await recognize(rollbackFile); + await pool.query(`CREATE FUNCTION synthetic_fail_incoming_audit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN RAISE EXCEPTION 'synthetic incoming audit failure'; END $$`); + await pool.query(`CREATE TRIGGER synthetic_fail_incoming_audit BEFORE INSERT ON audit_logs FOR EACH ROW + WHEN (NEW.action = 'COMPANY_LOAN_EVIDENCE_UPLOADED' AND NEW.extra->>'kind' = 'incoming') EXECUTE FUNCTION synthetic_fail_incoming_audit()`); + const rollbackKey = randomUUID(); + try { + await save(rollbackFile, { ...payload, requestKey: rollbackKey, context: rollbackPrefill.context, originReference: '合成审计失败回滚', + originalPrincipal: '30.00', openingOutstandingPrincipal: '30.00' }, finance, 500); + } finally { + await pool.query('DROP TRIGGER synthetic_fail_incoming_audit ON audit_logs'); + await pool.query('DROP FUNCTION synthetic_fail_incoming_audit()'); + } + assert.equal(await prisma.companyLoan.count({ where: { request_key: rollbackKey } }), 0); + assert.equal(await prisma.loanEvidence.count({ where: { file_sha256: sha(rollbackFile) } }), 0); + assert.equal(storedOriginals(), before.stored + 3, 'failed transaction removes the object it wrote'); + checks.push('incoming_audit_failure_rolls_back_loan_original_and_object'); + + // Same original, two new requests at once: the lender lock admits exactly one. + const raceFile = await receiptPdf(lender.name, { amount: '40.00' }); + const racePrefill = await recognize(raceFile); + const race = await Promise.all(['甲', '乙'].map((tag) => multipart(finance, '/company-loans/from-incoming-receipt', raceFile, { ...payload, + requestKey: randomUUID(), context: racePrefill.context, originReference: `合成并发-${tag}`, originalPrincipal: '40.00', openingOutstandingPrincipal: '40.00' }))); + assert.deepEqual(race.map((r) => r.statusCode).sort(), [200, 409]); + assert.equal(race.find((r) => r.statusCode === 409).json().error.details.reasonCode, 'loan.incoming_receipt_duplicate'); + // Old upload and new save racing for one original also admit exactly one. + const crossFile = await receiptPdf(lender.name, { amount: '41.00' }); + const crossPrefill = await recognize(crossFile); + const crossLoan = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成旧入口竞争', + originalPrincipal: '41.00', openingOutstandingPrincipal: '41.00', receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true }); + const cross = await Promise.all([ + multipart(finance, `/company-loans/${crossLoan.id}/evidences/incoming`, crossFile), + multipart(finance, '/company-loans/from-incoming-receipt', crossFile, { ...payload, requestKey: randomUUID(), context: crossPrefill.context, + originReference: '合成新入口竞争', originalPrincipal: '41.00', openingOutstandingPrincipal: '41.00' }), + ]); + assert.deepEqual(cross.map((r) => r.statusCode).sort(), [200, 409]); + assert.equal(await prisma.loanEvidence.count({ where: { file_sha256: sha(crossFile), kind: 'incoming' } }), 1); + checks.push('incoming_concurrent_saves_and_old_upload_race_admit_one_original'); + + console.log(JSON.stringify({ stage: 'loan_incoming_receipt_http', status: 'PASS', checks })); + return { lender, file, loan }; +}; diff --git a/scripts/qa/synthetic-bank-receipt.cjs b/scripts/qa/synthetic-bank-receipt.cjs index ea583d5d..3f1a0f66 100644 --- a/scripts/qa/synthetic-bank-receipt.cjs +++ b/scripts/qa/synthetic-bank-receipt.cjs @@ -4,13 +4,13 @@ const req = createRequire(path.resolve(__dirname, '../../server/package.json')); function syntheticReceiptText({ status = 'success', final = true, direction = 'debit', amount = '10.00', currency = '人民币', payerAccount = '000011112222', payeeAccount = '000033334444', - reference = 'SYNTHETIC-REFERENCE', bookedOn = '2026-10-01' } = {}) { + reference = 'SYNTHETIC-REFERENCE', bookedOn = '2026-10-01', extraLines = [] } = {}) { const words = { success: '交易成功', failed: '交易失败', pending: '处理中', booked: '' }; return [final ? '中国建设银行单位客户专用回单' : '中国建设银行网上银行电子回执', '合成公司测试回单 SYNTHETIC', `记账日期 ${bookedOn}`, direction === 'credit' ? '贷' : '借', `金额 ${amount}`, `币别 ${currency}`, `付款人账号 ${payerAccount}`, `收款人账号 ${payeeAccount}`, `凭证号码 ${reference}`, ...(words[status] ? [`单据状态 ${words[status]}`] : []), - ...(!final ? ['本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。'] : [])].join('\n'); + ...(!final ? ['本回执不作为收、付款方交易的最终依据,正式回单请在交易成功后另打印。'] : []), ...extraLines].join('\n'); } async function syntheticBankReceiptPdf({ pages = 1, tag = 'SYNTHETIC', ...options } = {}) { @@ -39,8 +39,53 @@ endcmap CMapName currentdict /CMap defineresource pop end end`; return Buffer.from(await doc.save()); } +// Synthetic replica of the CCB "单位客户专用回单" table layout (all values fake): +// out-of-order text items, stacked 付款人/收款人 column headers, a vertical copy +// note that also contains 收款人, an unlabelled header date and 大写/小写 amounts. +async function syntheticCcbIncomingPdf({ tag = 'SYNTHETIC-CCB', amount = '50,000.00', capital = '伍万元整', + date = ['2025', '03', '14'], serial = '123456789AB12CDEFG3', payerName = '合成甲', payerAccount = '6222000011112222', + payeeName = '合成公司', payeeAccount = '33050161000000000001', title = '单位客户专用回单', + direction = '贷', copyNote = '(第一联)(收款人回单)', headers = ['付款人', '收款人'], dateDy = 0, extraItems = [] } = {}) { + const { PDFDocument, PDFHexString, beginText, endText, setFontAndSize, moveText, showText } = req('pdf-lib'); + const items = [ + ...(serial ? [['流水号:' + serial, 388, 785]] : []), + // dateDy models the small baseline jitter of real PDFs within one row; date: null omits it. + ...(date ? [['年', 272, 785], ['月', 292, 785], ['日', 312, 785], + [date[0], 252, 785 + dateDy], [date[1], 282, 785 + dateDy], [date[2], 302, 785 + dateDy]] : []), ['币别:人民币', 40, 785], + ['中国建设银行' + title, 172, 806], + ...[...headers[0]].map((c, i) => [c, 29, 769 - i * 10]), ...[...headers[1]].map((c, i) => [c, 284, 769 - i * 10]), + ['全', 52, 772], ['称', 72, 772], [payerName, 92, 772], ['全', 307, 772], ['称', 327, 772], [payeeName, 347, 772], + ['账', 52, 759], ['号', 72, 759], [payerAccount, 92, 759], ['账', 307, 759], ['号', 327, 759], [payeeAccount, 347, 759], + ['开户行', 52, 745], ['中国建设银行合成支行', 92, 745], ['开户行', 307, 745], ['中国建设银行合成支行', 347, 745], + ...[...copyNote].map((c, i) => [c, 540, 751 - i * 10]), + ['金额', 47, 731], ['(大写)人民币' + capital, 92, 731], ['(小写)¥' + amount, 347, 731], + ['凭证种类', 37, 716], ['凭证号码', 293, 716], ['摘要', 37, 702], ['借款', 92, 702], [direction, 500, 690], + ['打印时间:2026-10-08 10:00:00', 25, 596], ['本回单为合成测试回单,仅用于验证版式解析,已记账。', 25, 585], ...extraItems, + ]; + const doc = await PDFDocument.create(); doc.setTitle(tag); + const hex = (t) => Buffer.from(t, 'utf16le').swap16().toString('hex').toUpperCase(); + const chars = [...new Set(items.map(([t]) => t).join(''))]; + const cmap = `/CIDInit /ProcSet findresource begin 12 dict begin begincmap +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def +/CMapName /Synthetic-UCS def /CMapType 2 def +1 begincodespacerange <0000> endcodespacerange +${Array.from({ length: Math.ceil(chars.length / 100) }, (_, i) => chars.slice(i * 100, i * 100 + 100)) + .map((chunk) => `${chunk.length} beginbfchar\n${chunk.map((c) => `<${hex(c)}> <${hex(c)}>`).join('\n')}\nendbfchar`).join('\n')} +endcmap CMapName currentdict /CMap defineresource pop end end`; + const toUnicode = doc.context.register(doc.context.flateStream(cmap)); + const cid = doc.context.register(doc.context.obj({ Type: 'Font', Subtype: 'CIDFontType0', BaseFont: 'STSong-Light', + CIDSystemInfo: { Registry: PDFHexString.fromText('Adobe'), Ordering: PDFHexString.fromText('GB1'), Supplement: 4 }, DW: 1000 })); + const font = doc.context.register(doc.context.obj({ Type: 'Font', Subtype: 'Type0', BaseFont: 'STSong-Light', + Encoding: 'Identity-H', DescendantFonts: [cid], ToUnicode: toUnicode })); + const page = doc.addPage([595, 842]); + const name = page.node.newFontDictionary('SYN', font); + // Real receipts use roughly 5pt glyphs; larger text would run off the page. + for (const [text, x, y] of items) page.pushOperators(beginText(), setFontAndSize(name, 5), moveText(x, y), showText(PDFHexString.of(hex(text))), endText()); + return Buffer.from(await doc.save()); +} + function syntheticDocumentEvidence(options) { return req('./src/services/paymentReceiptDocument').classifyReceiptText(syntheticReceiptText(options)); } -module.exports = { syntheticReceiptText, syntheticBankReceiptPdf, syntheticDocumentEvidence }; +module.exports = { syntheticReceiptText, syntheticBankReceiptPdf, syntheticCcbIncomingPdf, syntheticDocumentEvidence }; diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index 520bd69f..70e49c5a 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -1,4 +1,8 @@ const ERROR_REASONS = [ + {"code":"loan.incoming_receipt_invalid","domain":"loan","audience":"user","defaultMessage":"请上传 5MB 以内、不超过 20 页的单份 PDF 原借入回单;文件损坏或无法安全读取时请改用手工登记。","recovery":"follow_guidance"}, + {"code":"loan.incoming_receipt_blocked","domain":"loan","audience":"user","defaultMessage":"该回单不能作为本笔借入原件:可能是非最终回执、失败或处理中、公司支出、多笔交易、非人民币或收款主体不符。请更换正式入账回单或改用手工登记。","recovery":"follow_guidance"}, + {"code":"loan.incoming_receipt_context_invalid","domain":"loan","audience":"user","defaultMessage":"识别结果已过期,或与当前账号、所选文件不一致,请重新上传并识别。","recovery":"follow_guidance"}, + {"code":"loan.incoming_receipt_duplicate","domain":"loan","audience":"user","defaultMessage":"该原件已用于本出借人的其他借款登记,不能重复登记;请核对已有借款。","recovery":"follow_guidance"}, {"code":"loan.employee_source_incomplete","domain":"loan","audience":"user","defaultMessage":"员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。","recovery":"follow_guidance"}, {"code":"loan.employee_source_changed","domain":"loan","audience":"user","defaultMessage":"员工资料已变化,请重新加载并核对后再保存。","recovery":"follow_guidance"}, {"code":"loan.employee_lender_conflict","domain":"loan","audience":"user","defaultMessage":"该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。","recovery":"follow_guidance"}, diff --git a/server/src/routes/companyLoans.js b/server/src/routes/companyLoans.js index ec3473a6..2477d153 100644 --- a/server/src/routes/companyLoans.js +++ b/server/src/routes/companyLoans.js @@ -15,6 +15,7 @@ const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = requ const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy"); const approvalPolicy = require("../services/loanApprovalPolicy"); const employeeLenders = require("../services/loanLenderEmployee"); +const incomingReceipts = require("../services/loanIncomingReceipt"); const UUID = z.string().uuid(); const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString); @@ -29,12 +30,17 @@ const ACCOUNT = z.object({ const LENDER = ACCOUNT.extend({ requestKey: UUID, legalEntityId: UUID, name: z.string().trim().min(1).max(120) }).strict(); const IDENTITY = z.object({ expectedVersion: VERSION, employeeId: UUID.nullable(), externalPersonConfirmed: z.boolean(), reason: TEXT }).strict() .refine((v) => v.employeeId ? !v.externalPersonConfirmed : v.externalPersonConfirmed); -const LOAN = z.object({ +const LOAN_FIELDS = { requestKey: UUID, lenderId: UUID, originReference: z.string().trim().min(1).max(200), originalPrincipal: MONEY, openingOutstandingPrincipal: MONEY, receivedDate: DATE, balanceAsOf: DATE, historicalEntry: z.boolean(), -}).strict().refine((v) => new Prisma.Decimal(v.originalPrincipal).gt(0) && - new Prisma.Decimal(v.openingOutstandingPrincipal).lte(v.originalPrincipal) && v.receivedDate <= v.balanceAsOf); +}; +const loanAmountsValid = (v) => new Prisma.Decimal(v.originalPrincipal).gt(0) && + new Prisma.Decimal(v.openingOutstandingPrincipal).lte(v.originalPrincipal) && v.receivedDate <= v.balanceAsOf; +const LOAN = z.object(LOAN_FIELDS).strict().refine(loanAmountsValid); +const INCOMING_SAVE = z.object({ ...LOAN_FIELDS, context: z.string().min(16).max(20000), + expectedLenderIdentityVersion: VERSION, expectedLenderAccountVersion: VERSION, + originalChecked: z.literal(true), natureConfirmed: z.literal(true) }).strict().refine(loanAmountsValid); const CONFIRM = z.object({ expectedVersion: VERSION, accountingConfirmationRef: z.string().trim().min(2).max(200) }).strict(); const EVIDENCE_KIND = z.enum(["agreement", "incoming", "accounting_confirmation", "adjustment"]); const LIST = z.object({ lenderId: UUID.optional(), page: z.coerce.number().int().min(1).default(1), @@ -68,6 +74,38 @@ async function validateEvidenceFile(input) { } } +// One PDF original, optionally preceded by one JSON field named "payload". +async function readIncomingParts(request, { withPayload }) { + let payload = null; let file = null; + try { + for await (const part of request.parts({ preservePath: true, limits: { + files: 1, fields: withPayload ? 1 : 0, parts: withPayload ? 2 : 1, fileSize: incomingReceipts.MAX_BYTES, fieldSize: 32 * 1024, + } })) { + if (part.type === "field") { + if (!withPayload || part.fieldname !== "payload" || payload !== null || file) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + try { payload = JSON.parse(String(part.value)); } catch { throw buildBusinessValidationError("loan.invalid_input", {}, 400); } + continue; + } + if (part.fieldname !== "file" || file) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); + const buffer = await boundedBuffer(part.file); + if (buffer.length > incomingReceipts.MAX_BYTES) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); + let validated; + try { validated = await validateAndNormalizePurchaseAttachment({ buffer, fileName: part.filename, declaredMimeType: part.mimetype, maxBytes: incomingReceipts.MAX_BYTES }); } + catch (error) { + if (error instanceof PurchaseAttachmentPolicyError) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); + throw error; + } + if (validated.mimeType !== "application/pdf") throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); + file = validated; + } + } catch (error) { + if (String(error.code ?? "").startsWith("FST_")) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); + throw error; + } + if (!file || (withPayload && payload === null)) throw buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); + return { payload, file }; +} + async function companyLoansRoutes(fastify) { const auth = { preHandler: fastify.authenticate }; const isFinance = (request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && @@ -293,6 +331,37 @@ async function companyLoansRoutes(fastify) { return sendData(reply, request, mapLoan(fastify.config, item)); }); + // Recognition proposes values only; nothing is stored. The sealed context + // binds the result to this finance user, this exact file and 30 minutes. + fastify.post("/company-loans/incoming-receipt/recognize", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const { file } = await readIncomingParts(request, { withPayload: false }); + const result = await incomingReceipts.recognize(fastify.prisma, fastify.config, request, file.buffer); + reply.header("Cache-Control", "no-store"); + return sendData(reply, request, result); + }); + + fastify.post("/company-loans/from-incoming-receipt", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const { payload, file } = await readIncomingParts(request, { withPayload: true }); + const body = parse(INCOMING_SAVE, payload); + const storagePath = await writeBuffer({ config: fastify.config, key: `company-loan-evidence/incoming/${randomUUID()}${file.extension}`, + buffer: file.buffer, contentType: file.mimeType }); + let created = false; + try { + const result = await incomingReceipts.saveFromReceipt(fastify.prisma, fastify.config, request, { + body, file, storagePath, timeZone: fastify.config.timeZone ?? "Asia/Shanghai" }); + created = result.created; + return sendData(reply, request, { item: mapLoan(fastify.config, result.loan), created: result.created }); + } finally { + if (!created) { + try { await deleteObject({ config: fastify.config, storagePath }); } catch { + request.log.error({ scope: "loan_incoming_receipt" }, "loan incoming original cleanup failed"); + } + } + } + }); + fastify.post("/company-loans/:id/confirm", auth, async (request, reply) => { ensureLoanManager(fastify, request); const id = parse(UUID, request.params.id); const body = parse(CONFIRM, request.body); @@ -314,7 +383,8 @@ async function companyLoansRoutes(fastify) { fastify.post("/company-loans/:id/evidences/:kind", auth, async (request, reply) => { ensureLoanManager(fastify, request); const id = parse(UUID, request.params.id); const kind = parse(EVIDENCE_KIND, request.params.kind); - if (!await fastify.prisma.companyLoan.findUnique({ where: { id }, select: { id: true } })) throw loanNotFound(); + const target = await fastify.prisma.companyLoan.findUnique({ where: { id }, select: { id: true, lender_id: true } }); + if (!target) throw loanNotFound(); const files = []; try { for await (const part of request.parts({ preservePath: true, limits: { files: 1, fields: 0, parts: 1, fileSize: MAX_BYTES } })) { @@ -333,7 +403,14 @@ async function companyLoansRoutes(fastify) { buffer: file.buffer, contentType: file.mimeType }); try { const item = await fastify.prisma.$transaction(async (tx) => { - await lockLoan(tx, id); + // Same direction as repayment creation: lender, then loan. The incoming + // original is unique per lender across this and the receipt entry. + if (kind === "incoming") await lockLender(tx, target.lender_id); + const loan = await lockLoan(tx, id); + if (kind === "incoming") { + if (loan.lender_id !== target.lender_id) throw loanNotFound(); + await incomingReceipts.assertIncomingUnused(tx, loan.lender_id, file.fileSha256, id); + } const result = await tx.loanEvidence.create({ data: { loan_id: id, kind, original_name: file.originalName, storage_path: storagePath, mime_type: file.mimeType, file_size: file.fileSize, file_sha256: file.fileSha256, diff --git a/server/src/services/loanIncomingReceipt.js b/server/src/services/loanIncomingReceipt.js new file mode 100644 index 00000000..8af4c966 --- /dev/null +++ b/server/src/services/loanIncomingReceipt.js @@ -0,0 +1,606 @@ +const { createHash, randomUUID } = require("node:crypto"); +const { PDFDocument } = require("pdf-lib"); +const { Prisma } = require("@prisma/client"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { isValidDateOnlyString, parseDateOnlyString, currentDateString } = require("../utils/date"); +const { encryptStringOrThrow, decryptStringOrThrow, maskBankAccount } = require("./crypto"); +const { extractReceiptTexts, classifyReceiptText } = require("./paymentReceiptDocument"); +const { parseReceiptMinorUnits, normalizeCurrency } = require("./paymentReceiptMatching"); +const { createReceiptOcrClient } = require("./paymentReceiptOcr"); +const { resolveNumberingContext, issueBusinessNumber } = require("./businessNumbering"); +const { + LENDER_INCLUDE, LOAN_INCLUDE, auditLoan, lockLender, validateLenderReferences, readLenderAccount, +} = require("./companyLoans"); + +// Prefill for registering an original borrow-in (company receives money). +// Recognition never persists business data; it only proposes values for finance +// to check. Payment-receipt rules (paymentDocumentReady) are deliberately not +// reused: a company credit is the valid direction here. +const PARSER_VERSION = 2; +const PURPOSE = "loan_incoming_prefill"; +const CONTEXT_TTL_MS = 30 * 60 * 1000; +const MAX_BYTES = 5 * 1024 * 1024; +const MAX_PAGES = 20; +const OCR_TOTAL_MS = 20000; +const REQUIRED = ["amount", "receivedDate", "reference"]; + +const LABELS = { + amount: ["(小写)", "小写金额", "交易金额", "入账金额", "转账金额", "金额"], + receivedDate: ["记账日期", "交易日期", "入账日期", "记账时间", "交易时间"], + serial: ["交易流水号", "流水号"], + voucher: ["凭证号码", "凭证号"], + currency: ["币别", "币种"], + payerName: ["付款人名称", "付款人户名", "付款方名称", "付款方户名"], + payerAccount: ["付款人账号", "付款方账号", "付款账号"], + payeeName: ["收款人名称", "收款人户名", "收款方名称", "收款方户名"], + payeeAccount: ["收款人账号", "收款方账号", "收款账号"], +}; +// Labels that terminate a value but are never read (print/export stamps etc.). +const STOP_LABELS = ["打印日期", "打印时间", "打印次数", "大写金额", "(大写)", "大写", "摘要", "用途", "附言", "备注", "单据状态", "交易状态", + "开户行", "回单编号", "验证码", "柜员", "网点", "复核", "经办"]; +const ALL_LABELS = [...new Set([...Object.values(LABELS).flat(), ...STOP_LABELS])].sort((a, b) => b.length - a.length); +const LABEL_RE = new RegExp(ALL_LABELS.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gu"); + +function invalidFile() { return buildBusinessValidationError("loan.incoming_receipt_invalid", {}, 400); } +function contextInvalid() { return buildBusinessValidationError("loan.incoming_receipt_context_invalid", {}, 409); } +function blocked() { return buildBusinessValidationError("loan.incoming_receipt_blocked", {}, 409); } + +function compact(value) { return String(value ?? "").normalize("NFKC").replace(/\s+/g, ""); } + +// The whole value must be one date, optionally followed by a time of day. +function parseDate(value) { + const text = compact(value).replace(/^[::]+/u, ""); + const match = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?(?:\d{1,2}:\d{2}(?::\d{2})?)?$/u) ?? + text.match(/^(\d{4})(\d{2})(\d{2})$/u); + if (!match) return null; + const date = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`; + return isValidDateOnlyString(date) ? date : null; +} + +// The whole value must be one well-formed RMB amount. A malformed separator or +// a third decimal is invalid, never truncated into a different valid amount. +function parseAmount(value) { + const match = compact(value).match(/^(?:人民币|CNY|RMB)?[¥¥]?((?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?)元?$/u); + return match ? parseReceiptMinorUnits(match[1]) : null; +} + +// The whole value must be one identifier; an unknown suffix or an over-long +// value is invalid rather than cut down to a different, shorter number. +function parseReference(value) { + const match = compact(value).match(/^[::]?([A-Za-z0-9][A-Za-z0-9-]{3,63})$/u); + return match ? match[1] : null; +} + +function parseAccount(value) { + const match = compact(value).match(/^[::]?(\d{6,40})/u); + return match ? match[1] : null; +} + +// Only the leading currency word is read ("人民币2025年…" on CCB header rows). +const CURRENCY_WORDS = [["人民币", "CNY"], ["美元", "USD"], ["港币", "HKD"], ["港元", "HKD"], ["欧元", "EUR"], ["日元", "JPY"], ["英镑", "GBP"]]; +function parseCurrency(value) { + const text = compact(value).replace(/^[::]+/u, ""); + const word = CURRENCY_WORDS.find(([name]) => text.startsWith(name)); + if (word) return word[1]; + const code = text.match(/^([A-Z]{3})(?![A-Z])/u)?.[1]; + return code ? normalizeCurrency(code === "RMB" ? "CNY" : code) : null; +} + +function parseName(value) { + const text = compact(value).replace(/^[::]+/u, ""); + return text && text.length <= 120 && !/^\d+$/.test(text) ? text : null; +} + +const PARSERS = { + amount: parseAmount, receivedDate: parseDate, serial: parseReference, voucher: parseReference, + currency: parseCurrency, + payerName: parseName, payerAccount: parseAccount, payeeName: parseName, payeeAccount: parseAccount, +}; +const LABEL_FIELD = new Map(Object.entries(LABELS).flatMap(([field, labels]) => labels.map((label) => [label, field]))); + +// Read "label value" pairs from one text line. A value runs until the next known +// label; a label without a value on the same line is ignored (no guessing from +// arbitrary numbers or neighbouring cells). +function lineValues(line) { + const text = compact(line); + const hits = [...text.matchAll(LABEL_RE)]; + const values = []; + hits.forEach((hit, index) => { + const field = LABEL_FIELD.get(hit[0]); + if (!field) return; + const raw = text.slice(hit.index + hit[0].length, hits[index + 1]?.index ?? text.length).replace(/^[::]+/u, ""); + const value = raw ? PARSERS[field](raw) : null; + // A present but unreadable amount/date is recorded as invalid, not missing. + if (value) values.push([field, value]); + else if (raw && ["amount", "receivedDate", "serial", "voucher"].includes(field)) values.push([field, INVALID]); + }); + return values; +} + +const INVALID = Symbol("invalid"); +function collect(entries) { + const found = { invalid: new Set() }; + for (const [field, value] of entries) { + if (value === INVALID) found.invalid.add(field); + else (found[field] ??= new Set()).add(value); + } + return found; +} + +// Facts that make the file unusable for this entry. Distinct amounts or +// distinct serial/voucher numbers mean more than one transaction; any foreign +// or conflicting currency is a known contradiction. Neither can be cleared by +// typing one value into the form. +function addLimits(result, found) { + const push = (block) => { if (!result.blocks.includes(block)) result.blocks.push(block); }; + if ((found.amount?.size ?? 0) > 1 || (found.serial?.size ?? 0) > 1 || (found.voucher?.size ?? 0) > 1) push("multiple_transactions"); + const currencies = [...(found.currency ?? [])]; + if (currencies.some((c) => c !== "CNY")) push("non_cny"); + if (currencies.length > 1) push("currency_conflict"); +} + +function markInvalid(result, found) { + for (const raw of found.invalid ?? []) { + const key = ["serial", "voucher"].includes(raw) ? "reference" : raw; + if (result.fields[key] === undefined && !result.fieldIssues[key]) result.fieldIssues[key] = "invalid"; + else if (result.fields[key] !== undefined) { result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; delete result.sources[key]; } + } +} + +function pageIdentifiers(entries) { + const ids = { serial: new Set(), voucher: new Set() }; + for (const [key, value] of entries) if (ids[key] && typeof value === "string") ids[key].add(value); + return ids; +} + +// Pages are linked when they share an identifier of the same kind and value; +// a serial and a voucher number printed on one page belong together. Every page +// must be reachable from the others, otherwise it is not shown to be the same +// transaction. Returns the 1-based numbers of pages outside that one group. +function unlinkedPages(pageIds) { + const n = pageIds.length; + if (n === 0) return []; + const start = pageIds.findIndex((ids) => ids.serial.size || ids.voucher.size); + if (start < 0) return pageIds.map((_, i) => i + 1); + const linked = new Set([start]); const queue = [start]; + while (queue.length) { + const i = queue.shift(); + pageIds.forEach((other, j) => { + if (linked.has(j)) return; + const shares = ["serial", "voucher"].some((kind) => [...other[kind]].some((v) => pageIds[i][kind].has(v))); + if (shares) { linked.add(j); queue.push(j); } + }); + } + return pageIds.map((_, i) => (linked.has(i) ? null : i + 1)).filter(Boolean); +} + +function emptyResult() { + return { fields: {}, fieldIssues: {}, sources: {}, blocks: [], warnings: [], direction: null, documentKind: "unknown", + uncoveredPages: [], pageIds: [], identifiers: { serial: [], voucher: [] } }; +} + +function settleField(result, key, values, source) { + if (!values?.size) return; + if (values.size > 1) { result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; return; } + const [value] = values; + if (result.fields[key] !== undefined && result.fields[key] !== value) { + result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; delete result.sources[key]; return; + } + if (result.fieldIssues[key] === "ambiguous") return; + result.fields[key] = value; result.sources[key] = result.sources[key] ?? source; +} + +function settleReference(result, found, source) { + // Serial numbers and voucher numbers are different identifiers; prefer the + // serial only when it is unique, otherwise an unambiguous voucher number. + for (const kind of ["serial", "voucher"]) { + const values = found[kind]; + if (!values?.size) continue; + if (values.size > 1) { result.fieldIssues.reference = "ambiguous"; return; } + settleField(result, "reference", values, source); + if (result.fields.reference) result.fields.referenceKind = result.fields.referenceKind ?? kind; + return; + } +} + +// Local text: classify each page with the shared document rules, then read the +// whitelisted fields. Several pages may repeat one transaction; distinct values +// mean several transactions and the file is rejected for this path. +// Table receipts emit text out of reading order. Rows are grouped by baseline +// and read left to right; field values are taken only from these rows. +// Vertical text: three or more single characters stacked tightly at one x. +function verticalColumns(items) { + const columns = new Map(); + for (const item of items ?? []) { + if ([...item.s.trim()].length !== 1) continue; + const key = [...columns.keys()].find((x) => Math.abs(x - item.x) <= 2) ?? item.x; + (columns.get(key) ?? columns.set(key, []).get(key)).push(item); + } + const result = []; + for (const [x, list] of columns) { + list.sort((a, b) => b.y - a.y); + let run = [list[0]]; + const flush = () => { if (run.length >= 3) result.push({ x, items: run, text: run.map((i) => i.s.trim()).join("") }); }; + for (const item of list.slice(1)) { + if (run.at(-1).y - item.y <= 11) run.push(item); else { flush(); run = [item]; } + } + flush(); + } + return result; +} + +function layoutRows(items, skip = new Set()) { + const rows = []; + for (const item of [...(items ?? [])].filter((i) => !skip.has(i)).sort((a, b) => b.y - a.y || a.x - b.x)) { + const row = rows.find((r) => Math.abs(r.y - item.y) <= 2); + if (row) row.items.push(item); else rows.push({ y: item.y, items: [item] }); + } + for (const row of rows) { row.items.sort((a, b) => a.x - b.x); row.text = row.items.map((i) => i.s).join(" "); } + return rows; +} + +// CCB "单位客户专用回单": an unlabelled booking date on the header row (with 币别 +// and 流水号), and payer/payee columns headed by stacked "付款人"/"收款人". +function ccbTableEntries(items, rows) { + const entries = []; + for (const row of rows) { + const text = compact(row.text); + if (!/流水号|币别/u.test(text)) continue; + const hits = [...text.matchAll(LABEL_RE)]; + for (const match of text.matchAll(/(\d{4})年(\d{1,2})月(\d{1,2})日/gu)) { + // A date immediately after a labelled date/time (打印日期 etc.) belongs to + // that label; only an unlabelled date on the header row is the booking date. + const label = hits.filter((h) => h.index + h[0].length <= match.index).at(-1); + if (label && /日期|时间/u.test(label[0]) && /^[::]*$/u.test(text.slice(label.index + label[0].length, match.index))) continue; + const date = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`; + entries.push(["receivedDate", isValidDateOnlyString(date) ? date : INVALID]); + } + } + // A column header must be exactly the word; vertical notes such as + // "…收款人回单" elsewhere on the page are not headers. + const vertical = verticalColumns(items); + const payerX = vertical.filter((c) => c.text === "付款人").map((c) => c.x); + const payeeX = vertical.filter((c) => c.text === "收款人").map((c) => c.x); + if (payerX.length !== 1 || payeeX.length !== 1 || payerX[0] >= payeeX[0]) return entries; + for (const row of rows) { + const sides = { payer: [], payee: [] }; + for (const item of row.items) { + if (Math.abs(item.x - payerX[0]) <= 2 || Math.abs(item.x - payeeX[0]) <= 2) continue; + sides[item.x < payeeX[0] ? "payer" : "payee"].push(item.s); + } + for (const [side, parts] of Object.entries(sides)) { + const text = compact(parts.join("")); + const name = text.match(/^(?:全称|户名)(.+)$/u)?.[1]; + const account = text.match(/^账号(\d{6,40})$/u)?.[1]; + if (name && parseName(name)) entries.push([`${side}Name`, parseName(name)]); + if (account) entries.push([`${side}Account`, account]); + } + } + return entries; +} + +function parseIncomingTexts(texts, layouts = null) { + const result = emptyResult(); + const documents = texts.map((text) => classifyReceiptText(text)); + const pageRows = layouts ? layouts.map((items) => layoutRows(items, new Set(verticalColumns(items).flatMap((c) => c.items)))) : null; + // Restrictions are read from both the stored order and the rebuilt reading + // order, so reordering PDF objects cannot hide a failure or non-final notice. + const readingOrder = pageRows ? pageRows.map((rows) => classifyReceiptText(rows.map((r) => r.text).join("\n"))) : []; + const restrictions = [...documents, ...readingOrder]; + if (restrictions.some((d) => d.kind === "non_final")) result.blocks.push("non_final"); + if (restrictions.some((d) => d.issues.includes("document_transaction_failed"))) result.blocks.push("transaction_failed"); + if (restrictions.some((d) => d.issues.includes("document_transaction_pending"))) result.blocks.push("transaction_pending"); + if (restrictions.some((d) => d.issues.includes("document_multiple_receipts"))) result.blocks.push("multiple_transactions"); + const directions = new Set(documents.map((d) => d.direction).filter(Boolean)); + if (directions.size > 1 || documents.some((d) => d.issues.includes("document_direction_ambiguous"))) result.warnings.push("direction_ambiguous"); + else if (directions.size === 1) result.direction = [...directions][0]; + result.documentKind = documents.every((d) => d.kind === "bank_receipt") ? "bank_receipt" : "unknown"; + const perPage = layouts + ? layouts.map((items, i) => [...pageRows[i].flatMap((r) => lineValues(r.text)), ...ccbTableEntries(items, pageRows[i])]) + : texts.map((text) => text.split(/\n/).flatMap(lineValues)); + // A page without its own amount or reference is not shown to be the same + // transaction (e.g. a scanned page after a readable one). + // Typed identifiers per page; pages are tied to one transaction only through + // shared identifiers (see linkPages). An amount alone never links pages. + result.pageIds = perPage.map((entries) => pageIdentifiers(entries)); + result.uncoveredPages = unlinkedPages(result.pageIds); + const found = collect(perPage.flat()); + // Every identifier read locally is kept for cross-source comparison, not + // only the one chosen for the form. + result.identifiers = { serial: [...(found.serial ?? [])], voucher: [...(found.voucher ?? [])] }; + addLimits(result, found); + for (const key of ["amount", "receivedDate", "currency", "payerName", "payerAccount", "payeeName", "payeeAccount"]) { + settleField(result, key, found[key], "pdf_text"); + } + settleReference(result, found, "pdf_text"); + markInvalid(result, found); + return result; +} + +// Provider fields come back as [{Name, Value}]; only exact whitelisted names +// are read and they only fill gaps. Provider text can add, never lift, limits. +function mergeProviderFields(result, pages) { + const entries = []; + for (const fields of pages) { + if (!Array.isArray(fields)) continue; + for (const field of fields.slice(0, 200)) { + const name = compact(field?.Name); const key = LABEL_FIELD.get(name); + if (key && typeof field?.Value === "string" && field.Value.trim()) { + const value = PARSERS[key](field.Value); + if (value) entries.push([key, value]); + else if (["amount", "receivedDate"].includes(key)) entries.push([key, INVALID]); + } + } + const document = classifyReceiptText(fields.map((f) => `${f?.Name ?? ""}:${f?.Value ?? ""}`).join("\n")); + if (document.kind === "non_final" && !result.blocks.includes("non_final")) result.blocks.push("non_final"); + if (document.issues.includes("document_transaction_failed") && !result.blocks.includes("transaction_failed")) result.blocks.push("transaction_failed"); + if (document.issues.includes("document_transaction_pending") && !result.blocks.includes("transaction_pending")) result.blocks.push("transaction_pending"); + } + const found = collect(entries); + addLimits(result, found); + // The provider reading a different amount or serial/voucher than the text + // layer means the file holds another transaction, not a typo to fill in. + const differs = (key, local) => local !== undefined && found[key]?.size && !(found[key].size === 1 && found[key].has(local)); + const otherId = (kind) => (result.identifiers?.[kind]?.length ?? 0) > 0 && + [...(found[kind] ?? [])].some((value) => !result.identifiers[kind].includes(value)); + if ((differs("amount", result.fields.amount) || otherId("serial") || otherId("voucher")) && + !result.blocks.includes("multiple_transactions")) result.blocks.push("multiple_transactions"); + // A provider currency that differs from a locally read one is a known + // contradiction; it never clears the local currency into "unknown". + if (result.fields.currency && found.currency?.size && !(found.currency.size === 1 && found.currency.has(result.fields.currency)) && + !result.blocks.includes("currency_conflict")) result.blocks.push("currency_conflict"); + for (const key of ["amount", "receivedDate", "payerName", "payerAccount", "payeeName", "payeeAccount"]) { + if (result.fields[key] === undefined && result.fieldIssues[key] !== "ambiguous") settleField(result, key, found[key], "ocr"); + else if (found[key]?.size && result.fields[key] !== undefined && !found[key].has(result.fields[key])) { + result.fieldIssues[key] = "ambiguous"; delete result.fields[key]; delete result.sources[key]; + } + } + if (result.fields.currency === undefined && result.fieldIssues.currency !== "ambiguous") settleField(result, "currency", found.currency, "ocr"); + if (result.fields.reference === undefined && result.fieldIssues.reference !== "ambiguous") settleReference(result, found, "ocr"); + markInvalid(result, found); +} + +function finalizeResult(result) { + if (result.fields.currency && result.fields.currency !== "CNY") result.blocks.push("non_cny"); + for (const key of REQUIRED) if (result.fields[key] === undefined && !result.fieldIssues[key]) result.fieldIssues[key] = "missing"; + result.blocks = [...new Set(result.blocks)]; + result.warnings = [...new Set(result.warnings)]; + return result; +} + +// One bounded provider pass per recognition request: each page at most once, a +// total deadline, and no retries. Unavailability leaves local values for manual +// completion. +async function providerPass(config, buffer, pageCount, createClient, { totalMs = OCR_TOTAL_MS, pages: wanted = null } = {}) { + let client; + try { client = createClient(config); } catch { return { status: "unavailable" }; } + if (!client || typeof client.BankSlipOCR !== "function") return { status: "unavailable" }; + const encoded = buffer.toString("base64"); + if (encoded.length > 7 * 1024 * 1024) return { status: "unavailable" }; + // The deadline stops further pages, not just the wait: once expired, no page + // is sent even if an earlier in-flight call completes later. + const deadline = Date.now() + totalMs; + let expired = false; + const pages = (async () => { + const out = []; + for (const pageNumber of wanted ?? Array.from({ length: pageCount }, (_, i) => i + 1)) { + if (expired || Date.now() >= deadline) throw new Error("deadline"); + const response = await client.BankSlipOCR({ ImageBase64: encoded, IsPdf: true, PdfPageNumber: pageNumber }); + out.push(response?.BankSlipInfos ?? []); + } + return out; + })(); + pages.catch(() => {}); + let timer; + try { + const result = await Promise.race([pages, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), Math.max(0, deadline - Date.now())); })]); + return { status: "used", pages: result }; + } catch { expired = true; return { status: "failed" }; } + finally { clearTimeout(timer); } +} + +async function inspectIncomingPdf(buffer, { config, createClient = createReceiptOcrClient } = {}) { + if (!Buffer.isBuffer(buffer) || !buffer.length || buffer.length > MAX_BYTES || buffer.subarray(0, 5).toString() !== "%PDF-") throw invalidFile(); + let pageCount; + try { pageCount = (await PDFDocument.load(buffer)).getPageCount(); } catch { throw invalidFile(); } + if (pageCount < 1 || pageCount > MAX_PAGES) throw invalidFile(); + const extracted = await extractReceiptTexts({ buffer, pagesExpected: pageCount, layout: true }); + const texts = extracted?.texts ?? null; + const result = texts ? parseIncomingTexts(texts, extracted.layouts) : { ...emptyResult(), + uncoveredPages: Array.from({ length: pageCount }, (_, i) => i + 1), + pageIds: Array.from({ length: pageCount }, () => ({ serial: new Set(), voucher: new Set() })) }; + let ocr = "not_needed"; + const missing = REQUIRED.some((key) => result.fields[key] === undefined && result.fieldIssues[key] !== "ambiguous"); + if (!result.blocks.length && (missing || result.uncoveredPages.length)) { + // Uncovered pages are read individually; otherwise every page once. + const pages = result.uncoveredPages.length && !missing ? result.uncoveredPages : null; + const pass = await providerPass(config, buffer, pageCount, createClient, { pages }); + ocr = pass.status; + if (pass.status === "used") { + mergeProviderFields(result, pass.pages); + const read = pages ?? Array.from({ length: pageCount }, (_, i) => i + 1); + // Provider identifiers join each page's own set under the same validity + // and linking rules as the text layer. + read.forEach((n, index) => { + const entries = (pass.pages[index] ?? []).slice(0, 200).map((f) => { + const key = LABEL_FIELD.get(compact(f?.Name)); + return ["serial", "voucher"].includes(key) && typeof f?.Value === "string" ? [key, PARSERS[key](f.Value)] : null; + }).filter((entry) => entry && entry[1]); + const ids = pageIdentifiers(entries); + for (const kind of ["serial", "voucher"]) for (const v of ids[kind]) result.pageIds[n - 1][kind].add(v); + }); + result.uncoveredPages = unlinkedPages(result.pageIds); + } + } + // Several pages that cannot all be tied to the one transaction are refused; + // a single unreadable page can still be completed by hand. + if (pageCount > 1 && result.uncoveredPages.length) result.blocks.push("pages_unverified"); + if (!texts) result.warnings.push("text_unavailable"); + return { ...finalizeResult(result), ocr, pageCount }; +} + +function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } + +function minorToMoney(minor) { + const value = BigInt(minor); + return `${value / 100n}.${String(value % 100n).padStart(2, "0")}`; +} + +function sealContext(config, payload) { + return encryptStringOrThrow(config, JSON.stringify(payload)); +} + +function openContext(config, token, { actorId, fileSha256, now = Date.now() }) { + let payload; + try { payload = JSON.parse(decryptStringOrThrow(config, token)); } catch { throw contextInvalid(); } + if (payload?.purpose !== PURPOSE || payload.v !== 1 || payload.parserVersion !== PARSER_VERSION || + payload.actorId !== actorId || payload.fileSha256 !== fileSha256 || + !Number.isFinite(payload.expiresAt) || payload.expiresAt < now || payload.issuedAt > now + 60000) throw contextInvalid(); + return payload; +} + +async function suggestParties(db, config, fields) { + const suggestions = { legalEntityId: null, lenderId: null }; + const warnings = []; + if (fields.payeeName) { + const companies = await db.legalEntity.findMany({ where: { name: fields.payeeName, enabled: true, archived_at: null, currency_code: "CNY" }, + select: { id: true }, take: 2 }); + if (companies.length === 1) suggestions.legalEntityId = companies[0].id; + else if (companies.length > 1) warnings.push("company_not_unique"); + } + if (fields.payerName) { + const lenders = await db.loanLender.findMany({ where: { name: fields.payerName, archived_at: null, + ...(suggestions.legalEntityId ? { legal_entity_id: suggestions.legalEntityId } : {}) }, include: LENDER_INCLUDE, take: 2 }); + if (lenders.length === 1) { + suggestions.lenderId = lenders[0].id; + if (fields.payerAccount) { + try { if (readLenderAccount(config, lenders[0]).bankAccount !== fields.payerAccount) warnings.push("payer_account_differs"); } + catch { warnings.push("payer_account_differs"); } + } + } else if (lenders.length > 1) warnings.push("lender_not_unique"); + } + return { suggestions, warnings }; +} + +async function recognize(db, config, request, buffer, options = {}) { + const fileSha256 = sha256(buffer); + const result = await inspectIncomingPdf(buffer, { config, ...options }); + const parties = await suggestParties(db, config, result.fields); + const now = Date.now(); + const context = sealContext(config, { + v: 1, purpose: PURPOSE, parserVersion: PARSER_VERSION, actorId: request.employee.id, fileSha256, + issuedAt: now, expiresAt: now + CONTEXT_TTL_MS, + fields: result.fields, fieldIssues: result.fieldIssues, sources: result.sources, + blocks: result.blocks, direction: result.direction, + }); + const f = result.fields; + return { + context, expiresAt: new Date(now + CONTEXT_TTL_MS).toISOString(), ocr: result.ocr, pageCount: result.pageCount, + documentKind: result.documentKind, direction: result.direction, blocks: result.blocks, + warnings: [...new Set([...result.warnings, ...parties.warnings])], fieldIssues: result.fieldIssues, sources: result.sources, + fields: { + originalPrincipal: f.amount ? minorToMoney(f.amount) : null, receivedDate: f.receivedDate ?? null, + originReference: f.reference ?? null, referenceKind: f.referenceKind ?? null, currency: f.currency ?? null, + payerName: f.payerName ?? null, payerAccountMasked: f.payerAccount ? maskBankAccount(f.payerAccount) : null, + payeeName: f.payeeName ?? null, payeeAccountMasked: f.payeeAccount ? maskBankAccount(f.payeeAccount) : null, + }, + suggestions: parties.suggestions, + }; +} + +// Corrections are recorded by field name only; values stay in the business row. +function corrections(context, body) { + const f = context.fields ?? {}; + const changed = []; + if (f.amount && !new Prisma.Decimal(minorToMoney(f.amount)).eq(body.originalPrincipal)) changed.push("originalPrincipal"); + if (f.receivedDate && f.receivedDate !== body.receivedDate) changed.push("receivedDate"); + if (f.reference && f.reference !== body.originReference) changed.push("originReference"); + return changed; +} + +function assertSavable(context, lender) { + if (context.blocks?.length) throw blocked(); + const company = lender.legal_entity?.name ? compact(lender.legal_entity.name) : null; + const f = context.fields ?? {}; + // Company money going out, or money arriving at another entity, is not a + // borrow-in of this company. A personal debit slip from the lender is fine. + if (context.direction === "debit" && company && f.payerName && compact(f.payerName) === company) throw blocked(); + if (f.payeeName && company && compact(f.payeeName) !== company) throw blocked(); +} + +function sameRegistration(loan, body, fileSha256) { + return loan.lender_id === body.lenderId && loan.origin_reference === body.originReference && + new Prisma.Decimal(loan.original_principal).eq(body.originalPrincipal) && + new Prisma.Decimal(loan.opening_outstanding_principal).eq(body.openingOutstandingPrincipal) && + loan.received_date.toISOString().slice(0, 10) === body.receivedDate && + loan.balance_as_of.toISOString().slice(0, 10) === body.balanceAsOf && loan.historical_entry === body.historicalEntry && + (loan.evidences ?? []).some((e) => e.kind === "incoming" && e.file_sha256 === fileSha256 && !e.archived_at); +} + +async function assertIncomingUnused(tx, lenderId, fileSha256, exceptLoanId = null) { + const existing = await tx.loanEvidence.findFirst({ where: { kind: "incoming", file_sha256: fileSha256, archived_at: null, + loan: { lender_id: lenderId, ...(exceptLoanId ? { id: { not: exceptLoanId } } : {}) } }, select: { id: true } }); + if (existing) throw buildBusinessValidationError("loan.incoming_receipt_duplicate", {}, 409); +} + +// The file is written before the short transaction; the loan, its incoming +// original and both audits commit together or not at all. The caller deletes +// the new object whenever this does not return a freshly created record. +async function saveFromReceipt(prisma, config, request, { body, file, storagePath, timeZone }) { + if (body.balanceAsOf > currentDateString(timeZone)) throw buildBusinessValidationError("loan.invalid_input", {}, 400); + const actor = request.employee.id; + const lookup = (db) => db.companyLoan.findUnique({ where: { created_by_request_key: { created_by: actor, request_key: body.requestKey } }, include: LOAN_INCLUDE }); + try { + return await prisma.$transaction(async (tx) => { + const lender = await lockLender(tx, body.lenderId); + const previous = await lookup(tx); + if (previous) { + if (!sameRegistration(previous, body, file.fileSha256)) throw buildBusinessValidationError("loan.duplicate_registration", {}, 409); + return { loan: previous, created: false }; + } + // A lost response may be retried with the same key after the context expired. + const context = openContext(config, body.context, { actorId: actor, fileSha256: file.fileSha256 }); + if (lender.archived_at) throw buildBusinessValidationError("loan.not_found", {}, 404); + // The lender finance checked in the form must be the one saved against. + if (lender.identity_version !== body.expectedLenderIdentityVersion || lender.account_version !== body.expectedLenderAccountVersion) { + throw buildBusinessValidationError("loan.version_changed", {}, 409); + } + await validateLenderReferences(tx, { legalEntityId: lender.legal_entity_id, bankInstitutionId: lender.bank_institution_id }); + assertSavable(context, lender); + await assertIncomingUnused(tx, lender.id, file.fileSha256); + const id = randomUUID(); + const numbering = await resolveNumberingContext(tx); + const loanNo = numbering.version === 2 + ? (await issueBusinessNumber(tx, { family: "company_loan", ...numbering, timeZone: config.appTimezone })).documentNumber + : `JK-${id}`; + await tx.companyLoan.create({ data: { + id, loan_no: loanNo, origin_reference: body.originReference, + legal_entity_id: lender.legal_entity_id, lender_id: lender.id, + original_principal: body.originalPrincipal, opening_outstanding_principal: body.openingOutstandingPrincipal, + received_date: parseDateOnlyString(body.receivedDate), balance_as_of: parseDateOnlyString(body.balanceAsOf), + historical_entry: body.historicalEntry, created_by: actor, updated_by: actor, request_key: body.requestKey, + } }); + const evidence = await tx.loanEvidence.create({ data: { + loan_id: id, kind: "incoming", original_name: file.originalName, storage_path: storagePath, + mime_type: file.mimeType, file_size: file.fileSize, file_sha256: file.fileSha256, + governance_status: "approved", uploaded_by: actor, + } }); + await auditLoan(tx, request, "COMPANY_LOAN_REGISTERED", id, { historicalEntry: body.historicalEntry, source: "incoming_receipt", + parserVersion: PARSER_VERSION, fileSha256: file.fileSha256, fieldSources: context.sources ?? {}, + correctedFields: corrections(context, body) }); + await auditLoan(tx, request, "COMPANY_LOAN_EVIDENCE_UPLOADED", id, { evidenceId: evidence.id, kind: "incoming" }); + return { loan: await tx.companyLoan.findUniqueOrThrow({ where: { id }, include: LOAN_INCLUDE }), created: true }; + }); + } catch (error) { + if (error?.code !== "P2002") throw error; + const existing = await lookup(prisma); + if (existing && sameRegistration(existing, body, file.fileSha256)) return { loan: existing, created: false }; + throw buildBusinessValidationError("loan.duplicate_registration", {}, 409); + } +} + +module.exports = { + providerPass, PARSER_VERSION, PURPOSE, CONTEXT_TTL_MS, MAX_BYTES, + parseIncomingTexts, mergeProviderFields, finalizeResult, inspectIncomingPdf, recognize, + openContext, sealContext, saveFromReceipt, assertIncomingUnused, corrections, assertSavable, sha256, +}; diff --git a/server/src/services/paymentReceiptDocument.js b/server/src/services/paymentReceiptDocument.js index b1553102..c7b7ecd9 100644 --- a/server/src/services/paymentReceiptDocument.js +++ b/server/src/services/paymentReceiptDocument.js @@ -98,34 +98,46 @@ function safeManualProof(proof) { ...(proof.basis === "booked_debit" ? { bookedOn: proof.bookedOn } : {}) }; } +// Bound hostile PDF parsing independently of the request/event loop. No URLs, +// external fonts, OCR calls, raw text logging or persisted extraction payloads. +// Returns one text per page, or null when the document cannot be read in bounds. +function extractReceiptTexts({ buffer, pagesExpected, layout = false }) { + if (!Number.isInteger(pagesExpected) || pagesExpected < 1 || pagesExpected > 20 || + !Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) return Promise.resolve(null); + return new Promise((resolve) => { + const worker = new Worker(path.join(__dirname, "paymentReceiptDocumentWorker.js"), { + workerData: { bytes: new Uint8Array(buffer), pagesExpected, layout }, + resourceLimits: { maxOldGenerationSizeMb: 128 }, stdout: true, stderr: true, + }); + worker.stdout.resume(); worker.stderr.resume(); + let settled = false; + const finish = (texts) => { + if (settled) return; + settled = true; clearTimeout(timer); void worker.terminate(); resolve(texts); + }; + const timer = setTimeout(() => finish(null), 5000); + worker.once("message", (message) => { + const valid = Array.isArray(message?.texts) && message.texts.length === pagesExpected && + message.texts.every((text) => typeof text === "string") && + (!layout || (Array.isArray(message.layouts) && message.layouts.length === pagesExpected)); + // With layout, callers get { texts, layouts }; the default stays a text array. + finish(valid ? (layout ? { texts: message.texts, layouts: message.layouts } : message.texts) : null); + }); + worker.once("error", () => finish(null)); + worker.once("exit", () => finish(null)); + }); +} + async function inspectReceiptDocument({ buffer, mimeType, pagesExpected }) { if (!Number.isInteger(pagesExpected) || pagesExpected < 1 || pagesExpected > 20) return []; const unknown = () => Array.from({ length: pagesExpected }, (_, index) => ({ pageNumber: index + 1, document: unknownDocument(mimeType === "application/pdf" ? "unreadable" : "image"), })); - if (mimeType !== "application/pdf" || !Buffer.isBuffer(buffer) || !buffer.length || buffer.length > 5 * 1024 * 1024) return unknown(); - // Bound hostile PDF parsing independently of the request/event loop. No URLs, - // external fonts, OCR calls, raw text logging or persisted extraction payloads. - return new Promise((resolve) => { - const worker = new Worker(path.join(__dirname, "paymentReceiptDocumentWorker.js"), { - workerData: { bytes: new Uint8Array(buffer), pagesExpected }, - resourceLimits: { maxOldGenerationSizeMb: 128 }, stdout: true, stderr: true, - }); - worker.stdout.resume(); worker.stderr.resume(); - let settled = false; - const finish = (pages) => { - if (settled) return; - settled = true; clearTimeout(timer); void worker.terminate(); resolve(pages); - }; - const timer = setTimeout(() => finish(unknown()), 5000); - worker.once("message", (message) => { - if (!Array.isArray(message?.texts) || message.texts.length !== pagesExpected) return finish(unknown()); - finish(message.texts.map((text, index) => ({ pageNumber: index + 1, document: classifyReceiptText(text) }))); - }); - worker.once("error", () => finish(unknown())); - worker.once("exit", () => finish(unknown())); - }); + if (mimeType !== "application/pdf") return unknown(); + const texts = await extractReceiptTexts({ buffer, pagesExpected }); + if (!texts) return unknown(); + return texts.map((text, index) => ({ pageNumber: index + 1, document: classifyReceiptText(text) })); } module.exports = { DOCUMENT_VERSION, unknownDocument, classifyReceiptText, withProviderRestrictions, - safeDocument, safeManualProof, documentForPage, documentBlocksPayment, paymentDocumentReady, inspectReceiptDocument }; + safeDocument, safeManualProof, documentForPage, documentBlocksPayment, paymentDocumentReady, inspectReceiptDocument, extractReceiptTexts }; diff --git a/server/src/services/paymentReceiptDocumentWorker.js b/server/src/services/paymentReceiptDocumentWorker.js index 73ebd281..a62b5df4 100644 --- a/server/src/services/paymentReceiptDocumentWorker.js +++ b/server/src/services/paymentReceiptDocumentWorker.js @@ -8,24 +8,26 @@ const { parentPort, workerData } = require("node:worker_threads"); isEvalSupported: false, useSystemFonts: false, disableFontFace: true, useWorkerFetch: false, useWasm: false }); const pdf = await task.promise; if (pdf.numPages !== workerData.pagesExpected) throw new Error("page_count"); - const texts = []; + const texts = []; const layouts = []; for (let number = 1; number <= pdf.numPages; number++) { const page = await pdf.getPage(number); const content = await page.getTextContent(); if (content.items.length > 20000) throw new Error("text_limit"); let y = null; - const pieces = []; + const pieces = []; const layout = []; for (const item of content.items) { if (typeof item.str !== "string") continue; const nextY = Math.round(item.transform?.[5] ?? 0); + // Positions only on request: table receipts need row/column order. + if (workerData.layout && item.str.trim()) layout.push({ s: item.str, x: Math.round(item.transform?.[4] ?? 0), y: nextY }); if (y !== null && Math.abs(nextY - y) > 2) pieces.push("\n"); pieces.push(item.str, item.hasEOL ? "\n" : " "); y = nextY; } const text = pieces.join(""); if (text.length > 100000) throw new Error("text_limit"); - texts.push(text); page.cleanup(); + texts.push(text); layouts.push(layout); page.cleanup(); } - parentPort.postMessage({ texts }); + parentPort.postMessage(workerData.layout ? { texts, layouts } : { texts }); } catch { parentPort.postMessage({ failed: true }); } finally { diff --git a/server/src/services/paymentReceiptMatching.js b/server/src/services/paymentReceiptMatching.js index b8888914..3bee3443 100644 --- a/server/src/services/paymentReceiptMatching.js +++ b/server/src/services/paymentReceiptMatching.js @@ -111,4 +111,4 @@ function matchBankSlip(receipt, candidates) { return finish("mismatch", [...new Set(comparisons.flatMap((comparison) => comparison.reasons))]); } -module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits }; +module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits, normalizeCurrency }; diff --git a/server/src/services/paymentReceiptOcr.js b/server/src/services/paymentReceiptOcr.js index c6f99ef5..3e0ad920 100644 --- a/server/src/services/paymentReceiptOcr.js +++ b/server/src/services/paymentReceiptOcr.js @@ -73,4 +73,4 @@ async function recognizePaymentReceipt({ buffer, mimeType, config, createClient return { ...result, status: result.pages.every((page) => page.issues.length === 0) ? "recognized" : "needs_review" }; } -module.exports = { recognizePaymentReceipt }; +module.exports = { recognizePaymentReceipt, createReceiptOcrClient }; diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index 559bd6e9..51d36838 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -1,4 +1,8 @@ { + "loan.incoming_receipt_invalid": "请上传 5MB 以内、不超过 20 页的单份 PDF 原借入回单;文件损坏或无法安全读取时请改用手工登记。", + "loan.incoming_receipt_blocked": "该回单不能作为本笔借入原件:可能是非最终回执、失败或处理中、公司支出、多笔交易、非人民币或收款主体不符。请更换正式入账回单或改用手工登记。", + "loan.incoming_receipt_context_invalid": "识别结果已过期,或与当前账号、所选文件不一致,请重新上传并识别。", + "loan.incoming_receipt_duplicate": "该原件已用于本出借人的其他借款登记,不能重复登记;请核对已有借款。", "loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。", "loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。", "loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。", diff --git a/server/tests/unit/services/loanIncomingReceipt.test.js b/server/tests/unit/services/loanIncomingReceipt.test.js new file mode 100644 index 00000000..a45c2aaa --- /dev/null +++ b/server/tests/unit/services/loanIncomingReceipt.test.js @@ -0,0 +1,353 @@ +const incoming = require("../../../src/services/loanIncomingReceipt"); +const { syntheticReceiptText, syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs"); + +const config = { fieldEncryptionKey: Buffer.alloc(32, 9).toString("base64") }; +const credit = (options = {}) => syntheticReceiptText({ direction: "credit", status: "booked", amount: "50,000.00", + reference: "SYN-IN-0001", bookedOn: "2025-03-14", extraLines: ["付款人名称 合成出借人", "收款人名称 合成公司"], ...options }); +const parse = (...texts) => incoming.finalizeResult(incoming.parseIncomingTexts(texts)); + +describe("incoming receipt field extraction", () => { + test("reads amount, booking date, reference and parties from labelled lines", () => { + const result = parse(credit()); + expect(result.blocks).toEqual([]); + expect(result.direction).toBe("credit"); + expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001", + referenceKind: "voucher", currency: "CNY", payerName: "合成出借人", payeeName: "合成公司" }); + expect(result.sources).toMatchObject({ amount: "pdf_text", receivedDate: "pdf_text", reference: "pdf_text" }); + expect(result.fieldIssues).toEqual({}); + }); + test("a stable serial number is preferred over a voucher number, kept as text", () => { + const result = parse(credit({ extraLines: ["交易流水号 0012345678"] })); + expect(result.fields.reference).toBe("0012345678"); + expect(result.fields.referenceKind).toBe("serial"); + }); + test("conflicting dates are left empty rather than choosing one", () => { + const result = parse(credit({ extraLines: ["交易日期 2025-03-15"] })); + expect(result.fields.receivedDate).toBeUndefined(); + expect(result.fieldIssues.receivedDate).toBe("ambiguous"); + }); + test("print stamps and capital amounts are never read as business values", () => { + const result = parse(credit({ extraLines: ["打印日期 2026-10-09", "大写金额 伍万元整"] })); + expect(result.fields.receivedDate).toBe("2025-03-14"); + expect(result.fields.amount).toBe("5000000"); + }); + test("pages repeating one transaction are merged; different transactions are rejected", () => { + expect(parse(credit(), credit()).blocks).toEqual([]); + const result = parse(credit(), credit({ amount: "20.00", reference: "SYN-IN-0002" })); + expect(result.blocks).toContain("multiple_transactions"); + expect(result.fields.amount).toBeUndefined(); + }); + test.each([ + [{ final: false }, "non_final"], [{ status: "failed" }, "transaction_failed"], + [{ status: "pending" }, "transaction_pending"], [{ currency: "USD" }, "non_cny"], + ])("restricted document %j is blocked", (options, block) => { + expect(parse(credit(options)).blocks).toContain(block); + }); + test("two receipts on one page are blocked", () => { + expect(parse(credit() + "\n" + credit({ amount: "1.00" })).blocks).toContain("multiple_transactions"); + }); + test("missing required fields are reported, not invented", () => { + const result = parse("中国建设银行单位客户专用回单\n贷\n摘要 借款"); + expect(result.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" }); + expect(result.fields).toEqual({}); + }); +}); + +describe("review counterexamples (2026-10-10)", () => { + test("same amount with different voucher numbers is several transactions, not a missing reference", () => { + const result = parse(credit({ reference: "SYNTHETIC-VOUCHER-A" }), credit({ reference: "SYNTHETIC-VOUCHER-B" })); + expect(result.blocks).toContain("multiple_transactions"); + }); + test("a provider reporting different serial numbers with one amount is several transactions", () => { + const result = incoming.parseIncomingTexts([""]); + incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-A" }], + [{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-B" }]]); + expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions"); + }); + test("one transaction repeated across pages remains allowed", () => { + expect(parse(credit(), credit()).blocks).toEqual([]); + }); + test("a provider currency never clears a locally known one", () => { + const local = incoming.parseIncomingTexts([credit()]); + incoming.mergeProviderFields(local, [[{ Name: "币别", Value: "美元" }, { Name: "币种", Value: "USD" }]]); + expect(incoming.finalizeResult(local).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"])); + expect(local.fields.currency).toBe("CNY"); + }); + test("a locally known foreign currency blocks before any provider call", async () => { + const createClient = vi.fn(); + const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked", + currency: "USD", bookedOn: "not-a-date" }), { config, createClient }); + expect(createClient).not.toHaveBeenCalled(); + expect(result.blocks).toContain("non_cny"); + }); + test("conflicting currencies within the document are a known contradiction", () => { + expect(parse(credit({ extraLines: ["币种 USD"] })).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"])); + }); + test.each([["1,23.45"], ["1234.567"], ["50000.00USD"], ["1.2.3"], ["¥"]])("malformed amount %s is invalid, never truncated", (amount) => { + const result = parse(credit({ amount })); + expect(result.fields.amount).toBeUndefined(); + expect(result.fieldIssues.amount).toBe("invalid"); + }); + test.each([["¥1,234.50元", "123450"], ["人民币1234.5", "123450"], ["0.01", "1"], ["999,999,999,999.99", "99999999999999"]])( + "well-formed amount %s is read whole", (amount, minor) => { + expect(parse(credit({ amount })).fields.amount).toBe(minor); + }); + test("the provider deadline stops later pages, not just the wait", async () => { + let calls = 0; + const BankSlipOCR = vi.fn(() => { calls += 1; return new Promise((resolve) => setTimeout(() => resolve({ BankSlipInfos: [] }), 30)); }); + const pass = await incoming.providerPass(config, Buffer.from("%PDF-synthetic"), 3, () => ({ BankSlipOCR }), { totalMs: 10 }); + expect(pass.status).toBe("failed"); + const atReturn = calls; + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(calls).toBe(atReturn); + expect(calls).toBe(1); + }); +}); + +describe("CCB table layout (synthetic replica of the real borrow-in receipt)", () => { + const inspect = async (options) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient: vi.fn() }); + test("reads every field from out-of-order table items without any provider call", async () => { + const createClient = vi.fn(); + const result = await incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(), { config, createClient }); + expect(createClient).not.toHaveBeenCalled(); + expect(result).toMatchObject({ ocr: "not_needed", blocks: [], direction: "credit", documentKind: "bank_receipt", fieldIssues: {} }); + expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "123456789AB12CDEFG3", referenceKind: "serial", + currency: "CNY", payerName: "合成甲", payerAccount: "6222000011112222", payeeName: "合成公司", payeeAccount: "33050161000000000001" }); + }); + test("a vertical copy note level with the amount row neither pollutes the amount nor becomes a column header", async () => { + const result = await inspect({ copyNote: "(收款人回单)(第一联)" }); + expect(result.fields.amount).toBe("5000000"); + expect(result.fields.payeeName).toBe("合成公司"); + }); + test("baseline jitter within one row still reads the row left to right", async () => { + expect((await inspect({ dateDy: 1 })).fields.receivedDate).toBe("2025-03-14"); + expect((await inspect({ dateDy: -2 })).fields.receivedDate).toBe("2025-03-14"); + }); + test("two different unlabelled header dates are ambiguous", async () => { + const result = await inspect({ extraItems: [["2025年03月15日", 150, 785]] }); + expect(result.fields.receivedDate).toBeUndefined(); + expect(result.fieldIssues.receivedDate).toBe("ambiguous"); + }); + test("an impossible header date is invalid, not shifted", async () => { + const result = await inspect({ date: ["2025", "02", "30"] }); + expect(result.fieldIssues.receivedDate).toBe("invalid"); + }); + test("parties are only read when the payer column is left of the payee column", async () => { + const swapped = await inspect({ headers: ["收款人", "付款人"] }); + expect(swapped.fields.payerName).toBeUndefined(); + expect(swapped.fields.payeeName).toBeUndefined(); + expect(swapped.fields.amount).toBe("5000000"); + }); + test("a malformed lower-case amount is invalid even in the table layout", async () => { + const result = await inspect({ amount: "50,00.00" }); + expect(result.fields.amount).toBeUndefined(); + expect(result.fieldIssues.amount).toBe("invalid"); + }); + test("a non-final notice still blocks the table layout", async () => { + const result = await inspect({ extraItems: [["本回执不作为收、付款方交易的最终依据。", 25, 575]] }); + expect(result.blocks).toContain("non_final"); + }); +}); + +describe("second review counterexamples (2026-10-10)", () => { + const inspect = async (options, createClient = vi.fn()) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient }); + test.each([[["交易失败", 140, 650], ["交易状态", 37, 650]], [["处理中", 140, 650], ["交易状态", 37, 650]], + [["交易的最终依据。", 140, 575], ["本回执不作为收、付款方", 25, 575]]])( + "restrictions stored value-first are still read in reading order: %j", async (...extraItems) => { + const result = await inspect({ extraItems }); + expect(result.blocks.some((b) => ["transaction_failed", "transaction_pending", "non_final"].includes(b))).toBe(true); + }); + test("a labelled print date on the header row is never the booking date", async () => { + const result = await inspect({ date: null, extraItems: [["打印日期:2026年10月08日", 140, 785]] }); + expect(result.fields.receivedDate).toBeUndefined(); + expect(result.fieldIssues.receivedDate).toBe("missing"); + }); + test("a labelled print date beside the unlabelled booking date leaves the booking date", async () => { + expect((await inspect({ extraItems: [["打印日期:2026年10月08日", 450, 785]] })).fields.receivedDate).toBe("2025-03-14"); + }); + test.each([["2025-03-149"], ["2025-03-14/2025-04-14"]])("labelled date %s must be one whole date", (date) => { + expect(parse(credit({ bookedOn: date })).fieldIssues.receivedDate).toBe("invalid"); + }); + async function readablePlusScanned() { + const { PDFDocument } = require("pdf-lib"); + const doc = await PDFDocument.create(); + const source = await PDFDocument.load(await syntheticCcbIncomingPdf()); + doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage(); + return Buffer.from(await doc.save()); + } + test("a scanned page of another transaction after a readable page is read and blocked", async () => { + const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "9.00" }, { Name: "流水号", Value: "OTHER-2" }] })); + const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); + expect(BankSlipOCR).toHaveBeenCalledTimes(1); + expect(BankSlipOCR.mock.calls[0][0].PdfPageNumber).toBe(2); + expect(result.blocks).toContain("multiple_transactions"); + }); + test("an unverifiable extra page blocks when no provider is available", async () => { + const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => null }); + expect(result.blocks).toContain("pages_unverified"); + }); + test("a scanned repeat of the same transaction is accepted", async () => { + const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" }, { Name: "流水号", Value: "123456789AB12CDEFG3" }] })); + const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); + expect(result.blocks).toEqual([]); + }); + test.each([ + ["empty provider page", []], + ["unparseable serial", [{ Name: "流水号", Value: "?" }]], + ["unparseable amount", [{ Name: "金额", Value: "无法识别" }]], + ["same amount without any identifier", [{ Name: "金额", Value: "50,000.00" }]], + ])("a scanned page is not verified by %s", async (_label, fields) => { + const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: fields })); + const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); + expect(result.blocks).toContain("pages_unverified"); + }); + test("a readable page with only the same amount and no identifier is not verified", async () => { + const { PDFDocument } = require("pdf-lib"); + const doc = await PDFDocument.create(); + for (const options of [{}, { serial: null }]) { + const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options)); + doc.addPage((await doc.copyPages(source, [0]))[0]); + } + const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => null }); + expect(result.blocks).toContain("pages_unverified"); + }); + test("a different secondary voucher number is another transaction even with the same serial", async () => { + const { PDFDocument } = require("pdf-lib"); + const doc = await PDFDocument.create(); + const source = await PDFDocument.load(await syntheticCcbIncomingPdf({ extraItems: [["凭证号 LOCAL-VOUCHER", 293, 702]] })); + doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage(); + const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" }, + { Name: "流水号", Value: "123456789AB12CDEFG3" }, { Name: "凭证号", Value: "OTHER-VOUCHER" }] })); + const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) }); + expect(result.fields.referenceKind).toBe("serial"); + expect(result.blocks).toContain("multiple_transactions"); + }); + async function twoCcbPages(first, second) { + const { PDFDocument } = require("pdf-lib"); + const doc = await PDFDocument.create(); + for (const options of [first, second]) { + const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options)); + doc.addPage((await doc.copyPages(source, [0]))[0]); + } + return Buffer.from(await doc.save()); + } + const voucherOnly = (voucher) => ({ serial: null, extraItems: [[`凭证号 ${voucher}`, 293, 702]] }); + test("a serial on one page and an unrelated voucher on the other are not linked", async () => { + const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A" }, voucherOnly("VOUCHER-B")), { config, createClient: () => null }); + expect(result.blocks).toContain("pages_unverified"); + }); + test("the same text under different identifier kinds is not a link", async () => { + const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SAME-0001" }, voucherOnly("SAME-0001")), { config, createClient: () => null }); + expect(result.blocks).toContain("pages_unverified"); + }); + test("a page carrying both numbers links a later page that repeats one of them", async () => { + const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A", extraItems: [["凭证号 VOUCHER-B", 293, 702]] }, + voucherOnly("VOUCHER-B")), { config, createClient: () => null }); + expect(result.blocks).toEqual([]); + }); + test.each([["SERIAL-A/001"], ["SERIAL-A?"], ["A".repeat(70)]])("identifier %s is invalid as a whole, never cut to a prefix", (reference) => { + const result = parse(credit({ reference })); + expect(result.fields.reference).toBeUndefined(); + expect(result.fieldIssues.reference).toBe("invalid"); + }); + test("same prefix with different suffixes cannot collapse into one identifier", async () => { + const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIALA/001" }, { serial: "SERIALA/002" }), { config, createClient: () => null }); + expect(result.blocks).toContain("pages_unverified"); + expect(result.fields.reference).toBeUndefined(); + }); + test("a provider identifier with an invalid suffix does not verify a page", async () => { + const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "流水号", Value: "123456789AB12CDEFG3?" }] })); + const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) }); + expect(result.blocks).toContain("pages_unverified"); + }); + test("a provider transaction different from the text layer is several transactions", () => { + const result = incoming.parseIncomingTexts([credit({ reference: "LOCAL-A", amount: "10.00" })]); + incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "20.00" }, { Name: "凭证号", Value: "OCR-B" }]]); + expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions"); + }); +}); + +describe("bounded provider fallback", () => { + const providerFields = [{ Name: "金额", Value: "1,234.50" }, { Name: "记账日期", Value: "2025年1月2日" }, + { Name: "流水号", Value: "OCR-77" }, { Name: "币别", Value: "人民币" }]; + test("a readable PDF with all required fields makes no provider call", async () => { + const createClient = vi.fn(); + const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked", + reference: "SYN-PDF-1", bookedOn: "2025-02-03", amount: "88.00" }), { config, createClient }); + expect(createClient).not.toHaveBeenCalled(); + expect(result.ocr).toBe("not_needed"); + expect(result.fields).toMatchObject({ amount: "8800", receivedDate: "2025-02-03", reference: "SYN-PDF-1" }); + }); + test("an unreadable PDF uses each page at most once and only fills gaps", async () => { + const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: providerFields })); + const { PDFDocument } = require("pdf-lib"); + const doc = await PDFDocument.create(); doc.addPage(); doc.addPage(); + const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) }); + expect(BankSlipOCR).toHaveBeenCalledTimes(2); + expect(result.ocr).toBe("used"); + expect(result.fields).toMatchObject({ amount: "123450", receivedDate: "2025-01-02", reference: "OCR-77" }); + expect(result.sources.amount).toBe("ocr"); + }); + test("provider unavailable or failing leaves manual completion without retries", async () => { + const { PDFDocument } = require("pdf-lib"); + const doc = await PDFDocument.create(); doc.addPage(); + const pdf = Buffer.from(await doc.save()); + expect((await incoming.inspectIncomingPdf(pdf, { config, createClient: () => null })).ocr).toBe("unavailable"); + const BankSlipOCR = vi.fn(async () => { throw new Error("quota"); }); + const failed = await incoming.inspectIncomingPdf(pdf, { config, createClient: () => ({ BankSlipOCR }) }); + expect(BankSlipOCR).toHaveBeenCalledTimes(1); + expect(failed.ocr).toBe("failed"); + expect(failed.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" }); + }); + test("provider text can add restrictions but never overwrite local values", () => { + const result = incoming.parseIncomingTexts([credit()]); + incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "1.00" }, { Name: "备注", Value: "本回执不作为收付款方交易的最终依据" }]]); + incoming.finalizeResult(result); + expect(result.blocks).toContain("non_final"); + expect(result.fields.amount).toBeUndefined(); + expect(result.fieldIssues.amount).toBe("ambiguous"); + }); + test.each(["not a pdf", ""])("rejects non-PDF input %j", async (text) => { + await expect(incoming.inspectIncomingPdf(Buffer.from(text), { config })).rejects.toMatchObject({ details: { reasonCode: "loan.incoming_receipt_invalid" } }); + }); +}); + +describe("sealed prefill context", () => { + const base = { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION, actorId: "actor-1", fileSha256: "a".repeat(64), + issuedAt: 1000, expiresAt: 1000 + incoming.CONTEXT_TTL_MS, fields: {}, blocks: [] }; + test("opens only for the same actor, file and lifetime", () => { + const token = incoming.sealContext(config, base); + expect(incoming.openContext(config, token, { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 }).actorId).toBe("actor-1"); + for (const binding of [{ actorId: "actor-2", fileSha256: "a".repeat(64), now: 2000 }, + { actorId: "actor-1", fileSha256: "b".repeat(64), now: 2000 }, + { actorId: "actor-1", fileSha256: "a".repeat(64), now: base.expiresAt + 1 }]) { + expect(() => incoming.openContext(config, token, binding)).toThrow(); + } + }); + test("tampered, foreign-purpose or plain tokens are rejected", () => { + const token = incoming.sealContext(config, base); + const binding = { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 }; + expect(() => incoming.openContext(config, `${token.slice(0, -4)}AAAA`, binding)).toThrow(); + expect(() => incoming.openContext(config, incoming.sealContext(config, { ...base, purpose: "other" }), binding)).toThrow(); + expect(() => incoming.openContext(config, Buffer.from(JSON.stringify(base)).toString("base64"), binding)).toThrow(); + }); +}); + +describe("save guards", () => { + const lender = { legal_entity: { name: "合成公司" } }; + test("company payment out or money to another entity is not a borrow-in", () => { + expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成公司" } }, lender)).toThrow(); + expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "另一公司" } }, lender)).toThrow(); + expect(() => incoming.assertSavable({ blocks: ["non_final"], direction: "credit", fields: {} }, lender)).toThrow(); + }); + test("a credit to the company, or a lender's own debit slip, can be saved", () => { + expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "合成公司" } }, lender)).not.toThrow(); + expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成出借人" } }, lender)).not.toThrow(); + expect(() => incoming.assertSavable({ blocks: [], direction: null, fields: {} }, lender)).not.toThrow(); + }); + test("records corrected field names only", () => { + const context = { fields: { amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001" } }; + expect(incoming.corrections(context, { originalPrincipal: "50000.00", receivedDate: "2025-03-15", originReference: "SYN-IN-0001" })) + .toEqual(["receivedDate"]); + }); +});