ai-query对接新版freesun-agent接口的分支
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

2209 lines
106 KiB

/**
* 文件作用:处理财务发票的识别、判重、入库、台账和统计接口。
* 职责范围:调用 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 失败' };
}
};