Files
dingding-oa-server/src/pages/api/approval/exportInvoiceDataFile.ts
2026-05-27 14:12:10 +08:00

403 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { NextApiRequest, NextApiResponse } from 'next'
import prismaClient from '@/lib/prisma'
import fs from 'fs'
import path from 'path'
import archiver from 'archiver'
import XLSX from 'xlsx'
import XLSXStyle from 'xlsx-style';
import dayjs from 'dayjs'
import AuthClient from '@/api/dingding/auth'
import { getHanShuiAmount, getBuHanShuiAmount, getAllAmount } from '@/pages/api/approval/util'
import { AGREEMENT_TYPE_CODE, AGREEMENT_TYPES, XIANGMU_ZUBIE_MAP } from '@/CONSTANTS'
import { downloadAttachments, processInvoiceDataItem } from '@/lib/util'
// 定义协议类型名称映射
const AGREEMENT_CODE_TO_NAME = {
'1': '大协议',
'2': '独立协议',
'3': '大协议',
'4': '独立协议'
};
// 定义列宽配置
// 列宽:增加“开票作业序号”,移除“集团内外”
const COLUMN_WIDTHS = {
"开票作业序号": 15,
"数据ID": 20,
"项目编号": 15,
"单位": 25,
// 移除“集团内外”
"票号": 15,
"不含税金额": 15,
"税额": 15,
"含税金额": 15,
"发票类型": 20,
"申请人": 15,
"专业组": 15,
"开票日期": 15,
"账期": 15,
"回款日期": 15,
"回款金额": 15,
"统一社会信用代码": 25,
"开户行名称": 25,
"银行账号": 25,
"业务内容": 30
};
// 定义表头样式
const HEADER_STYLE = {
fill: {
fgColor: { rgb: "bce672" } // 蓝色背景
},
font: {
color: { rgb: "000000" }, // 白色字体
bold: true
},
alignment: {
horizontal: "center",
vertical: "center"
}
};
// 定义数据单元格样式
const CELL_STYLE = {
alignment: {
horizontal: "left",
vertical: "center"
},
numFmt: "@"
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' })
}
try {
// 获取access token实际实现中需要从请求上下文或认证系统获取
const accessToken = await AuthClient.getAccessToken();
const { ids } = req.body
console.log('Received IDs:', ids);
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ message: 'Invalid or missing IDs array' })
}
if (ids.length > 1000) {
return res.status(400).json({ message: 'Too many IDs requested. Maximum 1000 IDs allowed.' })
}
console.log('Fetching data for IDs:', ids);
const invoiceData = await prismaClient.invoice_application_match_view.findMany({
where: {
dataId: {
in: ids
}
},
// 预排序(视图字段为数字时有效;为字符串时按字典序,下面还有兜底排序)
orderBy: { invoiceJobNumber: 'asc' }
})
// 提取大协议的dataId
const largeAgreementIds = invoiceData
.filter(item => item.agreementType === '1')
.map(item => item.dataId);
// 从invoice_amount_details获取大协议的业务内容
const largeAgreementDetails = await prismaClient.invoice_amount_details.findMany({
where: {
dataid: {
in: largeAgreementIds
},
agreementType: AGREEMENT_TYPE_CODE[AGREEMENT_TYPES.LARGE]
}
});
// 创建一个映射存储每个dataid对应的多个business_content
const businessContentMap = new Map<string, string[]>();
largeAgreementDetails.forEach((detail, idx) => {
if (detail.dataid && detail.business_content) {
if (!businessContentMap.has(detail.dataid)) {
businessContentMap.set(detail.dataid, []);
}
businessContentMap.get(detail.dataid)?.push(`${idx}. [${detail.project_name}]${detail.business_content}`);
}
});
console.log('拉取开票申请数据数量:', invoiceData.length);
const modifiedInvoiceData = invoiceData
.map(item => {
const processed = processInvoiceDataItem(item)
const buHanShuiAmount = getBuHanShuiAmount(item)
const hanShuiAmount = getHanShuiAmount(item)
const AllAmount = getAllAmount(buHanShuiAmount, hanShuiAmount);
// 业务内容统一取开票事由
const businessContent = item.kai_piao_shi_you || '';
// 根据新的协议类型显示逻辑
let agreementTypeName = '';
if (item.agreementType === '2' || item.agreementType === '4') {
agreementTypeName = '独立协议';
} else if (item.agreementType === '1' || item.agreementType === '3') {
agreementTypeName = '大协议';
} else {
agreementTypeName = AGREEMENT_CODE_TO_NAME[item.agreementType as keyof typeof AGREEMENT_CODE_TO_NAME] || '';
}
// 专业组逻辑:提取项目编号的所有字母部分映射,如果没有项目编号则为客户服务室
const getProjectGroupByProjectId = (projectId: string | null | undefined): string => {
if (!projectId) {
return '客户服务室';
}
// 提取项目编号的所有字母部分(匹配连续的字母字符,不限位数)
const letterMatch = projectId.match(/^[A-Za-z]+/);
const prefix = letterMatch ? letterMatch[0] : '';
// 根据字母前缀映射专业组
const groupMapping: { [key: string]: string } = {
'YY': '运营室',
'ZP': '人员招聘项目组',
'ZW': '总务服务项目组',
'XC': '薪酬核算项目组',
'TX': '退休衔接服务项目组',
'TC': '统筹支持项目组',
'RZL': '人员入转离项目组',
'PJ': '人才评鉴项目组',
'HW': '会务服务项目组',
'GJ': '国际化服务项目组',
'GZ': '管理咨询项目组',
'KF': '客户服务室',
'FL': '福利业务项目组',
'FW': '法务合规中心',
'SJ': '数字化与交付项目组',
'KJ': '科技培训项目组',
'JG': '经营管理项目组',
'JN': '技能培训项目组',
'DJ': '党建培训项目组',
'JH': '计划核定项目组',
'LGB': '老干部服务项目组'
};
return groupMapping[prefix] || '客户服务室';
};
const zhuan_ye_zu = getProjectGroupByProjectId(item.projectNumber);
// 账期计算逻辑
let zhang_qi = '';
const kai_piao_ri_qi = item.kai_piao_ri_qi;
const hui_kuan_ri_qi = item.hui_kuan_ri_qi;
// 优先检查回款日期:如果有回款日期,则没有账期
if (hui_kuan_ri_qi) {
zhang_qi = '';
} else if (kai_piao_ri_qi) {
// 只有开票日期:账期 = 当前日期 - 开票日期(单位:天)
const kp = dayjs(kai_piao_ri_qi);
const now = dayjs();
if (kp.isValid()) {
const dayDiff = now.diff(kp, 'day');
zhang_qi = dayDiff.toString();
}
}
return {
"开票作业序号": item.invoiceJobNumber || '',
"数据ID": item.dataId,
"项目编号": item.projectNumber || '',
"单位": processed.customerCompanyName || '',
"票号": item.piao_hao || '',
"不含税金额": buHanShuiAmount,
"税额": AllAmount,
"含税金额": hanShuiAmount,
"发票类型": item.invoiceType || '',
"申请人": item.applicant || '',
"专业组": zhuan_ye_zu || '',
"开票日期": item.kai_piao_ri_qi || '',
"账期": zhang_qi, // 使用计算后的账期值
"回款日期": item.hui_kuan_ri_qi || '',
"回款金额": item.hui_kuan_jin_e || '',
"统一社会信用代码": processed.tong_yi_she_hui_xin_yong_dai_ma || '',
"开户行名称": processed.bankName || '',
"银行账号": processed.bankAccount || '',
"业务内容": businessContent,
"协议类型": agreementTypeName
};
})
// 兜底排序:有效数字升序,空值/非数字排最后
.sort((a, b) => {
const aRaw = a["开票作业序号"];
const bRaw = b["开票作业序号"];
const aEmpty = aRaw === '' || aRaw == null;
const bEmpty = bRaw === '' || bRaw == null;
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const aNum = Number(aRaw);
const bNum = Number(bRaw);
const aNumValid = Number.isFinite(aNum);
const bNumValid = Number.isFinite(bNum);
if (aNumValid && bNumValid) return aNum - bNum;
if (aNumValid) return -1;
if (bNumValid) return 1;
return String(aRaw).localeCompare(String(bRaw), 'zh-Hans-CN', { numeric: true });
});
// 转为工作表时的表头移除“集团内外”,统一“开票作业序号”
const worksheet = XLSX.utils.json_to_sheet(modifiedInvoiceData, {
header: [
"开票作业序号",
"数据ID",
"项目编号",
"单位",
// "集团内外" 已移除
"票号",
"不含税金额",
"税额",
"含税金额",
"发票类型",
"申请人",
"专业组",
"开票日期",
"账期",
"回款日期",
"回款金额",
"统一社会信用代码",
"开户行名称",
"银行账号",
"业务内容"
]
})
// 设置列宽
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
const cols = [];
for (let i = 0; i <= range.e.c; i++) {
const colHeader = worksheet[XLSX.utils.encode_cell({ r: 0, c: i })]?.v;
cols.push({ wch: COLUMN_WIDTHS[colHeader] || 15 });
}
worksheet['!cols'] = cols;
// 设置表头样式和单元格格式
for (let R = range.s.r; R <= range.e.r; R++) {
for (let C = range.s.c; C <= range.e.c; C++) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C });
if (R === 0) {
// 表头行
worksheet[cellAddress].s = HEADER_STYLE;
} else {
// 数据行
worksheet[cellAddress].s = CELL_STYLE;
}
}
}
// Create a new workbook and append the worksheet
const workbook = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(workbook, worksheet, '开票申请数据')
const buffer = XLSXStyle.write(workbook, { type: 'buffer', bookType: 'xlsx', cellStyles: true })
// 创建日期时间戳文件夹
const now = dayjs();
const dateStr = now.format('YYYY-MM-DD');
const timeStr = now.format('HHmmss');
const folderName = `${dateStr}_${timeStr}`;
const baseDir = path.join(process.cwd(), 'filebase');
const folderPath = path.join(baseDir, folderName);
// 确保filebase目录存在
if (!fs.existsSync(baseDir)) {
fs.mkdirSync(baseDir, { recursive: true });
}
if (!fs.existsSync(folderPath)) {
fs.mkdirSync(folderPath, { recursive: true });
}
// 保存Excel文件到文件夹
const excelFileName = '开票申请数据.xlsx';
const excelFilePath = path.join(folderPath, excelFileName);
fs.writeFileSync(excelFilePath, buffer);
for (const item of invoiceData) {
const invoiceFolderPath = path.join(folderPath, `${item.invoiceJobNumber}-${item.applicant}`)
if (item.uploadedContractAttachment) {
await downloadAttachments(
item.uploadedContractAttachment,
invoiceFolderPath,
'上传合同及相关附件',
item.dataId,
accessToken,
''
);
}
// 处理uploadedSealedCostList
if (item.uploadedSealedCostListDa) {
await downloadAttachments(
item.uploadedSealedCostListDa,
invoiceFolderPath,
'上传费用清单',
item.dataId,
accessToken,
''
);
}
// 处理uploadedSealedCostList
if (item.uploadedSealedCostListDu) {
await downloadAttachments(
item.uploadedSealedCostListDu,
invoiceFolderPath,
'上传盖章费用清单(独)',
item.dataId,
accessToken,
''
);
}
}
// 创建压缩文件
const zipFileName = `${folderName}.zip`;
const zipFilePath = path.join(process.env.DOWNLOAD_BASE_DIR ?? '', zipFileName);
const output = fs.createWriteStream(zipFilePath);
const archive = archiver('zip', {
zlib: { level: 9 } // 设置压缩级别
});
output.on('error', function (err) {
console.error('Error writing zip file:', err);
res.status(500).json({ message: 'Error writing zip file' });
});
// 当归档完成时发送响应
output.on('close', function () {
console.log(archive.pointer() + ' total bytes');
console.log('archiver has been finalized and the output file descriptor has closed.');
console.log(`${process.env.DOWNLOAD_BASE_URL ?? ''}/${zipFileName}`)
res.status(200).json({ code: 0, "error": null, message: '文件已经被妥善下载。', data: { zipFilePath: `${process.env.DOWNLOAD_BASE_URL ?? ''}/${zipFileName}` } });
});
// 处理归档错误
archive.on('error', function (err) {
console.error('Error creating zip file:', err);
res.status(500).json({ message: 'Error creating zip file' });
});
// 开始归档过程
archive.pipe(output);
archive.directory(folderPath, false); // 将整个文件夹添加到归档中 TODO 这个foldername 是个啥
archive.finalize();
} catch (error) {
console.error('Error exporting invoice data:', error)
res.status(500).json({ message: 'Internal server error', error })
}
}