借款登记支持上传原借入回单识别预填(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增识别与保存接口:可读 PDF 本地解析,必要时有界调用现有回单 OCR;预填上下文加密绑定经办人、文件和 30 分钟
- 适配建行单位客户专用回单表格版式:按位置归行排序、竖排栏头、顶行无标签日期、大小写金额
- 拦截非最终、失败/处理中、多笔交易(含跨页无关联、编号冲突)、非人民币、公司支出和其他收款主体
- 金额、日期、编号整段校验;借款与原件同事务保存,失败回滚并清理对象;同一出借人原件唯一
- 保存绑定所选出借人版本;前端冻结核对时版本,迟到结果与切换账号不覆盖、不重发
- 测试:单测、隔离 HTTP/浏览器、变异;对账浏览器场景固定 UTC;五轮独立复核通过
- 无新增迁移

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-10 10:34:40 +08:00
共同撰写人 Claude Opus 5.5
父节点 45737ccb9a
当前提交 2063ff1e93
共修改 22 个文件,包含 2056 行新增和 47 行删除
+48 -3
查看文件
@@ -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> <FFFF> 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 };