/** * 文件作用:处理财务发票的识别、判重、入库、台账和统计接口。 * 职责范围:调用 OCR 服务并使用正则提取发票字段、维护判重事务和返回页面所需数据。 * 不负责:文件二进制上传、奖惩流程和历史发票数据导入。 */ 'use strict'; const moment = require('moment'); const superagent = require('superagent'); const path = require('path'); const os = require('os'); const fsPromises = require('fs').promises; const { Op, fn, col } = require('sequelize'); const { v4: uuidv4 } = require('uuid'); const XLSX = require('xlsx-js-style'); const { convertPdfToImages } = require('../utils/pdfToImages'); const { reportBusinessCall } = require('../services/dashboardReporter'); const MAX_UPLOAD_COUNT = 20; const FINANCE_INVOICE_OCR_DETECTION_MODEL = 'PP-OCRv6_medium_det'; const FINANCE_INVOICE_OCR_RECOGNITION_MODEL = 'PP-OCRv6_medium_rec'; const OCR_SUPPORTED_FILE_SUFFIXES = ['.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff', '.webp', '.pdf', '.docx']; const INVOICE_DOCUMENT_KIND = Object.freeze({ GENERAL: '', ROAD_PASSENGER: '公路客运票', AVIATION: '航空运输电子客票行程单', TRANSPORT: '交通运输电子发票', RAILWAY: '铁路电子客票', MEDICAL: '医疗收费票据', FINANCIAL: '财政电子票据', MACHINE_PRINTED: '通用机打发票', FIXED_AMOUNT: '定额发票', }); // 发票类型识别按顺序执行,特征更明确的交通票据必须排在通用关键词之前。 const INVOICE_DOCUMENT_KIND_RULES = Object.freeze([ { kind: INVOICE_DOCUMENT_KIND.ROAD_PASSENGER, requiredPatterns: [/(?:发票号码|发票代码)/, /(?:讫站|乘车日期|开车时间|检票口|车型)/], }, { kind: INVOICE_DOCUMENT_KIND.AVIATION, requiredPatterns: [/(?:承运人|航班号|电子客票号码|客票行程单|燃油附加费|民航发展基金)/], }, { kind: INVOICE_DOCUMENT_KIND.TRANSPORT, requiredPatterns: [/(?:出行人|有效身份证件号|交通工具类型|出发地|到达地)/, /(?:运输服务|发票号码|开票日期)/], }, { kind: INVOICE_DOCUMENT_KIND.RAILWAY, requiredPatterns: [/铁路电子客票/] }, { kind: INVOICE_DOCUMENT_KIND.AVIATION, requiredPatterns: [/航空运输/] }, { kind: INVOICE_DOCUMENT_KIND.MEDICAL, requiredPatterns: [/医疗.{0,8}收费票据/] }, { kind: INVOICE_DOCUMENT_KIND.FINANCIAL, requiredPatterns: [/(?:财政|社会团体会费票据|票据(电子)|票据\(电子\))/] }, { kind: INVOICE_DOCUMENT_KIND.MACHINE_PRINTED, requiredPatterns: [/通用机打发票/] }, { kind: INVOICE_DOCUMENT_KIND.FIXED_AMOUNT, requiredPatterns: [/定额发票/] }, ]); const DEFAULT_COMMODITY_NAME_BY_DOCUMENT_KIND = Object.freeze({ [INVOICE_DOCUMENT_KIND.RAILWAY]: '铁路旅客运输服务', [INVOICE_DOCUMENT_KIND.AVIATION]: '航空旅客运输服务', [INVOICE_DOCUMENT_KIND.ROAD_PASSENGER]: '公路旅客运输服务', [INVOICE_DOCUMENT_KIND.MACHINE_PRINTED]: '出租车客运服务', [INVOICE_DOCUMENT_KIND.FIXED_AMOUNT]: '轨道交通客运服务', }); const INVOICE_TYPE = Object.freeze({ VAT_SPECIAL: '增值税专用发票', VAT_GENERAL: '增值税普通发票', }); const INVOICE_TYPE_RULES = Object.freeze([ { type: INVOICE_TYPE.VAT_SPECIAL, pattern: /(?:增值税.{0,12}专用发票|专用发票)/ }, ]); const normalizeText = value => String(value || '').trim(); const normalizeAmount = value => { const amountText = String(value || '') .replace(/(?:CNY|RMB)/ig, '') .replace(/^Y(?=\d)/i, '') .replace(/[¥¥,,\s元圆]/g, ''); if (!amountText) return null; const amount = Number(amountText); return Number.isFinite(amount) ? amount.toFixed(2) : null; }; // 清理所有文本字段,将空字符串转换为 null,确保所有字段支持为空 const cleanAllFields = fields => ({ invoiceType: fields.invoiceType || null, invoiceCode: fields.invoiceCode || null, invoiceNumber: fields.invoiceNumber || null, invoiceDate: fields.invoiceDate || null, buyerName: fields.buyerName || null, sellerName: fields.sellerName || null, commodityName: fields.commodityName || null, taxExclusiveAmount: fields.taxExclusiveAmount || null, taxAmount: fields.taxAmount || null, totalAmount: fields.totalAmount || null, }); const getModels = ctx => { const models = ctx?.app?.fs?.dc?.models; if (!models?.FinanceInvoice) throw new Error('发票判重数据模型尚未初始化'); return models; }; const getOperator = ctx => { const source = ctx?.fs?.curUser?.userInfo || {}; const mappedUserId = normalizeText(ctx?.fs?.userIdMapping?.internalUserId); const userId = mappedUserId || normalizeText(source.pepUserId || source.pepId || source.userId || source.id); const userName = normalizeText(source.name || source.username || source.nickName || source.realName); if (!userId || !userName) throw new Error('缺少当前操作人信息'); return { id: userId, name: userName, departmentId: normalizeText(source.departmentId || source.depId || source.department?.[0]?.id) || null, departmentName: normalizeText(source.departmentName || source.department?.[0]?.name) || null, }; }; const getReimbursementFields = source => ({ reimbursementUserId: normalizeText(source?.reimbursementUserId) || null, reimbursementUserName: normalizeText(source?.reimbursementUserName) || null, reimbursementDepartmentId: normalizeText(source?.reimbursementDepartmentId) || null, reimbursementDepartmentName: normalizeText(source?.reimbursementDepartmentName) || null, }); const getInvoiceKey = invoice => ({ invoiceCode: normalizeText(invoice.invoiceCode), invoiceNumber: normalizeText(invoice.invoiceNumber), }); const getInvoiceSnapshot = invoice => ({ id: invoice.id, invoiceCode: invoice.invoiceCode, invoiceNumber: invoice.invoiceNumber, invoiceType: invoice.invoiceType, invoiceDate: invoice.invoiceDate, commodityName: invoice.commodityName, totalAmount: invoice.totalAmount, reimbursementUserName: invoice.reimbursementUserName, storageDate: invoice.storageDate, }); const getUnrecognizedFields = fields => { const labels = { invoiceCode: '发票代码', invoiceNumber: '发票号码', invoiceType: '发票类型', invoiceDate: '开票日期', buyerName: '购买方名称', sellerName: '销售方名称', commodityName: '商品名称', taxExclusiveAmount: '不含税金额', taxAmount: '税额', totalAmount: '含税金额', }; return Object.keys(labels).filter(key => !fields[key]).map(key => labels[key]); }; const canStoreInvoice = fields => Boolean(normalizeText(fields.invoiceCode) || normalizeText(fields.invoiceNumber)); const getStorageErrorMessage = fields => { const missingFields = []; if (!normalizeText(fields.invoiceCode)) missingFields.push('发票代码'); if (!normalizeText(fields.invoiceNumber)) missingFields.push('发票号码'); return missingFields.length === 2 ? '发票代码和发票号码均未识别,无法入库' : `缺少${missingFields.join('、')},无法入库`; }; const isValidInvoiceDateParts = (year, month, day) => { if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return false; const date = new Date(Date.UTC(year, month - 1, day)); return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; }; const parseChineseDate = dateStr => { const text = String(dateStr || '').trim(); if (!text) return null; const incompleteYearMatch = text.match(/^(\d{3})年(\d{1,2})月(\d{1,2})日?/); if (incompleteYearMatch) { const year = `2${incompleteYearMatch[1]}`; const monthNumber = Number(incompleteYearMatch[2]); const dayNumber = Number(incompleteYearMatch[3]); if (!isValidInvoiceDateParts(Number(year), monthNumber, dayNumber)) return null; const month = String(monthNumber).padStart(2, '0'); const day = String(dayNumber).padStart(2, '0'); return `${year}-${month}-${day}`; } const compactMatch = text.match(/^(\d{4})(\d{2})(\d{2})$/); if (compactMatch) { const year = compactMatch[1]; const monthNumber = Number(compactMatch[2]); const dayNumber = Number(compactMatch[3]); if (!isValidInvoiceDateParts(Number(year), monthNumber, dayNumber)) return null; const month = String(monthNumber).padStart(2, '0'); const day = String(dayNumber).padStart(2, '0'); return `${year}-${month}-${day}`; } const chineseMatch = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日?/); if (chineseMatch) { const year = chineseMatch[1]; const monthNumber = Number(chineseMatch[2]); const dayNumber = Number(chineseMatch[3]); if (!isValidInvoiceDateParts(Number(year), monthNumber, dayNumber)) return null; const month = String(monthNumber).padStart(2, '0'); const day = String(dayNumber).padStart(2, '0'); return `${year}-${month}-${day}`; } const slashMatch = text.match(/(\d{4})[/.・-](\d{1,2})[/.・-](\d{1,2})/); if (slashMatch) { const year = slashMatch[1]; const month = String(Number(slashMatch[2])).padStart(2, '0'); const day = String(Number(slashMatch[3])).padStart(2, '0'); if (isValidInvoiceDateParts(Number(slashMatch[1]), Number(month), Number(day))) { return `${year}-${month}-${day}`; } const swappedMonth = String(Number(slashMatch[3])).padStart(2, '0'); const swappedDay = String(Number(slashMatch[2])).padStart(2, '0'); if (isValidInvoiceDateParts(Number(slashMatch[1]), Number(swappedMonth), Number(swappedDay))) { return `${year}-${swappedMonth}-${swappedDay}`; } } return null; }; const getFileNameFromUrl = fileUrl => { const cleanUrl = normalizeText(fileUrl).split('?')[0].split('#')[0]; const rawFileName = cleanUrl.substring(cleanUrl.lastIndexOf('/') + 1); try { return decodeURIComponent(rawFileName); } catch (error) { return rawFileName; } }; const getOcrFileName = (fileUrl, fileName) => { const normalizedFileName = normalizeText(fileName); const fileNameSuffix = path.extname(normalizedFileName).toLowerCase(); if (OCR_SUPPORTED_FILE_SUFFIXES.includes(fileNameSuffix)) return normalizedFileName; const urlFileName = getFileNameFromUrl(fileUrl); const urlFileNameSuffix = path.extname(urlFileName).toLowerCase(); if (OCR_SUPPORTED_FILE_SUFFIXES.includes(urlFileNameSuffix)) return urlFileName; return normalizedFileName || urlFileName || 'invoice.pdf'; }; const normalizeOcrLine = line => normalizeText(line) .replace(/[||]/g, ' ') .replace(/\s+/g, ' '); const getOcrLines = text => String(text || '') .split(/\r?\n/) .map(normalizeOcrLine) .filter(Boolean); const getCompactOcrText = text => getOcrLines(text).join('\n'); const getMoneyMatches = text => { const matches = normalizeText(text).match(/(?:[¥¥]|CNY|RMB|Y)?\s*-?\d{1,10}(?:[,,]\d{3})*(?:\.\d{1,2})?/gi) || []; return matches .map(value => normalizeAmount(value)) .filter(Boolean); }; const getRegexValue = (text, regex) => { const match = normalizeText(text).match(regex); return normalizeText(match?.[1]); }; const INVOICE_TITLE_REGEX = /(电子.{0,12}发票|增值税.{0,12}发票|铁路电子客票|机动车销售统一发票|通用机打发票|定额发票|普通发票|财政.{0,8}票据|医疗.{0,8}收费票据)/; const INVOICE_CORE_LABEL_REGEX = /(发票号码[::\s]*[0-9]{6,30}|开票日期[::\s]*\d{4}年\d{1,2}月\d{1,2}日?)/; const COMMODITY_KEYWORD_REGEX = /(?:房屋租赁|租赁服务|住宿服务|餐饮服务|物业服务|咨询服务|运输服务)/; const COMMODITY_DOCUMENT_TITLE_REGEX = /^(?:旅客运输服务|铁路旅客运输服务|航空旅客运输服务|公路旅客运输服务)$/; const COMMODITY_TABLE_START_REGEX = /(项目名称|项日名称|商品名称|货物或应税劳务、服务名称)/; const COMMODITY_TABLE_STOP_REGEX = /^(项目名称|项日名称|规格型号|单位|单住|数量|数量单位|数量\/单|单价|金额|金頭|全额|税率|税额|合计|全.{0,2}合计|价税合计|备注|购买方|销售方|[组組銷].{0,4}售方|开票人|等级|交通工具类型|出发地|到达地|出行日期|出租车|出行人|有效身份证件号|[((].{0,4}小写)/; const COMMODITY_IGNORED_REGEX = /(订单号|订单编号|电子客票号|SN|IMEI|试用水印|下载次数|开票人|69码|银行账号|购方开户银行|购方地|电话|项目编码|标准|备注|务注|元\/年|数量\/单|小写)/i; const cleanInvoiceName = value => normalizeText(value) .replace(/^(名称|称|购买方名称|销售方名称|交款人|填开单位)[::\s]*/g, '') .replace(/^[::\s]+/g, '') .replace(/(纳税人识别号|统一社会信用代码|地址|电话|开户行|账号).*$/g, '') .trim(); const cleanCommodityName = value => normalizeText(value) .replace(/^(项目名称|项日名称|商品名称|货物或应税劳务、服务名称)[::\s]*/g, '') .replace(/(规格型号|单位|单住|数量|单价|金额|金頭|全额|标准|税率|税额).*$/g, '') .trim(); const getPartyLabelRegex = partyLabel => partyLabel === 'buyer' ? /(购买方(?:信息|名称)?|交款人(?!统一社会信用代码))/ : /(销售方(?:信息|名称)?|[组組銷].{0,4}售方.{0,4}息|填开单位)/; const isInvalidPartyName = value => { const text = cleanInvoiceName(value); if (!text) return true; return /^(名称|称|下载次数|购买方信息|销售方信息|销售信息|組售方富息|项目名称|项日名称|规格型号|统一社会信用代码|校验码|章|\(章\)|(章))$/.test(text); }; const findLabeledNumber = (lines, labelRegex, minLength, maxLength) => { for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!labelRegex.test(line)) continue; const candidateText = [line.replace(labelRegex, ''), lines[index + 1] || ''].join(' '); const match = candidateText.match(new RegExp(`([0-9]{${minLength},${maxLength}})`)); if (match) return match[1]; } return ''; }; const normalizeStandaloneNumberLine = line => normalizeText(line).replace(/[\s**]/g, ''); // OCR 可能漏掉“发”“票”“据”等字,但代码/号码本身通常仍在当前行或下一行。 const invoiceCodeLabelRegex = /^(?:发票代码|票据代码|发代码|票代码|发票码|票码|代码)[::\s]*/; const extractInvoiceCode = lines => findLabeledNumber(lines, invoiceCodeLabelRegex, 8, 20) || findLabeledNumber(lines, invoiceCodeLabelRegex, 3, 20) || lines .map(line => normalizeStandaloneNumberLine(line).match(/^(0\d{11}|\d{12})$/)?.[1] || '') .find(Boolean) || ''; const invoiceNumberLabelRegex = /(?:发票号码|票据号码|发号码|票号码|发票号|票据号|票号|号码)/; const extractInvoiceNumber = lines => findLabeledNumber( lines, new RegExp(`^${invoiceNumberLabelRegex.source}[::\\s]*`), 6, 30, ) || normalizeText(lines.join(' ')).match(new RegExp(`${invoiceNumberLabelRegex.source}[^0-9]{0,12}([0-9]{6,30})`))?.[1] || lines .map(line => normalizeText(line).match(/^(?:N\.?\s*O\.?|No\.?|号码)\s*[::.]?\s*(\d{6,30})$/i)?.[1] || '') .find(Boolean) || lines .map(line => normalizeStandaloneNumberLine(line).match(/^(0\d{7,8})$/)?.[1] || '') .find(Boolean) || ''; const extractInvoiceDate = (text, lines) => { const labeledDate = getRegexValue( text, /(?:开票日期|填开日期|日期)[::\s]*(\d{3,4}年\d{1,2}月\d{1,2}日?|\d{4}[/.・-]\d{1,2}[/.・-]\d{1,2}|\d{8})/ ); if (labeledDate) return parseChineseDate(labeledDate); for (const line of lines) { const date = parseChineseDate(line); if (date) return date; } return null; }; const chineseAmountNumberMap = { 零: 0, 〇: 0, 一: 1, 壹: 1, 登: 1, 二: 2, 两: 2, 贰: 2, 三: 3, 叁: 3, 四: 4, 肆: 4, 五: 5, 伍: 5, 六: 6, 陆: 6, 七: 7, 柒: 7, 八: 8, 捌: 8, 九: 9, 玖: 9, }; const chineseAmountUnitMap = { 十: 10, 拾: 10, 百: 100, 佰: 100, 千: 1000, 仟: 1000, }; const parseChineseIntegerAmount = value => { let total = 0; let section = 0; let number = 0; const text = normalizeText(value); for (const char of text) { if (Object.prototype.hasOwnProperty.call(chineseAmountNumberMap, char)) { number = chineseAmountNumberMap[char]; continue; } if (Object.prototype.hasOwnProperty.call(chineseAmountUnitMap, char)) { const currentNumber = number || 1; section += currentNumber * chineseAmountUnitMap[char]; number = 0; continue; } if (char === '万') { section += number; total += section * 10000; section = 0; number = 0; continue; } if (char === '亿') { section += number; total += section * 100000000; section = 0; number = 0; } } return total + section + number; }; const parseChineseAmount = value => { const text = normalizeText(value).replace(/人民币/g, ''); const amountMatch = text.match(/([零〇一二两壹贰叁肆伍陆柒捌玖拾十佰百仟千万亿登]+)[元圆]([零〇一二两壹贰叁肆伍陆柒捌玖]角)?([零〇一二两壹贰叁肆伍陆柒捌玖]分)?/); if (!amountMatch) return null; const integerAmount = parseChineseIntegerAmount(amountMatch[1]); const jiao = amountMatch[2] ? chineseAmountNumberMap[amountMatch[2][0]] / 10 : 0; const fen = amountMatch[3] ? chineseAmountNumberMap[amountMatch[3][0]] / 100 : 0; const amount = integerAmount + jiao + fen; return amount > 0 ? amount.toFixed(2) : null; }; const extractChineseTotalAmount = text => { const matches = normalizeText(text).match(/[零〇一二两壹贰叁肆伍陆柒捌玖拾十佰百仟千万亿登]+[元圆](?:整|正)?(?:[零〇一二两壹贰叁肆伍陆柒捌玖]角)?(?:[零〇一二两壹贰叁肆伍陆柒捌玖]分)?/g) || []; for (const match of matches) { const amount = parseChineseAmount(match); if (amount) return amount; } return ''; }; const findLineValueAfterLabel = (lines, labelRegex, stopRegex) => { for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!labelRegex.test(line)) continue; const afterLabel = normalizeText(line.replace(labelRegex, '')); if (afterLabel && !stopRegex.test(afterLabel)) return afterLabel; const nextLine = lines[index + 1]; if (nextLine && !stopRegex.test(nextLine)) return nextLine; } return ''; }; /** * 功能:根据票面强特征识别票据种类。 * 使用场景:字段提取前选择对应的金额、税额和名称兜底规则。 * 入参:OCR 行文本数组。 * 返回:INVOICE_DOCUMENT_KIND 中的票据种类,未命中时返回 GENERAL。 * 注意:规则按顺序匹配,新增弱特征规则时必须放在强特征规则之后。 */ const extractInvoiceDocumentKind = lines => { const fullText = lines.join('\n'); const matchedRule = INVOICE_DOCUMENT_KIND_RULES.find(rule => rule.requiredPatterns.every(pattern => pattern.test(fullText)) ); return matchedRule?.kind || INVOICE_DOCUMENT_KIND.GENERAL; }; const extractInvoiceType = lines => { const fullText = lines.join('\n'); const matchedRule = INVOICE_TYPE_RULES.find(rule => rule.pattern.test(fullText)); return matchedRule?.type || INVOICE_TYPE.VAT_GENERAL; }; const extractPartyName = (text, lines, partyLabel) => { // 增强销售方/购买方标签识别,容错 OCR 错误和繁体字 const directLabel = getPartyLabelRegex(partyLabel); const stopLabel = /(下载次数|纳税人识别号|统一社会信用代码|地址|电话|开户行|账号|密码区|购买方|销售方|[组組銷].{0,4}售方.{0,4}息)/; const directValue = findLineValueAfterLabel(lines, directLabel, stopLabel); if (directValue && !isInvalidPartyName(directValue)) return cleanInvoiceName(directValue); let areaStartIndex = -1; for (let index = 0; index < lines.length; index += 1) { if (directLabel.test(lines[index])) { areaStartIndex = index; break; } } if (areaStartIndex >= 0) { const otherLabel = getPartyLabelRegex(partyLabel === 'buyer' ? 'seller' : 'buyer'); let areaEndIndex = lines.length; for (let index = areaStartIndex + 1; index < lines.length; index += 1) { const isOtherPartyStart = otherLabel.test(lines[index]); const isTableStart = /(项目名称|商品名称|货物或应税劳务、服务名称)/.test(lines[index]); if (isOtherPartyStart || isTableStart) { areaEndIndex = index; break; } } // OCR 经常把“名称:”和企业名称拆成两行,只在当前购/销方区块里找,避免串到另一方。 const areaLines = lines.slice(areaStartIndex + 1, areaEndIndex); for (let index = 0; index < areaLines.length; index += 1) { const line = areaLines[index]; if (!/^(?:名称|称)[::\s]*/.test(line)) continue; const inlineName = line.replace(/^(?:名称|称)[::\s]*/, ''); if (!isInvalidPartyName(inlineName)) return cleanInvoiceName(inlineName); const nextLine = areaLines[index + 1]; if (!isInvalidPartyName(nextLine)) return cleanInvoiceName(nextLine); } } if (partyLabel === 'buyer') { const sellerLabel = getPartyLabelRegex('seller'); const sellerIndex = lines.findIndex(line => sellerLabel.test(line)); const buyerAreaLines = sellerIndex >= 0 ? lines.slice(0, sellerIndex) : lines; for (const line of buyerAreaLines) { if (!/^(?:名称|称)[::\s]*/.test(line)) continue; const name = cleanInvoiceName(line); if (!isInvalidPartyName(name)) return name; } // 旋转、倾斜图片 OCR 可能只保留企业名称,丢失“购买方/名称”标签。 const probableBuyerName = lines.find(line => /^[\u4e00-\u9fa5]{2,30}(?:有限责任公司|有限公司|公司)$/.test(line)); if (probableBuyerName && !isInvalidPartyName(probableBuyerName)) return cleanInvoiceName(probableBuyerName); } else { // 代开发票备注中的“代开企业名称”不是销售方;销售方区块识别失败时优先取代开税务机关。 const isAgencyIssuedInvoice = lines.some(line => /代开/.test(line)); const taxAuthoritySeller = isAgencyIssuedInvoice ? lines.find(line => /^国家税务总局.{2,80}税务(?:局|分局)$/.test(line)) : ''; if (taxAuthoritySeller && !isInvalidPartyName(taxAuthoritySeller)) return cleanInvoiceName(taxAuthoritySeller); // 非代开发票缺少“销售方”标签时,才允许使用“企业名称”作为普通销售方兜底。 if (!isAgencyIssuedInvoice) { const probableSellerLine = lines.find(line => /(?:企业名称|业名称)[::\s]*[^税号]/.test(line)); const probableSellerName = probableSellerLine?.replace(/^(?:企业名称|业名称)[::\s]*/, ''); if (probableSellerName && !isInvalidPartyName(probableSellerName)) return cleanInvoiceName(probableSellerName); } } const areaStart = partyLabel === 'buyer' ? '购买方' : '(销售方|[组組銷].{0,4}售方.{0,4}息)'; const areaEnd = partyLabel === 'buyer' ? '(销售方|[组組銷].{0,4}售方.{0,4}息)' : '备注|合计|价税合计'; const areaRegex = new RegExp(`${areaStart}[\\s\\S]{0,160}?(?:名称|称)[::\\s]*([^\\n\\r]{2,80}?)(?=${areaEnd}|纳税人识别号|统一社会信用代码|地址|电话|开户行|账号|\\n|$)`); const areaValue = getRegexValue(text, areaRegex); return isInvalidPartyName(areaValue) ? '' : cleanInvoiceName(areaValue); }; const appendCommodityName = (commodityList, value) => { const cleanedName = cleanCommodityName(value); if (!cleanedName) return; const lastIndex = commodityList.length - 1; const isShortNameSuffix = /^[\u4e00-\u9fa5]{1,4}$/.test(cleanedName); const canAppendToLastName = lastIndex >= 0 && (/[*]/.test(commodityList[lastIndex]) || /(服|咨询)$/.test(commodityList[lastIndex])); if (isShortNameSuffix && canAppendToLastName && !commodityList[lastIndex].endsWith(cleanedName)) { commodityList[lastIndex] = `${commodityList[lastIndex]}${cleanedName}`; return; } commodityList.push(cleanedName); }; const extractCommodityName = (lines, documentKind) => { const likelyCommodityList = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!COMMODITY_KEYWORD_REGEX.test(line) || line.length > 80) continue; if (COMMODITY_DOCUMENT_TITLE_REGEX.test(line)) continue; if (/^(?:名称|称|购买方|销售方|销售网点)/.test(line)) continue; let commodityName = cleanCommodityName(line); const nextLine = lines[index + 1] || ''; if (/^(?:务费|服务费)$/.test(nextLine)) commodityName += nextLine; if (commodityName) likelyCommodityList.push(commodityName); } if (likelyCommodityList.length) { const hasSeparatedServiceFee = lines.some(line => /^(?:务费|服务费)$/.test(line)); const normalizedCommodityList = hasSeparatedServiceFee ? likelyCommodityList.map(name => name.endsWith('服') ? `${name}务费` : name) : likelyCommodityList; return Array.from(new Set(normalizedCommodityList)).join(';'); } const commodityList = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!COMMODITY_TABLE_START_REGEX.test(line)) continue; const value = cleanCommodityName(line.replace(/.*?(项目名称|项日名称|商品名称|货物或应税劳务、服务名称)[::\s]*/, '')); const isInvalidValue = !value || /(规格型号|单位|数量|单价|金额|税率|税额|订单号|电子客票号|SN|IMEI|试用水印|下载次数|开票人)/i.test(value); let currentTableCommodityCount = 0; if (!isInvalidValue) { appendCommodityName(commodityList, value); currentTableCommodityCount += 1; } // 表格类 OCR 经常把“项目名称”作为单独表头,真实项目值落在后续行。 for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) { const nextLine = lines[nextIndex]; if (/^(合|计)$/.test(nextLine)) break; if (COMMODITY_TABLE_STOP_REGEX.test(nextLine)) { // 已经取到商品后,后续表头通常意味着进入金额/税额区,继续扫会把“问”等 OCR 噪声当成商品。 if (currentTableCommodityCount > 0) break; continue; } if (COMMODITY_IGNORED_REGEX.test(nextLine)) continue; if (/^\d+(\.\d+)?$/.test(nextLine)) continue; if (/^[\d\s.%¥¥×xX+/-]+$/.test(nextLine)) continue; if (/^\d+(?:\.\d+)?\/[\u4e00-\u9fa5]+$/.test(nextLine)) continue; if (/^(项|台|盒|天|件|次|个|套)$/.test(nextLine)) continue; if (/^(?:间\/夜|间|夜|项|台|盒|天|件|次|个|套|人|张|份|小时|公里|元)$/.test(nextLine)) continue; if (/^[\u4e00-\u9fa5]+\/[\u4e00-\u9fa5]+$/.test(nextLine)) continue; const cleanedName = cleanCommodityName(nextLine); if (!cleanedName) continue; if (cleanedName.length <= 1) continue; if (/^[\d\s.%¥¥×xX+/-]+$/.test(cleanedName)) continue; if (/^\d+(?:\.\d+)?\/[\u4e00-\u9fa5]+$/.test(cleanedName)) continue; if (/^(?:间\/夜|间|夜|项|台|盒|天|件|次|个|套|人|张|份|小时|公里|元)$/.test(cleanedName)) continue; if (/^[\u4e00-\u9fa5]+\/[\u4e00-\u9fa5]+$/.test(cleanedName)) continue; if (!/[\u4e00-\u9fa5]/.test(cleanedName) && /[\d.%¥¥×xX+-]/.test(cleanedName)) continue; appendCommodityName(commodityList, nextLine); currentTableCommodityCount += 1; // 对于包含多个商品名称的情况,继续提取后续行,但避免扫到备注区。 if (currentTableCommodityCount >= 6) break; } } if (!commodityList.length) return DEFAULT_COMMODITY_NAME_BY_DOCUMENT_KIND[documentKind] || ''; // 过滤重复和空值,合并商品名称 return Array.from(new Set(commodityList.filter(Boolean))).join(';'); }; const extractRailwayTotalAmount = lines => { for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!/票\s*价/.test(line)) continue; // 铁路电子客票的金额通常在“票价”同一行,OCR 偶尔会把币种和数字拆到下一行。 const ticketPriceText = lines.slice(index, index + 3).join(' '); const ticketPriceMatch = ticketPriceText.match(/票\s*价[::\s]*[¥¥]?\s*(-?\d{1,10}(?:[,,]\d{3})*(?:\.\d{1,2})?)/); const ticketPriceAmount = normalizeAmount(ticketPriceMatch?.[1]); if (ticketPriceAmount) return ticketPriceAmount; const nearbyAmounts = getMoneyMatches(ticketPriceText); if (nearbyAmounts.length) return nearbyAmounts[0]; } return ''; }; const extractAviationTotalAmount = lines => { const totalIndex = lines.findIndex(line => /^合计[::\s]*$/.test(line) || /^合计/.test(line)); if (totalIndex >= 0) { // 航空客票会先输出“合计”及各费用标签,再集中输出金额;总金额通常是该汇总区最后一笔非零币种金额。 const totalLines = lines.slice(totalIndex, totalIndex + 16); const totalAmounts = totalLines .filter(line => /(?:CNY|RMB|[¥¥])/i.test(line)) .flatMap(line => getMoneyMatches(line)) .filter(amount => Number(amount) !== 0); if (totalAmounts.length) return totalAmounts[totalAmounts.length - 1]; } // OCR 漏掉“合计”标签时,航空票总金额通常仍是各费用中绝对值最大的一笔。 const aviationAmounts = lines .filter(line => /(?:CNY|RMB|[¥¥])/i.test(line)) .flatMap(line => getMoneyMatches(line)) .filter(amount => Number(amount) !== 0) .sort((left, right) => Math.abs(Number(right)) - Math.abs(Number(left))); return aviationAmounts[0] || ''; }; const extractRoadPassengerTotalAmount = lines => { const ticketPriceIndex = lines.findIndex(line => /票价/.test(line)); const ticketPriceLines = ticketPriceIndex >= 0 ? lines.slice(ticketPriceIndex + 1, ticketPriceIndex + 20) : lines; const ticketPrice = ticketPriceLines.find(line => /^\d{1,6}\.\d{2}$/.test(line)); return normalizeAmount(ticketPrice) || ''; }; const extractMachinePrintedTotalAmount = lines => { for (let index = 0; index < lines.length; index += 1) { if (!/^金额/.test(lines[index])) continue; const amounts = getMoneyMatches(lines.slice(index, index + 2).join(' ')); if (amounts.length) return amounts[0]; } return ''; }; const extractGeneralTotalAmount = (text, lines) => { for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!/小写/i.test(line)) continue; const summaryText = lines.slice(index, index + 2).join(' '); const amounts = getMoneyMatches(summaryText); if (amounts.length) return amounts[0]; } for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!/a计/i.test(line)) continue; const summaryText = lines.slice(index, index + 9).join(' '); const amounts = getMoneyMatches(summaryText); if (amounts.length) return amounts[amounts.length - 1]; } for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!/合计/.test(line)) continue; const smallAmountLine = lines.slice(index, index + 5).find(nextLine => /小写/i.test(nextLine)); const smallAmounts = getMoneyMatches(smallAmountLine || ''); if (smallAmounts.length) return smallAmounts[0]; } const totalPatterns = [ /价税合计[\s\S]{0,80}?[((][^))\n]{0,4}小写[^))\n]{0,4}[))]?[::\s]*(?:[¥¥]|CNY|RMB|Y)?\s*(-?\d{1,10}(?:[,,]\d{3})*(?:\.\d{1,2})?)/i, /[((][^))\n]{0,4}小写[^))\n]{0,4}[))]?[::\s]*(?:[¥¥]|CNY|RMB|Y)?\s*(-?\d{1,10}(?:[,,]\d{3})*(?:\.\d{1,2})?)/i, /(票价|应付金额|总价|合计金额)[::\s]*(?:[¥¥]|CNY|RMB|Y)?\s*(-?\d{1,10}(?:[,,]\d{3})*(?:\.\d{1,2})?)/i, ]; for (const pattern of totalPatterns) { const match = normalizeText(text).match(pattern); const amount = normalizeAmount(match?.[2] || match?.[1]); if (amount) return amount; } return extractChineseTotalAmount(text); }; const TOTAL_AMOUNT_EXTRACTOR_BY_DOCUMENT_KIND = Object.freeze({ [INVOICE_DOCUMENT_KIND.RAILWAY]: extractRailwayTotalAmount, [INVOICE_DOCUMENT_KIND.AVIATION]: extractAviationTotalAmount, [INVOICE_DOCUMENT_KIND.ROAD_PASSENGER]: extractRoadPassengerTotalAmount, [INVOICE_DOCUMENT_KIND.MACHINE_PRINTED]: extractMachinePrintedTotalAmount, }); /** * 功能:按票据种类提取含税总金额。 * 使用场景:正则解析单张 OCR 文本时调用。 * 入参:完整 OCR 文本、OCR 行数组、票据种类。 * 返回:标准两位小数字符串,无法确认时返回空字符串。 * 注意:专用规则未命中后统一回落到普通发票汇总规则。 */ const extractTotalAmount = (text, lines = [], documentKind = INVOICE_DOCUMENT_KIND.GENERAL) => { const specializedAmount = TOTAL_AMOUNT_EXTRACTOR_BY_DOCUMENT_KIND[documentKind]?.(lines); return specializedAmount || extractGeneralTotalAmount(text, lines); }; const extractTaxAmountFromTable = lines => { const pickSmallestPositiveAmount = amounts => amounts .map(value => Number(value)) .filter(value => Number.isFinite(value) && value >= 0) .sort((left, right) => left - right) .map(value => value.toFixed(2))[0] || ''; const taxRateIndex = lines.findIndex(line => /税率\s*[//]?\s*征收率|税率/.test(line)); if (taxRateIndex >= 0) { for (let index = taxRateIndex + 1; index < Math.min(lines.length, taxRateIndex + 20); index += 1) { if (!/^\d+(?:\.\d+)?%$/.test(lines[index])) continue; const percentageTaxAmounts = []; for (let amountIndex = index + 1; amountIndex < Math.min(lines.length, index + 4); amountIndex += 1) { if (/价税合计|合计/.test(lines[amountIndex])) break; if (/\d+\.\d{1,2}/.test(lines[amountIndex])) { percentageTaxAmounts.push(...getMoneyMatches(lines[amountIndex])); } } const taxAmount = pickSmallestPositiveAmount(percentageTaxAmounts); if (taxAmount) return taxAmount; } } const taxIndex = lines.findIndex(line => /^税额[::\s]*$/.test(line) || /税额/.test(line)); if (taxIndex < 0) return ''; const followingTaxAmounts = []; for (let index = taxIndex + 1; index < Math.min(lines.length, taxIndex + 16); index += 1) { if (/价税合计|合计/.test(lines[index])) break; if (!/\d+\.\d{1,2}/.test(lines[index])) continue; followingTaxAmounts.push(...getMoneyMatches(lines[index])); } if (followingTaxAmounts.length) { return pickSmallestPositiveAmount(followingTaxAmounts); } // 交通运输发票经常把“税额”放在金额列之后,优先取税额标签附近带币种的金额。 const nearbyLines = lines.slice(Math.max(0, taxIndex - 12), taxIndex + 1); for (let index = nearbyLines.length - 1; index >= 0; index -= 1) { if (!/[¥¥]/.test(nearbyLines[index])) continue; const amounts = getMoneyMatches(nearbyLines[index]); if (amounts.length) return amounts[amounts.length - 1]; } return ''; }; const calculateTaxExclusiveAmount = (totalAmount, taxAmount) => { const total = Number(totalAmount); const tax = Number(taxAmount); if (!Number.isFinite(total) || !Number.isFinite(tax) || total < tax) return ''; return normalizeAmount((total - tax).toFixed(2)); }; const extractTransportSellerName = (lines, buyerName = '') => { const companyCandidates = lines .map(line => { const nameMatch = line.match(/^(?:名称|称)[::\s]*(.+)$/); return nameMatch?.[1] || line; }) .filter(line => /^[\u4e00-\u9fa5]{2,30}(?:有限责任公司|有限公司|公司)$/.test(line) && line !== buyerName && !/税务局|国家税务总局/.test(line) ); const candidate = companyCandidates[companyCandidates.length - 1] || lines.find(line => line === '贵州通') || lines.find(line => /^[\u4e00-\u9fa5]{2,20}(?:客运|运输公司|汽车站|汽车公司)$/.test(line)); return candidate && !isInvalidPartyName(candidate) ? cleanInvoiceName(candidate) : ''; }; const extractRailwayBuyerName = lines => { for (const line of lines) { const match = line.match(/购买方(?:名称|信息)?[::\s]*(.+)$/); if (match?.[1] && !isInvalidPartyName(match[1])) return cleanInvoiceName(match[1]); } return ''; }; const BUYER_NAME_FALLBACK_BY_DOCUMENT_KIND = Object.freeze({ [INVOICE_DOCUMENT_KIND.RAILWAY]: extractRailwayBuyerName, }); const SELLER_NAME_FALLBACK_BY_DOCUMENT_KIND = Object.freeze({ [INVOICE_DOCUMENT_KIND.ROAD_PASSENGER]: extractTransportSellerName, [INVOICE_DOCUMENT_KIND.TRANSPORT]: extractTransportSellerName, }); const extractAviationSummaryAmounts = lines => { const taxLabelIndex = lines.findIndex(line => /增值税税额/.test(line)); if (taxLabelIndex >= 0) { const taxLines = lines.slice(taxLabelIndex, taxLabelIndex + 3); const taxAmounts = taxLines.flatMap(line => getMoneyMatches(line)); if (taxAmounts.length) return { taxExclusiveAmount: '', taxAmount: taxAmounts[taxAmounts.length - 1] }; } for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!/^\d+(?:\.\d+)?%$/.test(line)) continue; const taxAmountText = lines.slice(index + 1, index + 3).join(' '); const taxAmounts = getMoneyMatches(taxAmountText); if (taxAmounts.length) { return { taxExclusiveAmount: '', taxAmount: taxAmounts[0] }; } } for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!/增值税税率.*增值税税额/.test(line)) continue; const amountText = lines.slice(index + 1, index + 8).join(' '); const amounts = getMoneyMatches(amountText); if (amounts.length >= 3) { return { taxExclusiveAmount: '', taxAmount: amounts[2] }; } } return { taxExclusiveAmount: '', taxAmount: '' }; }; const SUMMARY_AMOUNT_EXTRACTOR_BY_DOCUMENT_KIND = Object.freeze({ [INVOICE_DOCUMENT_KIND.AVIATION]: extractAviationSummaryAmounts, }); const extractSummaryAmounts = (lines, documentKind = INVOICE_DOCUMENT_KIND.GENERAL) => { const specializedExtractor = SUMMARY_AMOUNT_EXTRACTOR_BY_DOCUMENT_KIND[documentKind]; if (specializedExtractor) return specializedExtractor(lines); const createSummaryAmounts = amounts => { const summaryAmounts = amounts .slice(-2) .sort((left, right) => Math.abs(Number(right)) - Math.abs(Number(left))); return { taxExclusiveAmount: summaryAmounts[0], taxAmount: summaryAmounts[1], }; }; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const isSplitSummaryLine = line === '合' && lines[index + 1] === '计'; const nearbyCurrencyAmounts = lines .slice(Math.max(0, index - 4), index) .filter(previousLine => /[¥¥]/.test(previousLine)) .flatMap(previousLine => getMoneyMatches(previousLine)); const isSingleSummaryLine = line === '计' && nearbyCurrencyAmounts.length >= 2; const isSummaryLine = (/合计/.test(line) || isSplitSummaryLine || isSingleSummaryLine) && !/(价税合计|小写|大写)/.test(line); if (!isSummaryLine) continue; // 发票表格 OCR 可能只保留“计”,也可能把两个汇总金额排在“合计”之前。 const summaryStartIndex = isSplitSummaryLine || isSingleSummaryLine ? Math.max(0, index - 6) : index; const summaryText = lines.slice(summaryStartIndex, index + 6).join(' '); const amounts = getMoneyMatches(summaryText); if (amounts.length >= 2) { return createSummaryAmounts(amounts); } const plainAmounts = (normalizeText(summaryText).match(/-?\d{1,10}(?:[,,]\d{3})*(?:\.\d{1,2})/g) || []) .map(value => normalizeAmount(value)) .filter(Boolean); if (plainAmounts.length >= 2) { return createSummaryAmounts(plainAmounts); } } return { taxExclusiveAmount: '', taxAmount: '' }; }; const splitInvoiceTextList = ocrText => { const lines = getOcrLines(ocrText); const titleIndexes = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!INVOICE_TITLE_REGEX.test(line)) continue; if (/(发票代码|发票号码|开票日期|电子客票号|订单号|订单编号|备注)/.test(line)) continue; const nearbyText = lines.slice(index, index + 18).join('\n'); const hasInvoiceCoreLabel = INVOICE_CORE_LABEL_REGEX.test(nearbyText); // 只把后面紧跟票号或开票日期的标题当作分票起点,避免备注里的“电子发票”等词生成空票。 if (hasInvoiceCoreLabel) titleIndexes.push(index); } if (titleIndexes.length <= 1) return [lines.join('\n')].filter(Boolean); return titleIndexes.map((startIndex, currentIndex) => { const endIndex = titleIndexes[currentIndex + 1] || lines.length; return lines.slice(startIndex, endIndex).join('\n'); }).filter(Boolean); }; /** * 功能:使用 OCR 文本中的明确标签正则提取发票字段。 * 场景:OCR 返回全文后调用,替代大模型字段提取,降低外部依赖和结果随机性。 * 注意: * - 发票号码只从“发票号码”标签后提取,避免误取订单号、客票号、SN、IMEI。 * - 金额只从汇总标签附近提取,无法确认时返回空,交由人工编辑。 */ const parseRegexInvoiceList = ocrText => { const invoiceTextList = splitInvoiceTextList(ocrText); const fieldsList = invoiceTextList.map(invoiceText => { const lines = getOcrLines(invoiceText); const compactText = getCompactOcrText(invoiceText); const invoiceType = extractInvoiceType(lines); const documentKind = extractInvoiceDocumentKind(lines); const totalAmount = extractTotalAmount(compactText, lines, documentKind); const summaryAmounts = extractSummaryAmounts(lines, documentKind); const tableTaxAmount = extractTaxAmountFromTable(lines); let taxAmount = summaryAmounts.taxAmount || tableTaxAmount; if (totalAmount && summaryAmounts.taxExclusiveAmount && summaryAmounts.taxAmount && tableTaxAmount) { const summaryDifference = Math.abs(Number(totalAmount) - Number(summaryAmounts.taxExclusiveAmount) - Number(summaryAmounts.taxAmount)); const tableDifference = Math.abs(Number(totalAmount) - Number(summaryAmounts.taxExclusiveAmount) - Number(tableTaxAmount)); if (tableDifference < summaryDifference) taxAmount = tableTaxAmount; } const taxExclusiveAmount = calculateTaxExclusiveAmount(totalAmount, taxAmount) || summaryAmounts.taxExclusiveAmount; const buyerNameFallback = BUYER_NAME_FALLBACK_BY_DOCUMENT_KIND[documentKind]; const sellerNameFallback = SELLER_NAME_FALLBACK_BY_DOCUMENT_KIND[documentKind]; const recognizedBuyerName = extractPartyName(compactText, lines, 'buyer') || buyerNameFallback?.(lines) || ''; const recognizedSellerName = extractPartyName(compactText, lines, 'seller') || sellerNameFallback?.(lines, recognizedBuyerName) || ''; return { invoiceType, invoiceCode: extractInvoiceCode(lines), invoiceNumber: extractInvoiceNumber(lines), invoiceDate: extractInvoiceDate(compactText, lines), buyerName: recognizedBuyerName, sellerName: recognizedSellerName, commodityName: extractCommodityName(lines, documentKind), taxExclusiveAmount, taxAmount, totalAmount, }; }).filter(fields => { // invoiceType 有默认值,不能作为有效识别依据;否则误切出的标题片段会生成一条空数据。 return Boolean(fields.invoiceCode || fields.invoiceNumber || fields.invoiceDate || fields.sellerName || fields.totalAmount); }); if (!fieldsList.length) throw new Error('正则未提取到发票字段'); return fieldsList; }; /** * 功能:识别发票并提取结构化字段 * 场景:上传发票或重新识别时调用 * 入参: * - ctx Koa 上下文,用于读取 OCR 配置和记录日志 * - fileUrl 已上传到七牛的文件地址 * - fileName 当前识别文件名,用于 OCR 服务记录和排障 * 出参: * - fieldsList 后续判重入库使用的发票字段列表 * - rawResult OCR 与正则提取结果,便于追踪识别质量 * 注意: * - OCR 服务只负责返回全文,字段提取使用本地正则规则 * - PDF 文件会先按页转换为 PNG,每页单独 OCR 和字段提取;单页可提取一张或多张发票 */ const recognizeInvoice = async (ctx, fileUrl, fileName) => { const ocrApiBaseUrl = normalizeText(ctx.app.fs.config.ocrApiUrl).replace(/\/+$/, ''); if (!ocrApiBaseUrl) throw new Error('OCR 服务地址未配置'); const ocrFileName = getOcrFileName(fileUrl, fileName); const isPdfFile = path.extname(ocrFileName).toLowerCase() === '.pdf'; let tempDir = null; const requestOcr = async (filePath, currentFileName) => { ctx.logger.info(`[financeInvoice] 开始调用 OCR 识别,文件:${currentFileName}`); const ocrResponse = await superagent .post(`${ocrApiBaseUrl}/ocr`) .query({ detectionModel: FINANCE_INVOICE_OCR_DETECTION_MODEL, recognitionModel: FINANCE_INVOICE_OCR_RECOGNITION_MODEL, }) .set({ Accept: 'application/json' }) .attach('file', filePath, { filename: currentFileName }) .timeout({ response: 120000, deadline: 180000 }); if (!ocrResponse.body?.success) { ctx.logger.error(`[financeInvoice] OCR 识别失败,文件:${currentFileName}`, ocrResponse.body); throw new Error(ocrResponse.body?.message || 'OCR 识别失败'); } const ocrText = ocrResponse.body?.result?.text; if (!ocrText) throw new Error(`OCR 未识别到文字,文件:${currentFileName}`); return { ocrResponse, ocrText }; }; ctx.logger.info(`[financeInvoice] 开始下载待识别源文件,文件:${ocrFileName}`); const fileResponse = await superagent .get(fileUrl) .buffer(true) .timeout({ response: 30000, deadline: 120000 }); if (!Buffer.isBuffer(fileResponse.body)) { ctx.logger.error(`[financeInvoice] 待识别源文件下载失败,文件:${ocrFileName}`); throw new Error('待识别源文件下载失败'); } try { let ocrResults = []; if (isPdfFile) { tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'finance-invoice-')); const pdfPath = path.join(tempDir, 'source.pdf'); await fsPromises.writeFile(pdfPath, fileResponse.body); const imagePaths = await convertPdfToImages(pdfPath, tempDir); if (!imagePaths.length) throw new Error('PDF 未转换出可识别的页面'); for (let pageIndex = 0; pageIndex < imagePaths.length; pageIndex += 1) { const pageFileName = `${path.basename(ocrFileName, path.extname(ocrFileName))}-第${pageIndex + 1}页.png`; const result = await requestOcr(imagePaths[pageIndex], pageFileName); const fieldsList = parseRegexInvoiceList(result.ocrText); ocrResults.push({ pageNumber: pageIndex + 1, response: result.ocrResponse.body, ocrText: result.ocrText, fieldsList, }); } } else { const result = await requestOcr(fileResponse.body, ocrFileName); ocrResults = [{ pageNumber: 1, response: result.ocrResponse.body, ocrText: result.ocrText, fieldsList: parseRegexInvoiceList(result.ocrText), }]; } const ocrText = ocrResults.map(result => result.ocrText).join('\n'); const fieldsList = ocrResults.flatMap(result => result.fieldsList); ctx.logger.info(`[financeInvoice] OCR 识别完成,文件:${ocrFileName},页数:${ocrResults.length},文本长度:${ocrText.length}`); ctx.logger.info(`[financeInvoice] 分页提取发票字段完成,文件:${ocrFileName},票据数:${fieldsList.length}`); return { fieldsList, rawResult: { ocr: isPdfFile ? ocrResults.map(result => result.response) : ocrResults[0].response, fieldExtract: { method: 'regex-by-page', fieldsList, pages: ocrResults.map(result => ({ pageNumber: result.pageNumber, fieldsList: result.fieldsList, })), }, }, }; } finally { if (tempDir) await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(error => { ctx.logger.warn(`[financeInvoice] 清理 PDF 临时文件失败,目录:${tempDir}`, error); }); } }; const getMatchedInvoice = async (FinanceInvoice, invoiceNumber, transaction, excludeUploadItemId) => { if (!invoiceNumber) return null; const excludeWhere = excludeUploadItemId ? { sourceUploadItemId: { [Op.ne]: excludeUploadItemId } } : {}; return FinanceInvoice.findOne({ // 电子发票经常没有发票代码,发票号码才是当前业务稳定判重键。 where: { invoiceNumber, ...excludeWhere, status: 'active' }, transaction, lock: transaction ? transaction.LOCK.UPDATE : undefined, }); }; const createDuplicateRecord = async (models, item, matchedInvoice, operator, transaction) => { const exists = await models.FinanceInvoiceDuplicateRecord.findOne({ where: { uploadItemId: item.id }, transaction }); if (!exists) { const duplicateInvoiceNumber = normalizeText(item.invoiceNumber || matchedInvoice.invoiceNumber); if (!duplicateInvoiceNumber) throw new Error('缺少重复发票号码,无法记录重复拦截'); await models.FinanceInvoiceDuplicateRecord.create({ uploadItemId: item.id, matchedInvoiceId: matchedInvoice.id, invoiceCode: normalizeText(item.invoiceCode || matchedInvoice.invoiceCode) || null, invoiceNumber: duplicateInvoiceNumber, uploaderId: operator.id, uploaderName: operator.name, uploaderDepartmentId: operator.departmentId, uploaderDepartmentName: operator.departmentName, totalAmount: item.totalAmount, matchedInvoiceSnapshot: getInvoiceSnapshot(matchedInvoice), }, { transaction }); } await item.update({ status: 'duplicate', matchedInvoiceId: matchedInvoice.id, updatedAt: new Date() }, { transaction }); }; const refreshBatchStatistics = async (models, batchId, transaction) => { const items = await models.FinanceInvoiceUploadItem.findAll({ where: { batchId }, attributes: ['status'], transaction }); const statusCount = items.reduce((result, item) => { result[item.status] = (result[item.status] || 0) + 1; return result; }, {}); await models.FinanceInvoiceUploadBatch.update({ totalCount: items.length, successCount: (statusCount.pending || 0) + (statusCount.stored || 0), duplicateCount: statusCount.duplicate || 0, invalidCount: statusCount.invalid || 0, updatedAt: new Date(), }, { where: { id: batchId }, transaction }); }; /** 功能:创建上传批次并同步调用 OCR 和本地正则识别发票。 */ module.exports.createUploadBatch = async ctx => { try { const models = getModels(ctx); const operator = getOperator(ctx); const { files = [], source = 'web' } = ctx.request.body; const reimbursementFields = getReimbursementFields(ctx.request.body); // 小程序/移动端先展示识别结果,由财务人员确认后再调用 confirm 接口写入台账。 // 保持 web 来源原有自动入库行为,避免影响桌面端既有流程。 const requiresManualConfirmation = source === 'mini_program'; if (requiresManualConfirmation && (!reimbursementFields.reimbursementUserId || !reimbursementFields.reimbursementUserName)) { throw new Error('请先选择报销人'); } if (!Array.isArray(files) || !files.length) throw new Error('请至少选择一张发票文件'); if (files.length > MAX_UPLOAD_COUNT) throw new Error(`单次最多上传 ${MAX_UPLOAD_COUNT} 个文件`); const batch = await models.FinanceInvoiceUploadBatch.create({ batchNo: `FI${moment().format('YYYYMMDDHHmmssSSS')}`, uploaderId: operator.id, uploaderName: operator.name, uploaderDepartmentId: operator.departmentId, uploaderDepartmentName: operator.departmentName, source, totalCount: files.length, }); let autoStoredCount = 0; for (const file of files) { const fileUrl = normalizeText(file.url || file.completeUrl); const fileName = normalizeText(file.name || file.originalFileName); const fileType = normalizeText(file.type || fileName.split('.').pop()).toLowerCase(); if (!fileUrl || !fileName) throw new Error('上传文件信息不完整'); let createdUploadItem = null; try { ctx.logger.info(`[financeInvoice] 开始识别发票源文件,文件:${fileName}`); const recognition = await recognizeInvoice(ctx, fileUrl, fileName); for (let invoiceIndex = 0; invoiceIndex < recognition.fieldsList.length; invoiceIndex += 1) { const fields = recognition.fieldsList[invoiceIndex]; const isMultiInvoiceFile = recognition.fieldsList.length > 1; const extension = path.extname(fileName); const displayFileName = isMultiInvoiceFile ? `${path.basename(fileName, extension)}-票${invoiceIndex + 1}${extension}` : fileName; const item = await models.FinanceInvoiceUploadItem.create({ batchId: batch.id, originalFileName: displayFileName, fileUrl, fileType, fileSize: Number(file.size) || 0, ocrStatus: 'processing', ...reimbursementFields, }); createdUploadItem = item; const key = getInvoiceKey(fields); const matchedInvoice = await getMatchedInvoice(models.FinanceInvoice, key.invoiceNumber); const missingFields = getUnrecognizedFields(fields); const canStore = canStoreInvoice(fields); await item.update({ ...cleanAllFields(fields), ocrStatus: 'completed', ocrRawResult: { invoiceIndex: invoiceIndex + 1, invoiceCount: recognition.fieldsList.length, recognition: recognition.rawResult, }, status: canStore ? (matchedInvoice ? 'duplicate' : 'pending') : 'invalid', errorMessage: canStore ? (missingFields.length ? `以下字段未识别:${missingFields.join('、')}` : null) : getStorageErrorMessage(fields), matchedInvoiceId: canStore ? (matchedInvoice?.id || null) : null, updatedAt: new Date(), }); if (matchedInvoice && canStore) { await createDuplicateRecord(models, item, matchedInvoice, operator); } else if (canStore && !requiresManualConfirmation) { await models.FinanceInvoice.create({ invoiceCode: key.invoiceCode, invoiceNumber: key.invoiceNumber, invoiceType: fields.invoiceType, invoiceDate: fields.invoiceDate, buyerName: fields.buyerName, sellerName: fields.sellerName, commodityName: fields.commodityName, taxExclusiveAmount: cleanAllFields(fields).taxExclusiveAmount, taxAmount: cleanAllFields(fields).taxAmount, totalAmount: cleanAllFields(fields).totalAmount || '0.00', sourceUploadItemId: item.id, ...reimbursementFields, storageDate: moment().format('YYYY-MM-DD HH:mm:ss'), createdBy: operator.id, }); await item.update({ status: 'stored', updatedAt: new Date() }); autoStoredCount += 1; } ctx.logger.info(`[financeInvoice] 发票识别完成,文件:${displayFileName},状态:${matchedInvoice ? 'duplicate' : (canStore ? 'success' : 'invalid')}`); } } catch (error) { ctx.logger.error(`[financeInvoice] 上传文件处理失败,文件:${fileName}`, error); if (createdUploadItem) { const isProcessingItem = createdUploadItem.ocrStatus === 'processing'; // 已经有识别项时不再补建失败行,否则一次上传会同时出现真实记录和空失败记录。 await createdUploadItem.update({ ocrStatus: isProcessingItem ? 'failed' : createdUploadItem.ocrStatus, status: isProcessingItem ? 'invalid' : createdUploadItem.status, errorMessage: error.message || createdUploadItem.errorMessage || '发票处理失败', updatedAt: new Date(), }); continue; } const item = await models.FinanceInvoiceUploadItem.create({ batchId: batch.id, originalFileName: fileName, fileUrl, fileType, fileSize: Number(file.size) || 0, ocrStatus: 'failed', status: 'invalid', errorMessage: error.message || '发票识别失败', ...reimbursementFields, }); await item.update({ updatedAt: new Date() }); } } await models.FinanceInvoiceUploadBatch.update({ status: requiresManualConfirmation ? 'completed' : 'confirmed', confirmedAt: requiresManualConfirmation ? null : new Date(), updatedAt: new Date(), }, { where: { id: batch.id } }); await refreshBatchStatistics(models, batch.id); const [completedBatch, items] = await Promise.all([ models.FinanceInvoiceUploadBatch.findByPk(batch.id, { raw: true }), models.FinanceInvoiceUploadItem.findAll({ where: { batchId: batch.id }, order: [['id', 'asc']], raw: true }), ]); if (items.some((item) => item.ocrStatus === 'completed')) { await reportBusinessCall({ ctx, applicationId: 'stable-finance-invoice', eventId: `finance-invoice:${batch.id}:recognize`, userId: operator.id, traceId: `finance-invoice:${batch.id}`, }); } ctx.body = { ...completedBatch, autoStoredCount, items }; } catch (error) { ctx.logger.error('[financeInvoice] 创建上传批次失败', error); ctx.status = 400; ctx.body = { message: error.message || '发票上传失败' }; } }; module.exports.getUploadBatch = async ctx => { try { const models = getModels(ctx); const batch = await models.FinanceInvoiceUploadBatch.findByPk(ctx.params.batchId, { raw: true }); if (!batch) throw new Error('上传批次不存在'); const items = await models.FinanceInvoiceUploadItem.findAll({ where: { batchId: batch.id }, order: [['id', 'asc']], raw: true }); ctx.body = { ...batch, items }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '获取上传结果失败' }; } }; /** 功能:删除上传识别项,并同步清理该识别项产生的台账和重复拦截数据。 */ module.exports.deleteUploadItem = async ctx => { const transaction = await ctx.app.fs.dc.orm.transaction(); try { const models = getModels(ctx); const batch = await models.FinanceInvoiceUploadBatch.findByPk(ctx.params.batchId, { transaction }); if (!batch) throw new Error('上传批次不存在'); const item = await models.FinanceInvoiceUploadItem.findOne({ where: { id: ctx.params.itemId, batchId: batch.id }, transaction, lock: transaction.LOCK.UPDATE, }); if (!item) throw new Error('上传发票不存在'); const generatedInvoices = await models.FinanceInvoice.findAll({ where: { sourceUploadItemId: item.id }, attributes: ['id'], raw: true, transaction, }); const generatedInvoiceIds = generatedInvoices.map(invoice => invoice.id); await models.FinanceInvoiceDuplicateRecord.destroy({ where: { [Op.or]: [ { uploadItemId: item.id }, { matchedInvoiceId: { [Op.in]: generatedInvoiceIds.length ? generatedInvoiceIds : [0] } }, ], }, transaction, }); if (generatedInvoiceIds.length) { // 删除该上传项生成的台账前,先断开其它上传项对该台账的重复引用。 await models.FinanceInvoiceUploadItem.update( { matchedInvoiceId: null, updatedAt: new Date() }, { where: { matchedInvoiceId: { [Op.in]: generatedInvoiceIds } }, transaction, }, ); await models.FinanceInvoice.destroy({ where: { id: { [Op.in]: generatedInvoiceIds } }, transaction, }); } const removedItemId = item.id; await item.destroy({ transaction }); await refreshBatchStatistics(models, batch.id, transaction); const [updatedBatch, updatedItems] = await Promise.all([ models.FinanceInvoiceUploadBatch.findByPk(batch.id, { raw: true, transaction }), models.FinanceInvoiceUploadItem.findAll({ where: { batchId: batch.id }, order: [['id', 'asc']], raw: true, transaction }), ]); await transaction.commit(); ctx.logger.info(`[financeInvoice] 上传识别项删除完成,batchId:${batch.id},itemId:${removedItemId}`); ctx.body = { ...updatedBatch, items: updatedItems }; } catch (error) { await transaction.rollback(); ctx.logger.error('[financeInvoice] 删除上传识别项失败', error); ctx.status = 400; ctx.body = { message: error.message || '删除上传识别项失败' }; } }; /** 功能:原地重新识别当前上传批次,更新现有识别项并清理历史重新识别留下的副作用。 */ module.exports.reRecognizeUploadBatch = async ctx => { try { const models = getModels(ctx); const operator = getOperator(ctx); const batch = await models.FinanceInvoiceUploadBatch.findByPk(ctx.params.batchId); if (!batch) throw new Error('上传批次不存在'); const currentItems = await models.FinanceInvoiceUploadItem.findAll({ where: { batchId: batch.id }, order: [['id', 'asc']], }); if (!currentItems.length) throw new Error('当前批次没有可重新识别的发票'); // 重新识别按原始文件维度执行。多票据识别会在同一 fileUrl 下生成多条 item, // 再次重新识别时只保留第一条作为承载行,其余历史行清理后按本次结果重新生成。 const recognitionItemMap = new Map(); for (const item of currentItems) { const fileUrlKey = normalizeText(item.fileUrl) || `item:${item.id}`; if (!recognitionItemMap.has(fileUrlKey)) { recognitionItemMap.set(fileUrlKey, item); } } const recognitionItems = Array.from(recognitionItemMap.values()); const currentItemIds = currentItems.map(item => item.id); const retainedItemIds = recognitionItems.map(item => item.id); const cleanupTransaction = await ctx.app.fs.dc.orm.transaction(); let relatedItemCount = 0; try { // 重新识别只能清理当前批次当前识别项产生的副作用。 // 不能按 fileUrl 或文件名扩散清理,否则会误删历史已入库发票,导致重复票再次入库。 const staleItemIds = currentItemIds.filter(itemId => !retainedItemIds.includes(itemId)); relatedItemCount = currentItemIds.length; if (currentItemIds.length) { const generatedInvoices = await models.FinanceInvoice.findAll({ where: { sourceUploadItemId: { [Op.in]: currentItemIds } }, attributes: ['id'], raw: true, transaction: cleanupTransaction, }); const generatedInvoiceIds = generatedInvoices.map(invoice => invoice.id); await models.FinanceInvoiceDuplicateRecord.destroy({ where: { [Op.or]: [ { uploadItemId: { [Op.in]: currentItemIds } }, { matchedInvoiceId: { [Op.in]: generatedInvoiceIds.length ? generatedInvoiceIds : [0] } }, ], }, transaction: cleanupTransaction, }); if (generatedInvoiceIds.length) { // 删除台账前必须断开上传项的 matched_invoice_id 外键引用,否则 PostgreSQL 会拒绝删除。 await models.FinanceInvoiceUploadItem.update( { matchedInvoiceId: null, updatedAt: new Date() }, { where: { matchedInvoiceId: { [Op.in]: generatedInvoiceIds } }, transaction: cleanupTransaction, }, ); } await models.FinanceInvoice.destroy({ where: { sourceUploadItemId: { [Op.in]: currentItemIds } }, transaction: cleanupTransaction, }); } if (staleItemIds.length) { await models.FinanceInvoiceUploadItem.destroy({ where: { id: { [Op.in]: staleItemIds } }, transaction: cleanupTransaction, }); } await cleanupTransaction.commit(); } catch (error) { await cleanupTransaction.rollback(); throw error; } let autoStoredCount = 0; for (const item of recognitionItems) { try { ctx.logger.info(`[financeInvoice] 开始重新识别发票,itemId:${item.id},文件:${item.originalFileName}`); const recognition = await recognizeInvoice(ctx, item.fileUrl, item.originalFileName); for (let invoiceIndex = 0; invoiceIndex < recognition.fieldsList.length; invoiceIndex += 1) { const fields = recognition.fieldsList[invoiceIndex]; const isPrimaryInvoice = invoiceIndex === 0; const isMultiInvoice = recognition.fieldsList.length > 1; const extension = path.extname(item.originalFileName); const baseFileName = path.basename(item.originalFileName, extension).replace(/-票\d+$/, ''); const displayFileName = isMultiInvoice ? `${baseFileName}-票${invoiceIndex + 1}${extension}` : item.originalFileName; const targetItem = isPrimaryInvoice ? item : await models.FinanceInvoiceUploadItem.create({ batchId: batch.id, originalFileName: displayFileName, fileUrl: item.fileUrl, fileType: item.fileType, fileSize: item.fileSize, ocrStatus: 'processing', reimbursementUserId: item.reimbursementUserId || null, reimbursementUserName: item.reimbursementUserName || null, reimbursementDepartmentId: item.reimbursementDepartmentId || null, reimbursementDepartmentName: item.reimbursementDepartmentName || null, }); const key = getInvoiceKey(fields); const matchedInvoice = await getMatchedInvoice(models.FinanceInvoice, key.invoiceNumber); const missingFields = getUnrecognizedFields(fields); const canStore = canStoreInvoice(fields); await targetItem.update({ ...cleanAllFields(fields), originalFileName: displayFileName, ocrStatus: 'completed', ocrRawResult: { invoiceIndex: invoiceIndex + 1, invoiceCount: recognition.fieldsList.length, recognition: recognition.rawResult, }, status: canStore ? (matchedInvoice ? 'duplicate' : 'pending') : 'invalid', errorMessage: canStore ? (missingFields.length ? `以下字段未识别:${missingFields.join('、')}` : null) : getStorageErrorMessage(fields), matchedInvoiceId: canStore ? (matchedInvoice?.id || null) : null, updatedAt: new Date(), }); if (matchedInvoice && canStore) { await createDuplicateRecord(models, targetItem, matchedInvoice, operator); } else if (canStore && batch.source !== 'mini_program') { await models.FinanceInvoice.create({ invoiceCode: key.invoiceCode, invoiceNumber: key.invoiceNumber, invoiceType: fields.invoiceType, invoiceDate: fields.invoiceDate, buyerName: fields.buyerName, sellerName: fields.sellerName, commodityName: fields.commodityName, taxExclusiveAmount: cleanAllFields(fields).taxExclusiveAmount, taxAmount: cleanAllFields(fields).taxAmount, totalAmount: cleanAllFields(fields).totalAmount || '0.00', sourceUploadItemId: targetItem.id, reimbursementUserId: targetItem.reimbursementUserId || null, reimbursementUserName: targetItem.reimbursementUserName || null, reimbursementDepartmentId: targetItem.reimbursementDepartmentId || null, reimbursementDepartmentName: targetItem.reimbursementDepartmentName || null, storageDate: moment().format('YYYY-MM-DD HH:mm:ss'), createdBy: operator.id, }); await targetItem.update({ status: 'stored', matchedInvoiceId: null, updatedAt: new Date() }); autoStoredCount += 1; } ctx.logger.info(`[financeInvoice] 重新识别完成,itemId:${targetItem.id},文件:${displayFileName},状态:${matchedInvoice ? 'duplicate' : (canStore ? 'success' : 'invalid')}`); } } catch (error) { ctx.logger.error(`[financeInvoice] 重新识别失败,itemId:${item.id}`, error); await item.update({ ocrStatus: 'failed', status: 'invalid', matchedInvoiceId: null, errorMessage: error.message || '发票重新识别失败', updatedAt: new Date(), }); } } await models.FinanceInvoiceUploadBatch.update({ status: batch.source === 'mini_program' ? 'completed' : 'confirmed', confirmedAt: batch.source === 'mini_program' ? null : new Date(), updatedAt: new Date(), }, { where: { id: batch.id } }); await refreshBatchStatistics(models, batch.id); const [updatedBatch, updatedItems] = await Promise.all([ models.FinanceInvoiceUploadBatch.findByPk(batch.id, { raw: true }), models.FinanceInvoiceUploadItem.findAll({ where: { batchId: batch.id }, order: [['id', 'asc']], raw: true }), ]); if (updatedItems.some((item) => item.ocrStatus === 'completed')) { await reportBusinessCall({ ctx, applicationId: 'stable-finance-invoice', eventId: `finance-invoice:${batch.id}:re-recognize:${Date.now()}`, userId: operator.id, traceId: `finance-invoice:${batch.id}`, }); } ctx.logger.info(`[financeInvoice] 当前批次重新识别完成,batchId:${batch.id},relatedItemCount:${relatedItemCount}`); ctx.body = { ...updatedBatch, autoStoredCount, items: updatedItems }; } catch (error) { ctx.logger.error('[financeInvoice] 重新识别上传批次失败', error); ctx.status = 400; ctx.body = { message: error.message || '重新识别上传批次失败' }; } }; module.exports.updateUploadItem = async ctx => { try { const models = getModels(ctx); const operator = getOperator(ctx); const item = await models.FinanceInvoiceUploadItem.findOne({ where: { id: ctx.params.itemId, batchId: ctx.params.batchId } }); if (!item) throw new Error('上传发票不存在'); const batch = await models.FinanceInvoiceUploadBatch.findByPk(item.batchId, { raw: true }); const requiresManualConfirmation = batch?.source === 'mini_program'; const fields = ctx.request.body?.invoice || ctx.request.body || {}; const key = getInvoiceKey(fields); const matchedInvoice = await getMatchedInvoice(models.FinanceInvoice, key.invoiceNumber, null, item.id); const normalizedFields = { invoiceType: normalizeText(fields.invoiceType), invoiceCode: key.invoiceCode, invoiceNumber: key.invoiceNumber, invoiceDate: parseChineseDate(fields.invoiceDate), buyerName: normalizeText(fields.buyerName), sellerName: normalizeText(fields.sellerName), commodityName: normalizeText(fields.commodityName), taxExclusiveAmount: normalizeAmount(fields.taxExclusiveAmount), taxAmount: normalizeAmount(fields.taxAmount), totalAmount: normalizeAmount(fields.totalAmount), }; const missingFields = getUnrecognizedFields(normalizedFields); const canStore = canStoreInvoice(normalizedFields); if (matchedInvoice) { throw new Error('修改后的发票代码和号码已存在,不能保存为入库记录'); } await item.update({ ...cleanAllFields(normalizedFields), status: canStore ? 'pending' : 'invalid', matchedInvoiceId: null, errorMessage: canStore ? (missingFields.length ? `以下字段未识别:${missingFields.join('、')}` : null) : getStorageErrorMessage(normalizedFields), updatedAt: new Date(), }); if (canStore && !requiresManualConfirmation) { const storedInvoice = await models.FinanceInvoice.findOne({ where: { sourceUploadItemId: item.id, status: 'active' } }); if (storedInvoice) { await storedInvoice.update({ invoiceCode: key.invoiceCode, invoiceNumber: key.invoiceNumber, invoiceType: normalizedFields.invoiceType, invoiceDate: normalizedFields.invoiceDate, buyerName: normalizedFields.buyerName, sellerName: normalizedFields.sellerName, commodityName: normalizedFields.commodityName, taxExclusiveAmount: cleanAllFields(normalizedFields).taxExclusiveAmount, taxAmount: cleanAllFields(normalizedFields).taxAmount, totalAmount: cleanAllFields(normalizedFields).totalAmount || '0.00', updatedAt: new Date(), }); } else { await models.FinanceInvoice.create({ invoiceCode: key.invoiceCode, invoiceNumber: key.invoiceNumber, invoiceType: normalizedFields.invoiceType, invoiceDate: normalizedFields.invoiceDate, buyerName: normalizedFields.buyerName, sellerName: normalizedFields.sellerName, commodityName: normalizedFields.commodityName, taxExclusiveAmount: cleanAllFields(normalizedFields).taxExclusiveAmount, taxAmount: cleanAllFields(normalizedFields).taxAmount, totalAmount: cleanAllFields(normalizedFields).totalAmount || '0.00', sourceUploadItemId: item.id, storageDate: moment().format('YYYY-MM-DD HH:mm:ss'), createdBy: operator.id, }); } await item.update({ status: 'stored', matchedInvoiceId: null, updatedAt: new Date() }); } await refreshBatchStatistics(models, item.batchId); ctx.body = await models.FinanceInvoiceUploadItem.findByPk(item.id, { raw: true }); } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '保存发票信息失败' }; } }; /** 功能:将待确认发票事务化写入有效台账,唯一索引冲突转为拦截记录。 */ module.exports.confirmUploadBatch = async ctx => { try { const models = getModels(ctx); const operator = getOperator(ctx); const { itemIds = [], reimbursementUserId, reimbursementUserName, reimbursementDepartmentId, reimbursementDepartmentName } = ctx.request.body; const reimbursementFields = getReimbursementFields({ reimbursementUserId, reimbursementUserName, reimbursementDepartmentId, reimbursementDepartmentName, }); if (!reimbursementFields.reimbursementUserId || !reimbursementFields.reimbursementUserName) throw new Error('请显式选择报销人'); const items = await models.FinanceInvoiceUploadItem.findAll({ where: { batchId: ctx.params.batchId, id: { [Op.in]: itemIds } } }); let storedCount = 0; let duplicateCount = 0; const failures = []; for (const item of items) { if (item.status !== 'pending') continue; const transaction = await ctx.app.fs.dc.orm.transaction(); try { const matchedInvoice = await getMatchedInvoice(models.FinanceInvoice, item.invoiceNumber, transaction); if (matchedInvoice) { await createDuplicateRecord(models, item, matchedInvoice, operator, transaction); await item.update({ ...reimbursementFields, status: 'duplicate', matchedInvoiceId: matchedInvoice.id, updatedAt: new Date(), }, { transaction }); duplicateCount += 1; } else { await models.FinanceInvoice.create({ invoiceCode: item.invoiceCode || null, invoiceNumber: item.invoiceNumber || null, invoiceType: item.invoiceType || null, invoiceDate: item.invoiceDate || null, buyerName: item.buyerName || null, sellerName: item.sellerName || null, commodityName: item.commodityName || null, taxExclusiveAmount: item.taxExclusiveAmount || null, taxAmount: item.taxAmount || null, totalAmount: item.totalAmount || '0.00', sourceUploadItemId: item.id, ...reimbursementFields, storageDate: moment().format('YYYY-MM-DD HH:mm:ss'), createdBy: operator.id, }, { transaction }); await item.update({ ...reimbursementFields, status: 'stored', updatedAt: new Date() }, { transaction }); storedCount += 1; } await transaction.commit(); } catch (error) { await transaction.rollback(); failures.push({ itemId: item.id, message: error.message || '入库失败' }); ctx.logger.error(`[financeInvoice] 入库失败,上传项:${item.id}`, error); } } await models.FinanceInvoiceUploadBatch.update({ status: 'confirmed', confirmedAt: new Date(), updatedAt: new Date() }, { where: { id: ctx.params.batchId } }); await refreshBatchStatistics(models, ctx.params.batchId); ctx.body = { storedCount, duplicateCount, failures }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '确认入库失败' }; } }; module.exports.checkInvoice = async ctx => { try { const models = getModels(ctx); const operator = getOperator(ctx); const key = getInvoiceKey(ctx.request.body); if (!key.invoiceNumber) throw new Error('请输入发票号码'); const invoice = await getMatchedInvoice(models.FinanceInvoice, key.invoiceNumber); const actionId = `finance-invoice:manual-check:${uuidv4()}`; await reportBusinessCall({ ctx, applicationId: 'stable-finance-invoice', eventId: actionId, userId: operator.id, traceId: actionId, }); ctx.body = { exists: Boolean(invoice), message: invoice ? '该发票已存在于报销发票库,不能重复报销' : '未发现重复发票' }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '发票查询失败' }; } }; const buildInvoiceWhere = query => { const where = { status: 'active' }; if (query.keyword) { const keyword = `%${normalizeText(query.keyword)}%`; where[Op.or] = [ { invoiceCode: { [Op.iLike]: keyword } }, { invoiceNumber: { [Op.iLike]: keyword } }, { sellerName: { [Op.iLike]: keyword } }, { reimbursementUserName: { [Op.iLike]: keyword } }, ]; } if (query.invoiceType) where.invoiceType = query.invoiceType; if (query.reimbursementUserId) where.reimbursementUserId = query.reimbursementUserId; if (query.reimbursementUserName) where.reimbursementUserName = { [Op.iLike]: `%${normalizeText(query.reimbursementUserName)}%` }; if (query.sellerName) where.sellerName = { [Op.iLike]: `%${normalizeText(query.sellerName)}%` }; if (query.invoiceCode) where.invoiceCode = { [Op.iLike]: `%${normalizeText(query.invoiceCode)}%` }; if (query.invoiceNumber) where.invoiceNumber = { [Op.iLike]: `%${normalizeText(query.invoiceNumber)}%` }; if (query.startDate || query.endDate) where.storageDate = { ...(query.startDate ? { [Op.gte]: query.startDate } : {}), ...(query.endDate ? { [Op.lte]: query.endDate } : {}) }; return where; }; const getFinanceInvoiceTypeOptions = async models => { const storedTypes = await models.FinanceInvoice.findAll({ where: { status: 'active', invoiceType: { [Op.ne]: null } }, attributes: ['invoiceType'], group: ['invoice_type'], raw: true, }); const uploadTypes = await models.FinanceInvoiceUploadItem.findAll({ where: { invoiceType: { [Op.ne]: null } }, attributes: ['invoiceType'], group: ['invoice_type'], raw: true, }); const invoiceTypes = [...storedTypes, ...uploadTypes] .map(item => normalizeText(item.invoiceType)) .filter(Boolean); return Array.from(new Set(invoiceTypes)).sort(); }; /** * 功能:为台账记录补齐上传人信息。 * 原因:已入库发票只保存 sourceUploadItemId,上传人保存在上传批次中,列表展示需要跨表回填。 */ const getUploadBatchMapByItems = async (models, uploadItems = []) => { const batchIds = Array.from(new Set( uploadItems .map(item => item?.batchId) .filter(Boolean), )); if (!batchIds.length) return new Map(); const batches = await models.FinanceInvoiceUploadBatch.findAll({ where: { id: { [Op.in]: batchIds } }, raw: true, }); return new Map(batches.map(batch => [String(batch.id), batch])); }; const buildUploaderFields = batch => ({ uploaderId: batch?.uploaderId || null, uploaderName: batch?.uploaderName || null, uploaderDepartmentId: batch?.uploaderDepartmentId || null, uploaderDepartmentName: batch?.uploaderDepartmentName || null, }); /** 功能:批量修改已入库、重复拦截和识别失败发票的报销人。 */ module.exports.updateBatchReimbursement = async ctx => { try { const models = getModels(ctx); const { records = [], invoiceIds = [], reimbursementUserId, reimbursementUserName, reimbursementDepartmentId, reimbursementDepartmentName } = ctx.request.body; const selectedRecords = Array.isArray(records) && records.length ? records : invoiceIds.map(invoiceId => ({ recordType: 'stored', recordId: invoiceId })); if (!selectedRecords.length) throw new Error('请选择需要编辑的发票'); if (!reimbursementUserName) throw new Error('请填写报销人'); const storedInvoiceIds = []; const uploadItemIds = []; selectedRecords.forEach(record => { const recordId = normalizeText(record.recordId || record.id); if (!/^\d+$/.test(recordId)) return; if (record.recordType === 'upload') uploadItemIds.push(recordId); else storedInvoiceIds.push(recordId); }); if (!storedInvoiceIds.length && !uploadItemIds.length) throw new Error('未找到可修改的发票记录'); const reimbursementFields = { reimbursementUserId: reimbursementUserId || null, reimbursementUserName: normalizeText(reimbursementUserName), reimbursementDepartmentId: reimbursementDepartmentId || null, reimbursementDepartmentName: normalizeText(reimbursementDepartmentName) || null, updatedAt: new Date(), }; const transaction = await ctx.app.fs.dc.orm.transaction(); try { const [storedUpdatedCount] = storedInvoiceIds.length ? await models.FinanceInvoice.update(reimbursementFields, { where: { id: { [Op.in]: storedInvoiceIds }, status: 'active' }, transaction, }) : [0]; const [uploadUpdatedCount] = uploadItemIds.length ? await models.FinanceInvoiceUploadItem.update(reimbursementFields, { where: { id: { [Op.in]: uploadItemIds } }, transaction, }) : [0]; await transaction.commit(); ctx.body = { updatedCount: storedUpdatedCount + uploadUpdatedCount }; } catch (error) { await transaction.rollback(); throw error; } } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '批量修改报销人失败' }; } }; module.exports.getRecords = async ctx => { try { const models = getModels(ctx); const query = ctx.request.query || {}; const page = Math.max(Number(query.page) || 1, 1); const pageSize = Math.min(Math.max(Number(query.pageSize) || 20, 1), 100); const status = normalizeText(query.status); const isStoredStatus = !status || status === 'stored'; const uploadStatusList = ['duplicate', 'invalid', 'store_failed']; const needUploadRecords = !query.startDate && !query.endDate && (!status || uploadStatusList.includes(status)); const storedInvoices = isStoredStatus ? await models.FinanceInvoice.findAll({ where: buildInvoiceWhere(query), order: [['storageDate', 'desc'], ['id', 'desc']], raw: true, }) : []; const uploadItems = needUploadRecords ? await models.FinanceInvoiceUploadItem.findAll({ where: { status: status ? status : { [Op.in]: uploadStatusList } }, order: [['createdAt', 'desc'], ['id', 'desc']], raw: true, }) : []; const invoiceTypes = await getFinanceInvoiceTypeOptions(models); const sourceUploadItemIds = storedInvoices .map(item => item.sourceUploadItemId) .filter(Boolean); const storedSourceItems = sourceUploadItemIds.length ? await models.FinanceInvoiceUploadItem.findAll({ where: { id: { [Op.in]: sourceUploadItemIds } }, raw: true, }) : []; const sourceItemMap = new Map(storedSourceItems.map(item => [String(item.id), item])); const batchMap = await getUploadBatchMapByItems(models, [ ...storedSourceItems, ...uploadItems, ]); const keyword = normalizeText(query.keyword).toLowerCase(); const failedRecords = uploadItems .map(item => { const batch = batchMap.get(String(item.batchId)); return { id: `upload-${item.id}`, sourceId: item.id, recordType: 'upload', invoiceCode: item.invoiceCode, invoiceNumber: item.invoiceNumber, invoiceType: item.invoiceType, invoiceDate: item.invoiceDate, sellerName: item.sellerName, commodityName: item.commodityName, totalAmount: item.totalAmount, reimbursementUserId: item.reimbursementUserId, reimbursementUserName: item.reimbursementUserName, reimbursementDepartmentId: item.reimbursementDepartmentId, reimbursementDepartmentName: item.reimbursementDepartmentName, ...buildUploaderFields(batch), storageDate: null, status: item.status, errorMessage: item.errorMessage, createdAt: item.createdAt, }; }) .filter(record => { if (query.invoiceType && record.invoiceType !== query.invoiceType) return false; if (query.reimbursementUserName && !String(record.reimbursementUserName || '').includes(query.reimbursementUserName)) return false; if (!keyword) return true; return [record.invoiceCode, record.invoiceNumber, record.sellerName, record.reimbursementUserName, record.uploaderName] .some(value => String(value || '').toLowerCase().includes(keyword)); }); const records = [ ...storedInvoices.map(item => { const sourceItem = sourceItemMap.get(String(item.sourceUploadItemId)); const batch = batchMap.get(String(sourceItem?.batchId)); return { ...item, ...buildUploaderFields(batch), sourceId: item.id, recordType: 'stored', status: 'stored', }; }), ...failedRecords, ].sort((left, right) => { const leftHasStorage = Boolean(left.storageDate); const rightHasStorage = Boolean(right.storageDate); if (leftHasStorage && !rightHasStorage) return -1; if (!leftHasStorage && rightHasStorage) return 1; if (leftHasStorage && rightHasStorage) { return new Date(right.storageDate) - new Date(left.storageDate); } return new Date(right.createdAt) - new Date(left.createdAt); }); const offset = (page - 1) * pageSize; const stats = records.reduce((acc, record) => { acc.total++; if (record.status === 'stored') acc.stored++; if (record.status === 'duplicate') acc.duplicate++; return acc; }, { total: 0, stored: 0, duplicate: 0 }); ctx.body = { total: records.length, page, pageSize, data: records.slice(offset, offset + pageSize), stats, filters: { invoiceTypes }, }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '获取发票台账失败' }; } }; module.exports.getRecordDetail = async ctx => { try { const models = getModels(ctx); const invoice = await models.FinanceInvoice.findByPk(ctx.params.invoiceId, { raw: true }); if (!invoice) throw new Error('发票台账不存在'); const uploadItem = await models.FinanceInvoiceUploadItem.findByPk(invoice.sourceUploadItemId, { raw: true }); const batchMap = await getUploadBatchMapByItems(models, uploadItem ? [uploadItem] : []); const batch = batchMap.get(String(uploadItem?.batchId)); ctx.body = { ...invoice, ...buildUploaderFields(batch), uploadItem }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '获取发票详情失败' }; } }; module.exports.getUploadRecordDetail = async ctx => { try { const models = getModels(ctx); const item = await models.FinanceInvoiceUploadItem.findByPk(ctx.params.uploadItemId, { raw: true }); if (!item) throw new Error('上传发票记录不存在'); const batchMap = await getUploadBatchMapByItems(models, [item]); const batch = batchMap.get(String(item.batchId)); ctx.body = { ...item, ...buildUploaderFields(batch), recordType: 'upload' }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '获取上传发票详情失败' }; } }; /** 功能:保存台账详情抽屉中的可编辑发票字段。 */ module.exports.updateLedgerRecord = async ctx => { try { const models = getModels(ctx); const recordType = ctx.params.recordType; const fields = ctx.request.body || {}; const updateData = { invoiceCode: normalizeText(fields.invoiceCode), invoiceNumber: normalizeText(fields.invoiceNumber), invoiceType: normalizeText(fields.invoiceType), invoiceDate: parseChineseDate(fields.invoiceDate), buyerName: normalizeText(fields.buyerName), sellerName: normalizeText(fields.sellerName), commodityName: normalizeText(fields.commodityName), taxExclusiveAmount: normalizeAmount(fields.taxExclusiveAmount), taxAmount: normalizeAmount(fields.taxAmount), totalAmount: normalizeAmount(fields.totalAmount) || '0.00', updatedAt: new Date(), }; if (!updateData.invoiceNumber) throw new Error('发票号码不能为空'); if (recordType === 'stored') { await models.FinanceInvoice.update({ ...updateData, reimbursementUserName: normalizeText(fields.reimbursementUserName), reimbursementDepartmentName: normalizeText(fields.reimbursementDepartmentName), }, { where: { id: ctx.params.recordId, status: 'active' } }); } else if (recordType === 'upload') { await models.FinanceInvoiceUploadItem.update(updateData, { where: { id: ctx.params.recordId } }); } else { throw new Error('不支持的台账记录类型'); } ctx.body = { success: true }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '保存发票详情失败' }; } }; module.exports.getDuplicates = async ctx => { try { const models = getModels(ctx); const query = ctx.request.query || {}; const page = Math.max(Number(query.page) || 1, 1); const pageSize = Math.min(Math.max(Number(query.pageSize) || 20, 1), 100); const where = {}; if (query.uploaderId) where.uploaderId = query.uploaderId; if (query.startDate || query.endDate) where.createdAt = { ...(query.startDate ? { [Op.gte]: `${query.startDate} 00:00:00` } : {}), ...(query.endDate ? { [Op.lte]: `${query.endDate} 23:59:59` } : {}) }; const result = await models.FinanceInvoiceDuplicateRecord.findAndCountAll({ where, order: [['createdAt', 'desc']], limit: pageSize, offset: (page - 1) * pageSize, raw: true }); ctx.body = { total: result.count, page, pageSize, data: result.rows }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '获取拦截记录失败' }; } }; module.exports.getDuplicateDetail = async ctx => { try { const models = getModels(ctx); const record = await models.FinanceInvoiceDuplicateRecord.findByPk(ctx.params.duplicateId, { raw: true }); if (!record) throw new Error('拦截记录不存在'); const uploadItem = await models.FinanceInvoiceUploadItem.findByPk(record.uploadItemId, { raw: true }); const matchedInvoice = await models.FinanceInvoice.findByPk(record.matchedInvoiceId, { raw: true }); ctx.body = { ...record, uploadItem, matchedInvoice }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '获取拦截详情失败' }; } }; const calculateChangePercent = (current, previous) => { const currentVal = Number(current || 0); const previousVal = Number(previous || 0); if (previousVal === 0) return currentVal > 0 ? 100 : 0; return Number((((currentVal - previousVal) / previousVal) * 100).toFixed(2)); }; module.exports.getDashboard = async ctx => { try { const models = getModels(ctx); const now = moment(); const currentStart = now.clone().startOf('month').format('YYYY-MM-DD'); const currentEnd = now.clone().endOf('month').format('YYYY-MM-DD'); const previousStart = now.clone().subtract(1, 'month').startOf('month').format('YYYY-MM-DD'); const previousEnd = now.clone().subtract(1, 'month').endOf('month').format('YYYY-MM-DD'); const getSummary = async (startDate, endDate) => models.FinanceInvoice.findOne({ where: { status: 'active', storageDate: { [Op.between]: [startDate, endDate] } }, attributes: [[fn('COUNT', col('id')), 'invoiceCount'], [fn('COALESCE', fn('SUM', col('total_amount')), 0), 'totalAmount'], [fn('COALESCE', fn('SUM', col('tax_exclusive_amount')), 0), 'taxExclusiveAmount'], [fn('COALESCE', fn('SUM', col('tax_amount')), 0), 'taxAmount']], raw: true, }); const getDuplicateSummary = async (startDate, endDate) => models.FinanceInvoiceDuplicateRecord.findOne({ where: { createdAt: { [Op.between]: [startDate, endDate] } }, attributes: [[fn('COUNT', col('id')), 'count'], [fn('COALESCE', fn('SUM', col('total_amount')), 0), 'totalAmount']], raw: true }); const trendMonths = Array.from({ length: 6 }, (_, index) => now.clone().subtract(5 - index, 'month')); const [currentMonth, previousMonth, currentDuplicate, previousDuplicate, recentDuplicates, invoiceTypeStats] = await Promise.all([ getSummary(currentStart, currentEnd), getSummary(previousStart, previousEnd), getDuplicateSummary(now.clone().startOf('month').toDate(), now.clone().endOf('month').toDate()), getDuplicateSummary(now.clone().subtract(1, 'month').startOf('month').toDate(), now.clone().subtract(1, 'month').endOf('month').toDate()), models.FinanceInvoiceDuplicateRecord.findAll({ where: { createdAt: { [Op.gte]: now.clone().subtract(1, 'month').toDate() } }, order: [['createdAt', 'desc']], limit: 10, raw: true }), models.FinanceInvoice.findAll({ where: { status: 'active', storageDate: { [Op.between]: [currentStart, currentEnd] } }, attributes: ['invoiceType', [fn('COUNT', col('id')), 'count']], group: ['invoice_type'], raw: true }), ]); const duplicateSummary = currentDuplicate || { count: 0, totalAmount: 0 }; const monthlyTrend = await Promise.all(trendMonths.map(async month => { const summary = await getSummary(month.clone().startOf('month').format('YYYY-MM-DD'), month.clone().endOf('month').format('YYYY-MM-DD')); return { month: month.format('M月'), totalAmount: Number(summary.totalAmount || 0) }; })); const compareStats = { totalAmountChange: calculateChangePercent(currentMonth?.totalAmount, previousMonth?.totalAmount), invoiceCountChange: calculateChangePercent(currentMonth?.invoiceCount, previousMonth?.invoiceCount), duplicateCountChange: calculateChangePercent(duplicateSummary.count, previousDuplicate?.count), duplicateAmountChange: calculateChangePercent(duplicateSummary.totalAmount, previousDuplicate?.totalAmount), }; ctx.body = { currentMonth, previousMonth, duplicateSummary, recentDuplicates, monthlyTrend, invoiceTypeStats, compareStats }; } catch (error) { ctx.status = 400; ctx.body = { message: error.message || '获取仪表盘失败' }; } }; /** * 功能:导出发票台账为 Excel 文件 * 场景:财务人员在台账页面点击导出按钮时调用 * 入参: * - ctx Koa 上下文,包含筛选条件(发票代码、号码、类型、日期范围、报销人、状态等) * 出参: * - Excel 文件流,包含发票基本信息、报销信息和金额明细 * 注意: * - Excel 文件名包含导出时间戳,避免重复 * - 导出结果按入库时间倒序排列 */ module.exports.exportRecordsToExcel = async ctx => { try { const models = getModels(ctx); const { invoiceCode, invoiceNumber, invoiceType, reimbursementUserName, status, storageDateRange } = ctx.query; // 根据status判断查询哪个表 const normalizedStatus = normalizeText(status); const uploadStatusList = ['duplicate', 'invalid', 'store_failed', 'pending']; const isUploadStatus = normalizedStatus && uploadStatusList.includes(normalizedStatus); const isStoredStatus = !normalizedStatus || normalizedStatus === 'stored' || normalizedStatus === 'active' || normalizedStatus === 'voided'; let exportedRecords = []; // 查询已入库发票 if (isStoredStatus) { const whereConditions = {}; if (invoiceCode) whereConditions.invoiceCode = { [Op.like]: `%${invoiceCode}%` }; if (invoiceNumber) whereConditions.invoiceNumber = { [Op.like]: `%${invoiceNumber}%` }; if (invoiceType) whereConditions.invoiceType = invoiceType; if (reimbursementUserName) whereConditions.reimbursementUserName = { [Op.like]: `%${reimbursementUserName}%` }; if (normalizedStatus && ['stored', 'active', 'voided'].includes(normalizedStatus)) { whereConditions.status = normalizedStatus === 'stored' ? 'active' : normalizedStatus; } // 处理日期范围筛选 if (storageDateRange && Array.isArray(storageDateRange) && storageDateRange.length === 2) { const startDate = moment(storageDateRange[0]).format('YYYY-MM-DD'); const endDate = moment(storageDateRange[1]).format('YYYY-MM-DD'); whereConditions.storageDate = { [Op.between]: [startDate, endDate] }; } const invoices = await models.FinanceInvoice.findAll({ where: whereConditions, order: [['storageDate', 'DESC']], raw: true, }); exportedRecords.push(...invoices.map(invoice => ({ ...invoice, recordType: 'stored' }))); } // 查询上传记录(重复、无效、待入库等状态) if (isUploadStatus || (!normalizedStatus && !storageDateRange)) { const uploadWhereConditions = {}; if (normalizedStatus && uploadStatusList.includes(normalizedStatus)) { uploadWhereConditions.status = normalizedStatus; } else if (!normalizedStatus && !storageDateRange) { // 如果没有status和日期筛选,查询所有上传状态 uploadWhereConditions.status = { [Op.in]: uploadStatusList }; } if (invoiceCode) uploadWhereConditions.invoiceCode = { [Op.like]: `%${invoiceCode}%` }; if (invoiceNumber) uploadWhereConditions.invoiceNumber = { [Op.like]: `%${invoiceNumber}%` }; if (invoiceType) uploadWhereConditions.invoiceType = invoiceType; if (reimbursementUserName) uploadWhereConditions.reimbursementUserName = { [Op.like]: `%${reimbursementUserName}%` }; const uploadItems = await models.FinanceInvoiceUploadItem.findAll({ where: uploadWhereConditions, order: [['createdAt', 'DESC']], raw: true, }); exportedRecords.push(...uploadItems.map(item => ({ ...item, recordType: 'upload' }))); } if (!exportedRecords.length) { ctx.status = 400; ctx.body = { message: '没有符合条件的发票数据' }; return; } // 获取上传批次信息 const batchIds = exportedRecords.map(record => record.batchId || record.sourceUploadItemId).filter(Boolean); const batches = batchIds.length ? await models.FinanceInvoiceUploadBatch.findAll({ where: { id: { [Op.in]: batchIds } }, raw: true, }) : []; const batchMap = new Map(batches.map(batch => [String(batch.id), batch])); // 准备 Excel 数据 const excelData = exportedRecords.map((record, index) => { const batch = batchMap.get(String(record.batchId || record.sourceUploadBatchId)); const statusText = record.recordType === 'stored' ? (record.status === 'active' ? '已入库' : record.status === 'voided' ? '已作废' : record.status) : (record.status === 'duplicate' ? '重复' : record.status === 'invalid' ? '无效' : record.status === 'pending' ? '待入库' : record.status); return { '序号': index + 1, '发票代码': record.invoiceCode || '', '发票号码': record.invoiceNumber || '', '发票类型': record.invoiceType || '', '开票日期': record.invoiceDate || '', '入库时间': record.storageDate ? moment(record.storageDate).format('YYYY-MM-DD HH:mm:ss') : '', '购买方名称': record.buyerName || '', '销售方名称': record.sellerName || '', '商品名称': record.commodityName || '', '不含税金额': record.taxExclusiveAmount ? Number(record.taxExclusiveAmount).toFixed(2) : '', '税额': record.taxAmount ? Number(record.taxAmount).toFixed(2) : '', '价税合计': record.totalAmount ? Number(record.totalAmount).toFixed(2) : '', '报销人': record.reimbursementUserName || '', '报销部门': record.reimbursementDepartmentName || '', '上传人': batch?.uploaderName || '', '上传部门': batch?.uploaderDepartmentName || '', '状态': statusText, '识别备注': record.errorMessage || '', }; }); // 创建 Excel 工作簿 const worksheet = XLSX.utils.json_to_sheet(excelData); // 设置列宽 const columnWidths = [ { wch: 6 }, // 序号 { wch: 12 }, // 发票代码 { wch: 12 }, // 发票号码 { wch: 20 }, // 发票类型 { wch: 12 }, // 开票日期 { wch: 18 }, // 入库时间 { wch: 25 }, // 购买方名称 { wch: 25 }, // 销售方名称 { wch: 30 }, // 商品名称 { wch: 12 }, // 不含税金额 { wch: 12 }, // 税额 { wch: 12 }, // 价税合计 { wch: 15 }, // 报销人 { wch: 20 }, // 报销部门 { wch: 15 }, // 上传人 { wch: 20 }, // 上传部门 { wch: 10 }, // 状态 { wch: 40 }, // 错误信息 ]; worksheet['!cols'] = columnWidths; // 设置表头样式 const headerRange = XLSX.utils.decode_range(worksheet['!ref']); for (let col = headerRange.s.c; col <= headerRange.e.c; col += 1) { const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col }); if (worksheet[cellAddress]) { worksheet[cellAddress].s = { font: { bold: true, sz: 12 }, fill: { fgColor: { rgb: 'E7E6E6' } }, alignment: { horizontal: 'center', vertical: 'center' }, }; } } // 创建工作簿并添加工作表 const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, '发票台账'); // 生成 Excel 文件 Buffer const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); // 设置响应头 const exportFileName = `发票台账_${moment().format('YYYYMMDDHHmmss')}.xlsx`; ctx.set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(exportFileName)}"`); ctx.body = excelBuffer; } catch (error) { ctx.logger.error('[financeInvoice] 导出 Excel 失败', error); ctx.status = 400; ctx.body = { message: error.message || '导出 Excel 失败' }; } };