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
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 失败' };
|
|
}
|
|
};
|
|
|