diff --git a/backend/package.json b/backend/package.json index 5798675..5f0cf6a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "cors": "^2.8.6", "cos-nodejs-sdk-v5": "^2.15.4", "dotenv": "^16.6.1", + "exceljs": "^4.4.0", "express": "^4.18.2", "express-validator": "^7.3.1", "jsonwebtoken": "^9.0.3", diff --git a/backend/public/templates/财务记账导入模板.xlsx b/backend/public/templates/财务记账导入模板.xlsx new file mode 100644 index 0000000..308a655 Binary files /dev/null and b/backend/public/templates/财务记账导入模板.xlsx differ diff --git a/backend/scripts/generate-excel-template.js b/backend/scripts/generate-excel-template.js new file mode 100644 index 0000000..bf35d44 --- /dev/null +++ b/backend/scripts/generate-excel-template.js @@ -0,0 +1,292 @@ +const ExcelJS = require('exceljs'); +const path = require('path'); +const db = require('../db'); + +async function generateTemplate() { + const workbook = new ExcelJS.Workbook(); + workbook.creator = '公司财务管理系统'; + workbook.created = new Date(); + + const projectResult = await db.query('SELECT name FROM projects ORDER BY name'); + const projectNames = projectResult.rows.map(r => r.name); + + const categoryResult = await db.query('SELECT * FROM expense_categories WHERE is_active = true ORDER BY category_level1, sort_order'); + const categories = categoryResult.rows; + + const incomeCategories = categories.filter(c => c.category_level1 === 'income').map(c => c.label); + const projectCategories = categories.filter(c => c.category_level1 === 'project').map(c => c.label); + const companyCategories = categories.filter(c => c.category_level1 === 'company').map(c => c.label); + + // ========== Sheet2: 项目名称列表 ========== + const sheet2 = workbook.addWorksheet('项目列表', { + properties: { tabColor: { argb: '4472C4' } } + }); + + sheet2.getColumn(1).width = 40; + sheet2.getCell('A1').value = '项目名称'; + sheet2.getCell('A1').font = { bold: true, size: 12, color: { argb: 'FFFFFF' } }; + sheet2.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '4472C4' } }; + sheet2.getCell('A1').alignment = { horizontal: 'center' }; + + projectNames.forEach((name, i) => { + const cell = sheet2.getCell(`A${i + 2}`); + cell.value = name; + cell.border = { bottom: { style: 'thin', color: { argb: 'D9D9D9' } } }; + }); + + sheet2.getCell('C1').value = '收支类型'; + sheet2.getCell('C1').font = { bold: true }; + ['收入', '支出'].forEach((v, i) => { sheet2.getCell(`C${i + 2}`).value = v; }); + + sheet2.getCell('D1').value = '一级分类'; + sheet2.getCell('D1').font = { bold: true }; + ['收入', '项目支出', '公司支出'].forEach((v, i) => { sheet2.getCell(`D${i + 2}`).value = v; }); + + sheet2.getCell('F1').value = '收入分类'; + sheet2.getCell('F1').font = { bold: true }; + incomeCategories.forEach((v, i) => { sheet2.getCell(`F${i + 2}`).value = v; }); + + sheet2.getCell('G1').value = '项目支出分类'; + sheet2.getCell('G1').font = { bold: true }; + projectCategories.forEach((v, i) => { sheet2.getCell(`G${i + 2}`).value = v; }); + + sheet2.getCell('H1').value = '公司支出分类'; + sheet2.getCell('H1').font = { bold: true }; + companyCategories.forEach((v, i) => { sheet2.getCell(`H${i + 2}`).value = v; }); + + sheet2.getCell('J1').value = '币种'; + sheet2.getCell('J1').font = { bold: true }; + ['CNY', 'LAK', 'USD', 'THB'].forEach((v, i) => { sheet2.getCell(`J${i + 2}`).value = v; }); + + sheet2.getCell('K1').value = '对方类型'; + sheet2.getCell('K1').font = { bold: true }; + ['供应商', '分包商', '客户', '员工', '物流公司', '股东', '其他'].forEach((v, i) => { sheet2.getCell(`K${i + 2}`).value = v; }); + + const projectListEnd = Math.max(projectNames.length + 1, 2); + const categoryListEnd = Math.max(incomeCategories.length, projectCategories.length, companyCategories.length) + 1; + + // ========== Sheet1: 记账主表 ========== + const sheet1 = workbook.addWorksheet('财务记账', { + properties: { tabColor: { argb: '52C41A' } } + }); + + const headers = [ + { key: 'date', header: '日期', width: 13 }, + { key: 'txn_type', header: '收支类型', width: 11 }, + { key: 'level1', header: '一级分类', width: 11 }, + { key: 'level2', header: '二级分类', width: 14 }, + { key: 'project', header: '项目名称', width: 35 }, + { key: 'amount', header: '金额', width: 14 }, + { key: 'currency', header: '币种', width: 8 }, + { key: 'rate', header: '汇率', width: 10 }, + { key: 'amount_cny', header: '等效人民币', width: 14 }, + { key: 'counterparty', header: '对方名称', width: 18 }, + { key: 'counterparty_type', header: '对方类型', width: 10 }, + { key: 'user', header: '人员姓名', width: 10 }, + { key: 'desc', header: '描述', width: 30 }, + { key: 'voucher', header: '凭证编号', width: 14 }, + ]; + + sheet1.columns = headers; + + const headerRow = sheet1.getRow(1); + headerRow.height = 28; + headerRow.eachCell((cell) => { + cell.font = { bold: true, size: 11, color: { argb: 'FFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: '4472C4' } }; + cell.alignment = { horizontal: 'center', vertical: 'middle' }; + cell.border = { + top: { style: 'thin' }, bottom: { style: 'thin' }, + left: { style: 'thin' }, right: { style: 'thin' } + }; + }); + + sheet1.views = [{ state: 'frozen', ySplit: 1 }]; + + const dataRows = 500; + for (let i = 2; i <= dataRows + 1; i++) { + const row = sheet1.getRow(i); + + // I列: 等效人民币 = 金额 × 汇率(自动计算) + const cnyCell = row.getCell(9); + cnyCell.value = { formula: `F${i}*H${i}` }; + cnyCell.numFmt = '#,##0.00'; + cnyCell.protection = { locked: true }; + + // 金额列格式 + row.getCell(6).numFmt = '#,##0.00'; + // 汇率列格式 + row.getCell(8).numFmt = '0.000000'; + + // 日期列格式 + row.getCell(1).numFmt = 'YYYY-MM-DD'; + + // 交替行背景色 + if (i % 2 === 0) { + row.eachCell({ includeEmpty: true }, (cell) => { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'F2F7FB' } }; + }); + } + + // 边框 + row.eachCell({ includeEmpty: true }, (cell) => { + cell.border = { + bottom: { style: 'thin', color: { argb: 'D9D9D9' } }, + vertical: { style: 'thin', color: { argb: 'D9D9D9' } } + }; + }); + } + + // 数据验证 - 收支类型 + sheet1.getDataValidator = function() {}; + for (let i = 2; i <= dataRows + 1; i++) { + // B列: 收支类型 + sheet1.getCell(`B${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"收入,支出"'], + showErrorMessage: true, + errorTitle: '输入错误', + error: '请从下拉列表中选择:收入 或 支出' + }; + + // C列: 一级分类 + sheet1.getCell(`C${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"收入,项目支出,公司支出"'], + showErrorMessage: true, + errorTitle: '输入错误', + error: '请从下拉列表中选择' + }; + + // D列: 二级分类(收入+项目+公司 全部列出,用户根据一级分类选择对应的) + const allCategories = [...incomeCategories, ...projectCategories, ...companyCategories]; + const catStr = allCategories.join(','); + sheet1.getCell(`D${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: [`"${catStr}"`], + showErrorMessage: true, + errorTitle: '输入错误', + error: '请从下拉列表中选择二级分类' + }; + + // E列: 项目名称(从Sheet2引用) + sheet1.getCell(`E${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: [`项目列表!$A$2:$A$${projectListEnd}`], + showErrorMessage: true, + errorTitle: '输入错误', + error: '请从下拉列表中选择项目名称' + }; + + // G列: 币种 + sheet1.getCell(`G${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"CNY,LAK,USD,THB"'], + showErrorMessage: true, + errorTitle: '输入错误', + error: '请选择币种:CNY / LAK / USD / THB' + }; + + // K列: 对方类型 + sheet1.getCell(`K${i}`).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"供应商,分包商,客户,员工,物流公司,股东,其他"'], + showErrorMessage: true, + errorTitle: '输入错误', + error: '请从下拉列表中选择对方类型' + }; + + // H列: 汇率 - CNY时自动填1 + // A列: 日期验证 + sheet1.getCell(`A${i}`).dataValidation = { + type: 'date', + allowBlank: true, + operator: 'greaterThan', + formulae: [new Date(2020, 0, 1)], + showErrorMessage: true, + errorTitle: '日期格式错误', + error: '请输入有效日期,格式:YYYY-MM-DD' + }; + } + + // 示例数据(第2行) + const exampleRow = sheet1.getRow(2); + exampleRow.getCell(1).value = new Date(2026, 2, 15); + exampleRow.getCell(2).value = '支出'; + exampleRow.getCell(3).value = '项目支出'; + exampleRow.getCell(4).value = '材料采购'; + exampleRow.getCell(5).value = projectNames[0] || ''; + exampleRow.getCell(6).value = 150000; + exampleRow.getCell(7).value = 'CNY'; + exampleRow.getCell(8).value = 1; + exampleRow.getCell(10).value = '云南XX电力器材有限公司'; + exampleRow.getCell(11).value = '供应商'; + exampleRow.getCell(13).value = '采购190×12m电杆50根'; + + // 示例数据(第3行) + const exampleRow2 = sheet1.getRow(3); + exampleRow2.getCell(1).value = new Date(2026, 2, 20); + exampleRow2.getCell(2).value = '支出'; + exampleRow2.getCell(3).value = '项目支出'; + exampleRow2.getCell(4).value = '食宿费用'; + exampleRow2.getCell(5).value = projectNames[0] || ''; + exampleRow2.getCell(6).value = 100000; + exampleRow2.getCell(7).value = 'LAK'; + exampleRow2.getCell(8).value = 0.0003; + exampleRow2.getCell(10).value = 'XX招待所'; + exampleRow2.getCell(11).value = '其他'; + exampleRow2.getCell(12).value = '李四'; + exampleRow2.getCell(13).value = '3月驻现场住宿费'; + + // 示例数据(第4行) + const exampleRow3 = sheet1.getRow(4); + exampleRow3.getCell(1).value = new Date(2026, 1, 20); + exampleRow3.getCell(2).value = '收入'; + exampleRow3.getCell(3).value = '收入'; + exampleRow3.getCell(4).value = '项目合同收款'; + exampleRow3.getCell(5).value = projectNames[0] || ''; + exampleRow3.getCell(6).value = 300000; + exampleRow3.getCell(7).value = 'CNY'; + exampleRow3.getCell(8).value = 1; + exampleRow3.getCell(10).value = '老挝国家电力公司'; + exampleRow3.getCell(11).value = '客户'; + exampleRow3.getCell(13).value = '合同首付款30%'; + + // 保护工作表(锁定I列公式) + sheet1.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatCells: true, + formatColumns: true, + formatRows: true, + insertRows: true, + insertColumns: false, + deleteRows: false, + deleteColumns: false, + sort: true, + autoFilter: true, + }); + + // 设置可编辑列的单元格为未锁定 + for (let i = 2; i <= dataRows + 1; i++) { + [1,2,3,4,5,6,7,8,10,11,12,13,14].forEach(col => { + sheet1.getRow(i).getCell(col).protection = { locked: false }; + }); + } + + const outputPath = path.join(__dirname, '..', 'public', 'templates', '财务记账导入模板.xlsx'); + const fs = require('fs'); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + + await workbook.xlsx.writeFile(outputPath); + console.log('✅ Excel模板已生成:', outputPath); + return outputPath; +} + +generateTemplate().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); }); diff --git a/frontend/public/templates/财务记账导入模板.xlsx b/frontend/public/templates/财务记账导入模板.xlsx new file mode 100644 index 0000000..308a655 Binary files /dev/null and b/frontend/public/templates/财务记账导入模板.xlsx differ diff --git a/frontend/src/pages/admin/ExcelImport.tsx b/frontend/src/pages/admin/ExcelImport.tsx index eaf5d3a..92b2134 100644 --- a/frontend/src/pages/admin/ExcelImport.tsx +++ b/frontend/src/pages/admin/ExcelImport.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Card, Upload, Button, Table, Tag, message, Typography, Space, Alert, Descriptions, Progress, Select, DatePicker } from 'antd'; -import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutlined, DownloadOutlined } from '@ant-design/icons'; import * as XLSX from 'xlsx'; import apiClient from '../../utils/request'; import dayjs from 'dayjs'; @@ -233,6 +233,10 @@ const ExcelImport: React.FC = () => { + + + + {parsedData.length > 0 && ( <>