diff --git a/backend/app.js b/backend/app.js index afb236d..373a547 100644 --- a/backend/app.js +++ b/backend/app.js @@ -67,6 +67,8 @@ app.use('/api/returns', require('./routes/returns')); app.use('/api/project-materials', require('./routes/project-materials')); app.use('/api/process-templates', require('./routes/process-templates')); app.use('/api/receiving', require('./routes/receiving')); +app.use('/api/expense-categories', require('./routes/expense-categories')); +app.use('/api/financial-records', require('./routes/financial-records')); app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../frontend/dist/index.html')); diff --git a/backend/migrations/006_create_expense_categories.sql b/backend/migrations/006_create_expense_categories.sql new file mode 100644 index 0000000..a054d5c --- /dev/null +++ b/backend/migrations/006_create_expense_categories.sql @@ -0,0 +1,52 @@ +-- 创建财务分类配置表 +CREATE TABLE IF NOT EXISTS expense_categories ( + id SERIAL PRIMARY KEY, + category_level1 TEXT NOT NULL, + category_level2 TEXT NOT NULL, + label TEXT NOT NULL, + description TEXT, + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category_level1, category_level2) +); + +-- 收入分类 +INSERT INTO expense_categories (category_level1, category_level2, label, description, sort_order) VALUES +('income', 'contract_payment', '项目合同收款', '客户按合同打款到账', 1), +('income', 'deposit_refund', '质保金退回', '项目质保期满退回的质保金', 2), +('income', 'shareholder_investment', '股东投资入股', '股东注入的资金/股本', 3), +('income', 'other_income', '其他收入', '利息、退税、资产处置等', 4); + +-- 项目支出分类 +INSERT INTO expense_categories (category_level1, category_level2, label, description, sort_order) VALUES +('project', 'material', '材料采购', '电杆、导线、电缆、横担、金具、水泥等', 1), +('project', 'equipment', '设备采购', '变压器、开关柜、配电箱、计量箱等', 2), +('project', 'subcontract', '施工分包', '外包施工队的劳务/工程费', 3), +('project', 'labor', '人工工资', '项目现场工人、项目经理的工资或劳务费', 4), +('project', 'travel', '差旅交通', '驻现场人员路费,含跨境交通、油费', 5), +('project', 'accommodation', '食宿费用', '驻现场人员住宿费、餐饮费', 6), +('project', 'freight', '运输物流', '材料/设备一次运输、二次转运', 7), +('project', 'design', '勘测设计', '现场勘测、图纸设计费', 8), +('project', 'tools', '小型工具', '零散小工具,非固定资产', 9), +('project', 'client_relations', '客户/EDL关系', '项目相关的客户招待、电力局关系', 10), +('project', 'other_project', '其他项目支出', '保险、临时杂费', 11); + +-- 公司支出分类 +INSERT INTO expense_categories (category_level1, category_level2, label, description, sort_order) VALUES +('company', 'salary', '工资薪酬', '办公室管理人员工资、社保、公积金', 1), +('company', 'rent', '房租物业', '办公室/仓库租金、物业费、水电', 2), +('company', 'office', '办公费用', '办公用品、打印耗材、网络、电话', 3), +('company', 'commute', '交通通勤', '日常市内交通、公司车辆油费', 4), +('company', 'vehicle_maintenance', '车辆维保', '车辆维修、保养、配件更换', 5), +('company', 'assets', '固定资产', '购买车辆、大型工具、办公设备', 6), +('company', 'marketing', '营销拓展', '业务拓展、广告推广、展会', 7), +('company', 'entertainment', '招待费用', '公司级客户/合作方招待', 8), +('company', 'welfare', '员工福利', '聚餐、团建、节日礼品、体检', 9), +('company', 'logistics', '快递物流', '文件、样品快递费', 10), +('company', 'other_company', '其他公司支出', '中介佣金、税费等', 11); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_expense_categories_level1 ON expense_categories(category_level1); +CREATE INDEX IF NOT EXISTS idx_expense_categories_active ON expense_categories(is_active); diff --git a/backend/migrations/007_create_financial_records.sql b/backend/migrations/007_create_financial_records.sql new file mode 100644 index 0000000..b0a9da0 --- /dev/null +++ b/backend/migrations/007_create_financial_records.sql @@ -0,0 +1,44 @@ +-- 创建统一财务账本表 +CREATE TABLE IF NOT EXISTS financial_records ( + id SERIAL PRIMARY KEY, + record_code TEXT UNIQUE NOT NULL, + + txn_type TEXT NOT NULL CHECK (txn_type IN ('income', 'expense')), + category_level1 TEXT NOT NULL, + category_level2 TEXT NOT NULL, + + project_id INTEGER REFERENCES projects(id), + user_id INTEGER REFERENCES users(id), + user_name TEXT, + + amount_original REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CNY', + exchange_rate REAL NOT NULL DEFAULT 1, + amount_cny REAL NOT NULL DEFAULT 0, + + record_date DATE NOT NULL, + + counterparty_name TEXT, + counterparty_type TEXT, + counterparty_id INTEGER, + + source TEXT NOT NULL DEFAULT 'manual', + source_id INTEGER, + source_code TEXT, + + description TEXT, + voucher_no TEXT, + attachments TEXT, + + status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'pending', 'voided')), + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_financial_records_project ON financial_records(project_id); +CREATE INDEX IF NOT EXISTS idx_financial_records_user ON financial_records(user_id); +CREATE INDEX IF NOT EXISTS idx_financial_records_date ON financial_records(record_date); +CREATE INDEX IF NOT EXISTS idx_financial_records_type ON financial_records(txn_type, category_level1, category_level2); +CREATE INDEX IF NOT EXISTS idx_financial_records_source ON financial_records(source, source_id); +CREATE INDEX IF NOT EXISTS idx_financial_records_status ON financial_records(status); diff --git a/backend/routes/expense-categories.js b/backend/routes/expense-categories.js new file mode 100644 index 0000000..67930a0 --- /dev/null +++ b/backend/routes/expense-categories.js @@ -0,0 +1,122 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +router.get('/', async (req, res) => { + try { + const { level1, active } = req.query; + let sql = 'SELECT * FROM expense_categories WHERE 1=1'; + const params = []; + if (level1) { + params.push(level1); + sql += ` AND category_level1 = $${params.length}`; + } + if (active !== undefined) { + params.push(active === 'true'); + sql += ` AND is_active = $${params.length}`; + } + sql += ' ORDER BY category_level1, sort_order'; + const result = await db.query(sql, params); + res.json({ success: true, data: result.rows }); + } catch (error) { + console.error('获取分类失败:', error); + res.status(500).json({ success: false, message: '获取分类失败' }); + } +}); + +router.get('/grouped', async (req, res) => { + try { + const result = await db.query('SELECT * FROM expense_categories WHERE is_active = true ORDER BY category_level1, sort_order'); + const grouped = { + income: [], + project: [], + company: [] + }; + result.rows.forEach(row => { + if (grouped[row.category_level1]) { + grouped[row.category_level1].push({ + value: row.category_level2, + label: row.label, + description: row.description + }); + } + }); + res.json({ success: true, data: grouped }); + } catch (error) { + console.error('获取分组分类失败:', error); + res.status(500).json({ success: false, message: '获取分组分类失败' }); + } +}); + +router.post('/', authenticate, async (req, res) => { + try { + if (req.user.role !== 'admin') { + return res.status(403).json({ success: false, message: '无权限操作' }); + } + const { category_level1, category_level2, label, description, sort_order } = req.body; + if (!category_level1 || !category_level2 || !label) { + return res.status(400).json({ success: false, message: '缺少必填字段' }); + } + const result = await db.query( + `INSERT INTO expense_categories (category_level1, category_level2, label, description, sort_order) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [category_level1, category_level2, label, description, sort_order || 0] + ); + res.json({ success: true, data: result.rows[0] }); + } catch (error) { + console.error('创建分类失败:', error); + if (error.code === '23505') { + return res.status(400).json({ success: false, message: '分类已存在' }); + } + res.status(500).json({ success: false, message: '创建分类失败' }); + } +}); + +router.put('/:id', authenticate, async (req, res) => { + try { + if (req.user.role !== 'admin') { + return res.status(403).json({ success: false, message: '无权限操作' }); + } + const { id } = req.params; + const { category_level1, category_level2, label, description, sort_order, is_active } = req.body; + const result = await db.query( + `UPDATE expense_categories SET + category_level1 = COALESCE($1, category_level1), + category_level2 = COALESCE($2, category_level2), + label = COALESCE($3, label), + description = COALESCE($4, description), + sort_order = COALESCE($5, sort_order), + is_active = COALESCE($6, is_active), + updated_at = CURRENT_TIMESTAMP + WHERE id = $7 RETURNING *`, + [category_level1, category_level2, label, description, sort_order, is_active, id] + ); + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: '分类不存在' }); + } + res.json({ success: true, data: result.rows[0] }); + } catch (error) { + console.error('更新分类失败:', error); + res.status(500).json({ success: false, message: '更新分类失败' }); + } +}); + +router.delete('/:id', authenticate, async (req, res) => { + try { + if (req.user.role !== 'admin') { + return res.status(403).json({ success: false, message: '无权限操作' }); + } + const { id } = req.params; + const result = await db.query('DELETE FROM expense_categories WHERE id = $1 RETURNING *', [id]); + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: '分类不存在' }); + } + res.json({ success: true, message: '删除成功' }); + } catch (error) { + console.error('删除分类失败:', error); + res.status(500).json({ success: false, message: '删除分类失败' }); + } +}); + +module.exports = router; diff --git a/backend/routes/financial-records.js b/backend/routes/financial-records.js new file mode 100644 index 0000000..61a7627 --- /dev/null +++ b/backend/routes/financial-records.js @@ -0,0 +1,340 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const { authenticate } = require('../middleware/auth'); + +function generateCode(prefix) { + const now = new Date(); + const dateStr = now.getFullYear().toString() + + (now.getMonth() + 1).toString().padStart(2, '0') + + now.getDate().toString().padStart(2, '0'); + const rand = Math.floor(Math.random() * 10000).toString().padStart(4, '0'); + return `${prefix}-${dateStr}-${rand}`; +} + +router.get('/', authenticate, async (req, res) => { + try { + const { + txn_type, category_level1, category_level2, + project_id, user_id, source, + date_from, date_to, + page = 1, pageSize = 50 + } = req.query; + + let sql = 'SELECT fr.*, p.name as project_name FROM financial_records fr LEFT JOIN projects p ON fr.project_id = p.id WHERE 1=1'; + const params = []; + let paramIdx = 1; + + if (txn_type) { params.push(txn_type); sql += ` AND fr.txn_type = $${paramIdx++}`; } + if (category_level1) { params.push(category_level1); sql += ` AND fr.category_level1 = $${paramIdx++}`; } + if (category_level2) { params.push(category_level2); sql += ` AND fr.category_level2 = $${paramIdx++}`; } + if (project_id) { params.push(project_id); sql += ` AND fr.project_id = $${paramIdx++}`; } + if (user_id) { params.push(user_id); sql += ` AND fr.user_id = $${paramIdx++}`; } + if (source) { params.push(source); sql += ` AND fr.source = $${paramIdx++}`; } + if (date_from) { params.push(date_from); sql += ` AND fr.record_date >= $${paramIdx++}`; } + if (date_to) { params.push(date_to); sql += ` AND fr.record_date <= $${paramIdx++}`; } + + const countResult = await db.query(`SELECT COUNT(*) as total FROM (${sql}) sub`, params); + const total = parseInt(countResult.rows[0].total); + + sql += ' ORDER BY fr.record_date DESC, fr.created_at DESC'; + const offset = (parseInt(page) - 1) * parseInt(pageSize); + params.push(parseInt(pageSize)); + sql += ` LIMIT $${paramIdx++}`; + params.push(offset); + sql += ` OFFSET $${paramIdx++}`; + + const result = await db.query(sql, params); + res.json({ + success: true, + data: result.rows, + pagination: { + page: parseInt(page), + pageSize: parseInt(pageSize), + total, + totalPages: Math.ceil(total / parseInt(pageSize)) + } + }); + } catch (error) { + console.error('查询财务记录失败:', error); + res.status(500).json({ success: false, message: '查询财务记录失败' }); + } +}); + +router.get('/summary', authenticate, async (req, res) => { + try { + const { project_id, date_from, date_to } = req.query; + let where = "WHERE status != 'voided'"; + const params = []; + let paramIdx = 1; + + if (project_id) { params.push(project_id); where += ` AND project_id = $${paramIdx++}`; } + if (date_from) { params.push(date_from); where += ` AND record_date >= $${paramIdx++}`; } + if (date_to) { params.push(date_to); where += ` AND record_date <= $${paramIdx++}`; } + + const totalResult = await db.query( + `SELECT + COALESCE(SUM(CASE WHEN txn_type = 'income' THEN amount_cny ELSE 0 END), 0) as total_income, + COALESCE(SUM(CASE WHEN txn_type = 'expense' THEN amount_cny ELSE 0 END), 0) as total_expense, + COALESCE(SUM(CASE WHEN txn_type = 'income' THEN amount_cny ELSE -amount_cny END), 0) as net_profit + FROM financial_records ${where}`, + params + ); + + const byCategory = await db.query( + `SELECT category_level1, category_level2, + COALESCE(SUM(amount_cny), 0) as total_amount, + COUNT(*) as count + FROM financial_records ${where} + GROUP BY category_level1, category_level2 + ORDER BY category_level1, total_amount DESC`, + params + ); + + const byMonth = await db.query( + `SELECT TO_CHAR(record_date, 'YYYY-MM') as month, + COALESCE(SUM(CASE WHEN txn_type = 'income' THEN amount_cny ELSE 0 END), 0) as income, + COALESCE(SUM(CASE WHEN txn_type = 'expense' THEN amount_cny ELSE 0 END), 0) as expense + FROM financial_records ${where} + GROUP BY TO_CHAR(record_date, 'YYYY-MM') + ORDER BY month`, + params + ); + + res.json({ + success: true, + data: { + totals: totalResult.rows[0], + byCategory: byCategory.rows, + byMonth: byMonth.rows + } + }); + } catch (error) { + console.error('获取财务汇总失败:', error); + res.status(500).json({ success: false, message: '获取财务汇总失败' }); + } +}); + +router.get('/project-profit/:projectId', authenticate, async (req, res) => { + try { + const { projectId } = req.params; + const projectResult = await db.query('SELECT * FROM projects WHERE id = $1', [projectId]); + if (projectResult.rows.length === 0) { + return res.status(404).json({ success: false, message: '项目不存在' }); + } + const project = projectResult.rows[0]; + + const incomeResult = await db.query( + `SELECT COALESCE(SUM(amount_cny), 0) as total + FROM financial_records WHERE project_id = $1 AND txn_type = 'income' AND status != 'voided'`, + [projectId] + ); + const expenseResult = await db.query( + `SELECT COALESCE(SUM(amount_cny), 0) as total + FROM financial_records WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided'`, + [projectId] + ); + + const expenseByCategory = await db.query( + `SELECT category_level2, COALESCE(SUM(amount_cny), 0) as total_amount, COUNT(*) as count + FROM financial_records + WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided' + GROUP BY category_level2 ORDER BY total_amount DESC`, + [projectId] + ); + + const expenseByUser = await db.query( + `SELECT user_name, COALESCE(SUM(amount_cny), 0) as total_amount, COUNT(*) as count + FROM financial_records + WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided' AND user_id IS NOT NULL + GROUP BY user_name ORDER BY total_amount DESC`, + [projectId] + ); + + const totalIncome = parseFloat(incomeResult.rows[0].total); + const totalExpense = parseFloat(expenseResult.rows[0].total); + const contractAmount = parseFloat(project.contract_amount || 0); + + res.json({ + success: true, + data: { + project_name: project.name, + contract_amount: contractAmount, + total_income: totalIncome, + total_expense: totalExpense, + gross_profit: totalIncome - totalExpense, + gross_margin: totalIncome > 0 ? ((totalIncome - totalExpense) / totalIncome * 100).toFixed(2) : 0, + expense_by_category: expenseByCategory.rows, + expense_by_user: expenseByUser.rows + } + }); + } catch (error) { + console.error('获取项目利润失败:', error); + res.status(500).json({ success: false, message: '获取项目利润失败' }); + } +}); + +router.post('/', authenticate, async (req, res) => { + try { + const { + txn_type, category_level1, category_level2, + project_id, user_id, user_name, + amount_original, currency, exchange_rate, amount_cny, + record_date, + counterparty_name, counterparty_type, counterparty_id, + source, source_id, source_code, + description, voucher_no, attachments + } = req.body; + + if (!txn_type || !category_level1 || !category_level2 || !amount_original || !record_date) { + return res.status(400).json({ success: false, message: '缺少必填字段' }); + } + + const record_code = generateCode('FIN'); + const calculated_amount_cny = amount_cny || (parseFloat(amount_original) * parseFloat(exchange_rate || 1)); + + const result = await db.query( + `INSERT INTO financial_records + (record_code, txn_type, category_level1, category_level2, + project_id, user_id, user_name, + amount_original, currency, exchange_rate, amount_cny, + record_date, counterparty_name, counterparty_type, counterparty_id, + source, source_id, source_code, + description, voucher_no, attachments) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) + RETURNING *`, + [record_code, txn_type, category_level1, category_level2, + project_id || null, user_id || null, user_name || null, + amount_original, currency || 'CNY', exchange_rate || 1, calculated_amount_cny, + record_date, + counterparty_name || null, counterparty_type || null, counterparty_id || null, + source || 'manual', source_id || null, source_code || null, + description || null, voucher_no || null, attachments || null] + ); + + res.json({ success: true, data: result.rows[0] }); + } catch (error) { + console.error('创建财务记录失败:', error); + res.status(500).json({ success: false, message: '创建财务记录失败' }); + } +}); + +router.post('/batch', authenticate, async (req, res) => { + try { + if (req.user.role !== 'admin') { + return res.status(403).json({ success: false, message: '无权限操作' }); + } + + const { records } = req.body; + if (!Array.isArray(records) || records.length === 0) { + return res.status(400).json({ success: false, message: '没有可导入的记录' }); + } + + const results = []; + const errors = []; + + for (let i = 0; i < records.length; i++) { + const r = records[i]; + try { + if (!r.txn_type || !r.category_level1 || !r.category_level2 || !r.amount_original || !r.record_date) { + errors.push({ row: i + 1, message: '缺少必填字段' }); + continue; + } + + const record_code = generateCode('FIN'); + const calculated_amount_cny = r.amount_cny || (parseFloat(r.amount_original) * parseFloat(r.exchange_rate || 1)); + + let projectId = r.project_id || null; + if (!projectId && r.project_name) { + const projResult = await db.query("SELECT id FROM projects WHERE name = $1", [r.project_name]); + if (projResult.rows.length > 0) { + projectId = projResult.rows[0].id; + } + } + + let userId = r.user_id || null; + if (!userId && r.user_name) { + const userResult = await db.query("SELECT id FROM users WHERE name = $1", [r.user_name]); + if (userResult.rows.length > 0) { + userId = userResult.rows[0].id; + } + } + + const result = await db.query( + `INSERT INTO financial_records + (record_code, txn_type, category_level1, category_level2, + project_id, user_id, user_name, + amount_original, currency, exchange_rate, amount_cny, + record_date, counterparty_name, counterparty_type, counterparty_id, + source, source_id, source_code, + description, voucher_no) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) + RETURNING *`, + [record_code, r.txn_type, r.category_level1, r.category_level2, + projectId, userId, r.user_name || null, + r.amount_original, r.currency || 'CNY', r.exchange_rate || 1, calculated_amount_cny, + r.record_date, + r.counterparty_name || null, r.counterparty_type || null, r.counterparty_id || null, + r.source || 'manual', r.source_id || null, r.source_code || null, + r.description || null, r.voucher_no || null] + ); + results.push(result.rows[0]); + } catch (err) { + errors.push({ row: i + 1, message: err.message }); + } + } + + res.json({ + success: true, + data: { + imported: results.length, + failed: errors.length, + errors + } + }); + } catch (error) { + console.error('批量导入失败:', error); + res.status(500).json({ success: false, message: '批量导入失败' }); + } +}); + +router.put('/:id', authenticate, async (req, res) => { + try { + if (req.user.role !== 'admin') { + return res.status(403).json({ success: false, message: '无权限操作' }); + } + const { id } = req.params; + const fields = req.body; + const sets = []; + const params = []; + let idx = 1; + + const allowedFields = ['category_level1', 'category_level2', 'description', 'counterparty_name', 'counterparty_type', 'voucher_no', 'status']; + for (const key of allowedFields) { + if (fields[key] !== undefined) { + params.push(fields[key]); + sets.push(`${key} = $${idx++}`); + } + } + + if (sets.length === 0) { + return res.status(400).json({ success: false, message: '没有可更新的字段' }); + } + + params.push(id); + sets.push(`updated_at = CURRENT_TIMESTAMP`); + const result = await db.query( + `UPDATE financial_records SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, + params + ); + if (result.rows.length === 0) { + return res.status(404).json({ success: false, message: '记录不存在' }); + } + res.json({ success: true, data: result.rows[0] }); + } catch (error) { + console.error('更新财务记录失败:', error); + res.status(500).json({ success: false, message: '更新财务记录失败' }); + } +}); + +module.exports = router; diff --git a/backend/routes/payment-execution.js b/backend/routes/payment-execution.js index c74495f..4c4b71d 100644 --- a/backend/routes/payment-execution.js +++ b/backend/routes/payment-execution.js @@ -331,7 +331,97 @@ router.post('/execute', async (req, res) => { VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, CURRENT_TIMESTAMP) RETURNING id`, [recordCode, payment_type, source_id, amount, 'CNY', paymentDate, voucher_url, payee_account, remark]); - + + let finProjectId = null; + let finUserId = null; + let finUserName = null; + let finCategory1 = 'company'; + let finCategory2 = 'other_company'; + let finSourceCode = recordCode; + let finCounterpartyName = null; + let finDescription = remark || ''; + + switch (payment_type) { + case 'material': { + const planRow = await db.query('SELECT purchase_order_id FROM payment_plans WHERE id = $1', [source_id]); + if (planRow.rows.length > 0) { + const poRow = await db.query('SELECT project_id, code FROM purchase_orders WHERE id = $1', [planRow.rows[0].purchase_order_id]); + if (poRow.rows.length > 0) { + finProjectId = poRow.rows[0].project_id; + finSourceCode = poRow.rows[0].code; + } + } + finCategory1 = 'project'; + finCategory2 = 'material'; + finDescription = finDescription || '材料采购付款'; + break; + } + case 'primary_freight': + case 'secondary_freight': { + const lrRow = await db.query('SELECT purchase_order_id FROM logistics_records WHERE id = $1', [source_id]); + if (lrRow.rows.length > 0) { + const poRow = await db.query('SELECT project_id, code FROM purchase_orders WHERE id = $1', [lrRow.rows[0].purchase_order_id]); + if (poRow.rows.length > 0) { + finProjectId = poRow.rows[0].project_id; + finSourceCode = poRow.rows[0].code; + } + } + finCategory1 = 'project'; + finCategory2 = 'freight'; + finDescription = finDescription || (payment_type === 'primary_freight' ? '一次运费付款' : '二次运费付款'); + break; + } + case 'advance': { + const advRow = await db.query('SELECT project_id, applicant_id, applicant, advance_code, purpose FROM advances WHERE id = $1', [source_id]); + if (advRow.rows.length > 0) { + finProjectId = advRow.rows[0].project_id; + finUserId = advRow.rows[0].applicant_id; + finUserName = advRow.rows[0].applicant; + finSourceCode = advRow.rows[0].advance_code; + finDescription = finDescription || advRow.rows[0].purpose || '预支款'; + } + finCategory1 = finProjectId ? 'project' : 'company'; + finCategory2 = finProjectId ? 'other_project' : 'other_company'; + break; + } + case 'reimbursement': { + const reimbRow = await db.query('SELECT project_id, applicant_id, applicant, reimbursement_code, expense_type, description as reimb_desc FROM reimbursements WHERE id = $1', [source_id]); + if (reimbRow.rows.length > 0) { + finProjectId = reimbRow.rows[0].project_id; + finUserId = reimbRow.rows[0].applicant_id; + finUserName = reimbRow.rows[0].applicant; + finSourceCode = reimbRow.rows[0].reimbursement_code; + finDescription = finDescription || reimbRow.rows[0].reimb_desc || '报销'; + if (reimbRow.rows[0].expense_type === 'project') { + finCategory1 = 'project'; + finCategory2 = 'other_project'; + } else { + finCategory1 = 'company'; + finCategory2 = 'other_company'; + } + } + break; + } + } + + const finCode = 'FIN-' + Date.now(); + const finAmountCny = parseFloat(amount) || 0; + await db.query(` + INSERT INTO financial_records + (record_code, txn_type, category_level1, category_level2, + project_id, user_id, user_name, + amount_original, currency, exchange_rate, amount_cny, + record_date, counterparty_name, + source, source_id, source_code, + description, voucher_no) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, + [finCode, 'expense', finCategory1, finCategory2, + finProjectId, finUserId, finUserName, + amount, 'CNY', 1, finAmountCny, + paymentDate, finCounterpartyName, + payment_type, source_id, finSourceCode, + finDescription, voucher_url]); + await db.query('COMMIT'); res.json({ diff --git a/frontend/package.json b/frontend/package.json index 3c2ba00..2cee530 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "react-i18next": "^16.5.8", "react-query": "^3.39.3", "react-router-dom": "^6.21.0", + "xlsx": "^0.18.5", "zustand": "^4.4.7" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e7a61a1..f662f92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,8 @@ import BackupPage from './pages/admin/BackupPage' import ProcessManagement from './pages/admin/ProcessManagement' import ProcessTemplates from './pages/admin/ProcessTemplates' import AboutPage from './pages/admin/AboutPage' +import ExpenseCategories from './pages/admin/ExpenseCategories' +import ExcelImport from './pages/admin/ExcelImport' // 布局组件 import MainLayout from './components/layout/MainLayout' @@ -181,6 +183,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 81ae094..bb8da4e 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -9,7 +9,9 @@ import { InfoCircleOutlined, ArrowLeftOutlined, SettingOutlined, - AppstoreOutlined + AppstoreOutlined, + AccountBookOutlined, + ImportOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; @@ -40,6 +42,16 @@ const AdminLayout: React.FC = () => { icon: , label: '工程模板管理' }, + { + key: '/admin/expense-categories', + icon: , + label: '财务分类管理' + }, + { + key: '/admin/excel-import', + icon: , + label: 'Excel批量导入' + }, { key: '/admin/logs', icon: , diff --git a/frontend/src/pages/admin/ExcelImport.tsx b/frontend/src/pages/admin/ExcelImport.tsx new file mode 100644 index 0000000..eaf5d3a --- /dev/null +++ b/frontend/src/pages/admin/ExcelImport.tsx @@ -0,0 +1,286 @@ +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 * as XLSX from 'xlsx'; +import apiClient from '../../utils/request'; +import dayjs from 'dayjs'; + +const { Title } = Typography; + +const TXN_TYPE_MAP: Record = { '收入': 'income', '支出': 'expense' }; +const LEVEL1_MAP: Record = { '收入': 'income', '项目支出': 'project', '公司支出': 'company' }; +const COUNTERPARTY_TYPE_MAP: Record = { + '供应商': 'supplier', '分包商': 'subcontractor', '客户': 'customer', + '员工': 'employee', '物流公司': 'logistics', '股东': 'shareholder', '其他': 'other' +}; + +interface ParsedRow { + row: number; + record_date: string; + txn_type: string; + category_level1: string; + category_level2: string; + project_name: string; + amount_original: number; + currency: string; + exchange_rate: number; + amount_cny: number; + counterparty_name: string; + counterparty_type: string; + user_name: string; + description: string; + voucher_no: string; + errors: string[]; +} + +const ExcelImport: React.FC = () => { + const [parsedData, setParsedData] = useState([]); + const [categories, setCategories] = useState({}); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + + useEffect(() => { + apiClient.get('/expense-categories/grouped').then(res => { + if (res.data.success) setCategories(res.data.data); + }).catch(() => {}); + }, []); + + const getCategoryMap = () => { + const map: Record = {}; + Object.entries(categories).forEach(([level1, items]: [string, any]) => { + items.forEach((item: any) => { map[item.label] = item.value; }); + }); + return map; + }; + + const handleFileUpload = (file: File) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = new Uint8Array(e.target?.result as ArrayBuffer); + const workbook = XLSX.read(data, { type: 'array' }); + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const json = XLSX.utils.sheet_to_json(sheet, { header: 1 }); + + if (json.length < 2) { + message.error('Excel文件没有数据行'); + return; + } + + const categoryMap = getCategoryMap(); + const rows: ParsedRow[] = []; + + for (let i = 1; i < json.length; i++) { + const r: any[] = json[i]; + if (!r || r.length === 0 || !r[0]) continue; + + const errors: string[] = []; + const dateVal = r[0]; + let recordDate = ''; + if (typeof dateVal === 'number') { + const d = XLSX.SSF.parse_date_code(dateVal); + recordDate = `${d.y}-${String(d.m).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`; + } else if (typeof dateVal === 'string') { + recordDate = dateVal; + } + + const txnTypeLabel = String(r[1] || '').trim(); + const level1Label = String(r[2] || '').trim(); + const level2Label = String(r[3] || '').trim(); + const projectName = String(r[4] || '').trim(); + const amountOriginal = parseFloat(r[5]) || 0; + const currency = String(r[6] || 'CNY').trim(); + const exchangeRate = parseFloat(r[7]) || 1; + const amountCny = parseFloat(r[8]) || (amountOriginal * exchangeRate); + const counterpartyName = String(r[9] || '').trim(); + const counterpartyTypeLabel = String(r[10] || '').trim(); + const userName = String(r[11] || '').trim(); + const description = String(r[12] || '').trim(); + const voucherNo = String(r[13] || '').trim(); + + const txnType = TXN_TYPE_MAP[txnTypeLabel] || ''; + const level1 = LEVEL1_MAP[level1Label] || ''; + const level2 = categoryMap[level2Label] || ''; + + if (!recordDate) errors.push('日期为空'); + if (!txnType) errors.push(`收支类型无效: ${txnTypeLabel}`); + if (!level1) errors.push(`一级分类无效: ${level1Label}`); + if (!level2) errors.push(`二级分类无效: ${level2Label}`); + if (amountOriginal <= 0) errors.push('金额必须大于0'); + if ((level1 === 'project' || (level1 === 'income' && level2 !== 'shareholder_investment')) && !projectName) { + errors.push('项目支出/项目收入必须填写项目名称'); + } + + rows.push({ + row: i + 1, + record_date: recordDate, + txn_type: txnType, + category_level1: level1, + category_level2: level2, + project_name: projectName, + amount_original: amountOriginal, + currency, + exchange_rate: exchangeRate, + amount_cny: Math.round(amountCny * 100) / 100, + counterparty_name: counterpartyName, + counterparty_type: COUNTERPARTY_TYPE_MAP[counterpartyTypeLabel] || '', + user_name: userName, + description, + voucher_no: voucherNo, + errors + }); + } + + setParsedData(rows); + setImportResult(null); + message.success(`解析完成,共 ${rows.length} 条记录`); + } catch (err: any) { + message.error('解析Excel失败: ' + err.message); + } + }; + reader.readAsArrayBuffer(file); + return false; + }; + + const handleImport = async () => { + const validRows = parsedData.filter(r => r.errors.length === 0); + if (validRows.length === 0) { + message.error('没有有效的数据可导入'); + return; + } + + setImporting(true); + try { + const records = validRows.map(r => ({ + txn_type: r.txn_type, + category_level1: r.category_level1, + category_level2: r.category_level2, + project_name: r.project_name, + amount_original: r.amount_original, + currency: r.currency, + exchange_rate: r.exchange_rate, + amount_cny: r.amount_cny, + record_date: r.record_date, + counterparty_name: r.counterparty_name, + counterparty_type: r.counterparty_type, + user_name: r.user_name, + description: r.description, + voucher_no: r.voucher_no, + source: 'manual' + })); + + const res = await apiClient.post('/financial-records/batch', { records }); + if (res.data.success) { + setImportResult(res.data.data); + message.success(`导入完成:成功 ${res.data.data.imported} 条,失败 ${res.data.data.failed} 条`); + } + } catch (e: any) { + message.error('导入失败: ' + (e.response?.data?.message || e.message)); + } + setImporting(false); + }; + + const errorCount = parsedData.filter(r => r.errors.length > 0).length; + const validCount = parsedData.filter(r => r.errors.length === 0).length; + + const columns = [ + { title: '行号', dataIndex: 'row', width: 50 }, + { title: '日期', dataIndex: 'record_date', width: 100 }, + { title: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => {v === 'income' ? '收入' : '支出'} }, + { title: '一级', dataIndex: 'category_level1', width: 80, render: (v: string) => { + const m: Record = { income: '收入', project: '项目', company: '公司' }; + return m[v] || v; + }}, + { title: '二级', dataIndex: 'category_level2', width: 100 }, + { title: '项目', dataIndex: 'project_name', width: 120, ellipsis: true }, + { title: '金额', dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() }, + { title: '币种', dataIndex: 'currency', width: 50 }, + { title: '汇率', dataIndex: 'exchange_rate', width: 60 }, + { title: '等效人民币', dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() }, + { title: '描述', dataIndex: 'description', width: 150, ellipsis: true }, + { + title: '校验', width: 80, + render: (_: unknown, r: ParsedRow) => r.errors.length === 0 + ? + : + } + ]; + + return ( +
+ + Excel 批量导入财务记录 + + +

列顺序:日期 | 收支类型 | 一级分类 | 二级分类 | 项目名称 | 金额 | 币种 | 汇率 | 等效人民币 | 对方名称 | 对方类型 | 人员姓名 | 描述 | 凭证编号

+

收支类型:收入 / 支出 | 一级分类:收入 / 项目支出 / 公司支出 | 币种:CNY / USD / LAK / THB

+

二级分类必须使用系统中已有的分类名称(如:材料采购、工资薪酬等)

+
+ } + /> + + + + + + {parsedData.length > 0 && ( + <> +
+ + 共 {parsedData.length} 条 + 有效 {validCount} + {errorCount > 0 && 有误 {errorCount}} + + +
+ + r.errors.length > 0 ? 'row-error' : ''} + /> + + {importResult && ( + 0 ? 'warning' : 'success'} + showIcon + style={{ marginTop: 16 }} + message={`导入完成:成功 ${importResult.imported} 条,失败 ${importResult.failed} 条`} + description={ + importResult.errors?.length > 0 + ? importResult.errors.map((e: any, i: number) =>
第 {e.row} 行:{e.message}
) + : undefined + } + /> + )} + + )} + + + + + ); +}; + +export default ExcelImport; diff --git a/frontend/src/pages/admin/ExpenseCategories.tsx b/frontend/src/pages/admin/ExpenseCategories.tsx new file mode 100644 index 0000000..bfcba56 --- /dev/null +++ b/frontend/src/pages/admin/ExpenseCategories.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react'; +import { Table, Button, Modal, Form, Input, Select, Switch, Tag, message, Space, Card, Typography } from 'antd'; +import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons'; +import apiClient from '../../utils/request'; + +const { Title } = Typography; + +const LEVEL1_OPTIONS = [ + { value: 'income', label: '收入', color: 'green' }, + { value: 'project', label: '项目支出', color: 'blue' }, + { value: 'company', label: '公司支出', color: 'orange' }, +]; + +const ExpenseCategories: React.FC = () => { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form] = Form.useForm(); + + const fetchCategories = async () => { + setLoading(true); + try { + const res = await apiClient.get('/expense-categories'); + if (res.data.success) { + setCategories(res.data.data); + } + } catch (e) { + message.error('获取分类失败'); + } + setLoading(false); + }; + + useEffect(() => { fetchCategories(); }, []); + + const handleAdd = () => { + setEditingId(null); + form.resetFields(); + setModalVisible(true); + }; + + const handleEdit = (record: any) => { + setEditingId(record.id); + form.setFieldsValue(record); + setModalVisible(true); + }; + + const handleSave = async () => { + try { + const values = await form.validateFields(); + if (editingId) { + await apiClient.put(`/expense-categories/${editingId}`, values); + message.success('更新成功'); + } else { + await apiClient.post('/expense-categories', values); + message.success('创建成功'); + } + setModalVisible(false); + fetchCategories(); + } catch (e: any) { + if (e.response?.data?.message) message.error(e.response.data.message); + } + }; + + const handleToggleActive = async (id: number, isActive: boolean) => { + try { + await apiClient.put(`/expense-categories/${id}`, { is_active: isActive }); + message.success(isActive ? '已启用' : '已禁用'); + fetchCategories(); + } catch (e) { + message.error('操作失败'); + } + }; + + const getLevel1Tag = (level1: string) => { + const opt = LEVEL1_OPTIONS.find(o => o.value === level1); + return {opt?.label || level1}; + }; + + const columns = [ + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '一级分类', dataIndex: 'category_level1', width: 120, render: getLevel1Tag }, + { title: '二级编码', dataIndex: 'category_level2', width: 160 }, + { title: '显示名称', dataIndex: 'label', width: 140 }, + { title: '说明', dataIndex: 'description', ellipsis: true }, + { title: '排序', dataIndex: 'sort_order', width: 70 }, + { + title: '状态', dataIndex: 'is_active', width: 90, + render: (v: boolean, r: any) => ( + handleToggleActive(r.id, checked)} /> + ) + }, + { + title: '操作', width: 100, + render: (_: unknown, r: any) => ( + + ) + } + ]; + + return ( +
+ +
+ 财务分类管理 + + + + +
+
+ + + setModalVisible(false)} + destroyOnClose + > +
+ + + + + + + + + + + + + +
+ + ); +}; + +export default ExpenseCategories; diff --git a/frontend/src/pages/finance/FinancePage.tsx b/frontend/src/pages/finance/FinancePage.tsx index c7df02b..049e250 100644 --- a/frontend/src/pages/finance/FinancePage.tsx +++ b/frontend/src/pages/finance/FinancePage.tsx @@ -1,196 +1,185 @@ -import React from 'react'; -import { Card, Typography, Table, Statistic, Row, Col, Tag } from 'antd'; -import { DollarOutlined, FileTextOutlined, CheckCircleOutlined } from '@ant-design/icons'; - -const { Title, Paragraph } = Typography; - -const FinancePage: React.FC = () => { - // 统计数据 - 从API获取真实数据 - const [stats, setStats] = React.useState([ - { - title: '本月总收入', - value: 0, - prefix: '¥', - icon: , - trend: '', - color: '#3f8600', - }, - { - title: '本月总支出', - value: 0, - prefix: '¥', - icon: , - trend: '', - color: '#cf1322', - }, - { - title: '待审批报销', - value: 0, - prefix: '¥', - icon: , - trend: '', - color: '#1890ff', - }, - ]); - - // 财务记录数据 - 从API获取真实数据 - const [dataSource, setDataSource] = React.useState([]); - - // 从API获取财务数据 - React.useEffect(() => { - // 这里可以添加从API获取真实数据的逻辑 - // 例如:fetch('/api/finance-stats') 等 - }, []); - - // 桌面端表格列 - const desktopColumns = [ - { - title: '日期', - dataIndex: 'date', - key: 'date', - sorter: (a: any, b: any) => a.date.localeCompare(b.date), - }, - { - title: '类型', - dataIndex: 'type', - key: 'type', - render: (type: string) => ( - - {type === '收入' ? '↑ 收入' : '↓ 支出'} - - ), - }, - { - title: '类别', - dataIndex: 'category', - key: 'category', - }, - { - title: '项目', - dataIndex: 'project', - key: 'project', - }, - { - title: '金额', - dataIndex: 'amount', - key: 'amount', - render: (amount: number) => `¥${amount.toLocaleString()}`, - sorter: (a: any, b: any) => a.amount - b.amount, - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - render: (status: string) => { - const colorMap: Record = { - '已入账': 'green', - '已付款': 'blue', - '处理中': 'orange', - }; - return {status}; - }, - }, - ]; - - // 移动端简化表格列 - const mobileColumns = [ - { - title: '日期', - dataIndex: 'date', - key: 'date', - width: 100, - }, - { - title: '类型', - dataIndex: 'type', - key: 'type', - width: 60, - render: (type: string) => ( - - {type === '收入' ? '↑' : '↓'} - - ), - }, - { - title: '金额', - dataIndex: 'amount', - key: 'amount', - render: (amount: number) => ( -
¥{amount.toLocaleString()}
- ), - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - render: (status: string) => ( - - {status} - - ), - }, - ]; - - const [isMobile, setIsMobile] = React.useState(false); - - React.useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth <= 768); - }; - - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - return ( -
-
- 财务管理 - - 查看公司财务状况、收支明细 - -
- - {/* 统计卡片 - 移动端优化 */} - - {stats.map((stat, index) => ( -
- - {stat.title}} - value={stat.value} - prefix={stat.prefix} - suffix={stat.trend} - valueStyle={{ - color: stat.color, - fontSize: isMobile ? 18 : undefined - }} - /> - - - ))} - - - {/* 财务明细表 */} - -
- - - ); -}; - -export default FinancePage; \ No newline at end of file +import React, { useState, useEffect } from 'react'; +import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin } from 'antd'; +import { DollarOutlined, FileTextOutlined, RiseOutlined, FallOutlined } from '@ant-design/icons'; +import apiClient from '../../utils/request'; +import dayjs from 'dayjs'; + +const { Title, Paragraph } = Typography; +const { RangePicker } = DatePicker; + +const LEVEL1_LABELS: Record = { income: '收入', project: '项目支出', company: '公司支出' }; +const LEVEL2_LABELS: Record = { + contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入', + material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资', + travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计', + tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出', + salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤', + vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展', + entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出' +}; + +const FinancePage: React.FC = () => { + const [loading, setLoading] = useState(false); + const [summary, setSummary] = useState({ total_income: 0, total_expense: 0, net_profit: 0 }); + const [byCategory, setByCategory] = useState([]); + const [records, setRecords] = useState([]); + const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0 }); + const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null); + const [filterType, setFilterType] = useState(undefined); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth <= 768); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + const fetchSummary = async () => { + try { + const params: any = {}; + if (dateRange && dateRange[0]) { + params.date_from = dateRange[0].format('YYYY-MM-DD'); + params.date_to = dateRange[1]?.format('YYYY-MM-DD'); + } + const res = await apiClient.get('/financial-records/summary', { params }); + if (res.data.success) { + setSummary(res.data.data.totals); + setByCategory(res.data.data.byCategory); + } + } catch (e) { console.error(e); } + }; + + const fetchRecords = async (page = 1) => { + setLoading(true); + try { + const params: any = { page, pageSize: pagination.pageSize }; + if (dateRange && dateRange[0]) { + params.date_from = dateRange[0].format('YYYY-MM-DD'); + params.date_to = dateRange[1]?.format('YYYY-MM-DD'); + } + if (filterType) params.txn_type = filterType; + const res = await apiClient.get('/financial-records', { params }); + if (res.data.success) { + setRecords(res.data.data); + setPagination(res.data.pagination); + } + } catch (e) { console.error(e); } + setLoading(false); + }; + + useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]); + + const desktopColumns = [ + { title: '日期', dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date) }, + { + title: '收支', dataIndex: 'txn_type', width: 60, + render: (v: string) => {v === 'income' ? '收入' : '支出'} + }, + { + title: '一级分类', dataIndex: 'category_level1', width: 90, + render: (v: string) => LEVEL1_LABELS[v] || v + }, + { + title: '二级分类', dataIndex: 'category_level2', width: 100, + render: (v: string) => LEVEL2_LABELS[v] || v + }, + { title: '项目', dataIndex: 'project_name', width: 140, ellipsis: true }, + { title: '原始金额', dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const }, + { title: '币种', dataIndex: 'currency', width: 50 }, + { title: '等效人民币', dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const, sorter: (a: any, b: any) => a.amount_cny - b.amount_cny }, + { title: '人员', dataIndex: 'user_name', width: 70 }, + { title: '描述', dataIndex: 'description', ellipsis: true }, + { + title: '来源', dataIndex: 'source', width: 80, + render: (v: string) => { + const m: Record = { manual: '手动导入', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' }; + return {m[v] || v}; + } + } + ]; + + const mobileColumns = [ + { title: '日期', dataIndex: 'record_date', width: 80 }, + { title: '收支', dataIndex: 'txn_type', width: 40, render: (v: string) => {v === 'income' ? '↑' : '↓'} }, + { title: '分类', dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v }, + { title: '金额', dataIndex: 'amount_cny', render: (v: number) => ¥{v?.toLocaleString()}, align: 'right' as const }, + ]; + + return ( +
+
+ 财务管理 + 查看公司财务状况、收支明细 +
+ + +
+ + 总收入} value={summary.total_income} prefix="¥" + valueStyle={{ color: '#3f8600', fontSize: isMobile ? 18 : undefined }} + icon={} /> + + + + + 总支出} value={summary.total_expense} prefix="¥" + valueStyle={{ color: '#cf1322', fontSize: isMobile ? 18 : undefined }} + icon={} /> + + + + + 净利润} value={summary.net_profit} prefix="¥" + valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 18 : undefined }} + icon={} /> + + + + + {byCategory.length > 0 && ( + + + {byCategory.filter(c => c.category_level1 !== 'income').map((c, i) => ( + +
{LEVEL2_LABELS[c.category_level2] || c.category_level2}
+
¥{parseFloat(c.total_amount).toLocaleString()}
+ + ))} + + + )} + + + setDateRange(dates as any)} /> +
fetchRecords(page), + size: isMobile ? 'small' : 'default', + showTotal: (total) => `共 ${total} 条` + }} + /> + + + + ); +}; + +export default FinancePage; diff --git a/frontend/src/pages/projects/ProjectDetail.tsx b/frontend/src/pages/projects/ProjectDetail.tsx index 62ae4e4..632fb06 100644 --- a/frontend/src/pages/projects/ProjectDetail.tsx +++ b/frontend/src/pages/projects/ProjectDetail.tsx @@ -868,31 +868,94 @@ const ProjectDetail: React.FC = () => { ) - // 财务信息 Tab const FinanceTab = () => { - const contractAmount = parseFloat(project?.contract_amount || '0') - const totalIncome = finances.filter(f => f.payment_type === 'income').reduce((sum, f) => sum + (f.amount || 0), 0) - const totalExpense = finances.filter(f => f.payment_type === 'expense').reduce((sum, f) => sum + (f.amount || 0), 0) - const grossProfit = totalIncome - totalExpense + const [profitData, setProfitData] = React.useState(null); + const [profitLoading, setProfitLoading] = React.useState(false); + + React.useEffect(() => { + if (!project?.id) return; + setProfitLoading(true); + apiClient.get(`/financial-records/project-profit/${project.id}`) + .then(res => { if (res.data.success) setProfitData(res.data.data); }) + .catch(() => {}) + .finally(() => setProfitLoading(false)); + }, [project?.id]); + + const contractAmount = parseFloat(project?.contract_amount || '0'); + const totalIncome = profitData?.total_income || 0; + const totalExpense = profitData?.total_expense || 0; + const grossProfit = profitData?.gross_profit || 0; + const grossMargin = profitData?.gross_margin || 0; + + const LEVEL2_LABELS: Record = { + material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资', + travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计', + tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出', + contract_payment: '合同收款', deposit_refund: '质保金退回' + }; return ( - - - - ¥{contractAmount.toLocaleString()} - - ¥{totalIncome.toLocaleString()} - ¥0 - ¥0 - ¥0 - ¥{totalExpense.toLocaleString()} - - = 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}> - ¥{grossProfit.toLocaleString()} - - - - + + + + ¥{contractAmount.toLocaleString()} + + ¥{totalIncome.toLocaleString()} + + + ¥{totalExpense.toLocaleString()} + + + = 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold', fontSize: 16 }}> + ¥{grossProfit.toLocaleString()} + + + + = 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}> + {grossMargin}% + + + + + + {profitData?.expense_by_category?.length > 0 && ( + +
LEVEL2_LABELS[v] || v }, + { title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const }, + { title: '笔数', dataIndex: 'count', align: 'center' as const }, + { + title: '占比', render: (_: unknown, r: any) => { + const pct = totalExpense > 0 ? (parseFloat(r.total_amount) / totalExpense * 100).toFixed(1) : '0'; + return `${pct}%`; + } + }, + ]} + /> + + )} + + {profitData?.expense_by_user?.length > 0 && ( + +
parseFloat(v).toLocaleString(), align: 'right' as const }, + { title: '笔数', dataIndex: 'count', align: 'center' as const }, + ]} + /> + + )} + ) } diff --git a/frontend/src/pages/reports/ReportsPage.tsx b/frontend/src/pages/reports/ReportsPage.tsx index 0eb33e6..8079a64 100644 --- a/frontend/src/pages/reports/ReportsPage.tsx +++ b/frontend/src/pages/reports/ReportsPage.tsx @@ -1,177 +1,177 @@ -import React, { useState, useEffect } from 'react'; -import { Card, Typography, Table, DatePicker, Button, Row, Col, Tag } from 'antd'; -import { DownloadOutlined } from '@ant-design/icons'; - -const { Title, Paragraph } = Typography; - -const ReportsPage: React.FC = () => { - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth <= 768); - }; - - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - // 报表数据 - 从API获取或为空数组 - const dataSource: any[] = []; - - // 桌面端表格列 - const desktopColumns = [ - { - title: '月份', - dataIndex: 'month', - key: 'month', - }, - { - title: '总收入', - dataIndex: 'income', - key: 'income', - render: (amount: number) => `¥${amount.toLocaleString()}`, - }, - { - title: '总支出', - dataIndex: 'expense', - key: 'expense', - render: (amount: number) => `¥${amount.toLocaleString()}`, - }, - { - title: '净利润', - dataIndex: 'profit', - key: 'profit', - render: (amount: number) => ( - 0 ? 'green' : 'red' }}> - ¥{amount.toLocaleString()} - - ), - }, - { - title: '项目数量', - dataIndex: 'projects', - key: 'projects', - }, - { - title: '操作', - key: 'action', - render: () => ( - - ), - }, - ]; - - // 移动端简化表格列 - const mobileColumns = [ - { - title: '月份', - dataIndex: 'month', - key: 'month', - render: (month: string) => month.replace('-', '/'), - }, - { - title: '收入', - dataIndex: 'income', - key: 'income', - render: (amount: number) => ( -
¥{(amount / 10000).toFixed(0)}万
- ), - }, - { - title: '支出', - dataIndex: 'expense', - key: 'expense', - render: (amount: number) => ( -
¥{(amount / 10000).toFixed(0)}万
- ), - }, - { - title: '利润', - dataIndex: 'profit', - key: 'profit', - render: (amount: number) => ( -
0 ? 'green' : 'red' }}> - ¥{(amount / 10000).toFixed(0)}万 -
- ), - }, - ]; - - return ( -
-
- 统计报表 - - 查看项目财务报表和统计分析数据 - -
- - } /> - ) : ( - - ) - } - > -
{ - let totalIncome = 0; - let totalExpense = 0; - let totalProfit = 0; - let totalProjects = 0; - - pageData.forEach(({ income, expense, profit, projects }) => { - totalIncome += income; - totalExpense += expense; - totalProfit += profit; - totalProjects += projects; - }); - - return ( - - - - 合计 - - - ¥{(totalIncome / 10000).toFixed(0)}万 - - - ¥{(totalExpense / 10000).toFixed(0)}万 - - - 0 ? 'green' : 'red' }}> - ¥{(totalProfit / 10000).toFixed(0)}万 - - - {!isMobile && ( - <> - - {totalProjects} - - - - )} - - - ); - }} - /> - - - ); -}; - -export default ReportsPage; \ No newline at end of file +import React, { useState, useEffect } from 'react'; +import { Card, Typography, Table, DatePicker, Row, Col, Statistic, Tag, Spin } from 'antd'; +import { RiseOutlined, FallOutlined, DollarOutlined } from '@ant-design/icons'; +import apiClient from '../../utils/request'; +import dayjs from 'dayjs'; + +const { Title, Paragraph } = Typography; + +const LEVEL2_LABELS: Record = { + contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入', + material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资', + travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计', + tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出', + salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤', + vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展', + entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出' +}; + +const ReportsPage: React.FC = () => { + const [loading, setLoading] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [summary, setSummary] = useState({ total_income: 0, total_expense: 0, net_profit: 0 }); + const [byMonth, setByMonth] = useState([]); + const [byCategory, setByCategory] = useState([]); + const [selectedMonth, setSelectedMonth] = useState(null); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth <= 768); + checkMobile(); + window.addEventListener('resize', checkMobile); + return () => window.removeEventListener('resize', checkMobile); + }, []); + + const fetchData = async () => { + setLoading(true); + try { + const params: any = {}; + if (selectedMonth) { + params.date_from = selectedMonth.startOf('month').format('YYYY-MM-DD'); + params.date_to = selectedMonth.endOf('month').format('YYYY-MM-DD'); + } + const res = await apiClient.get('/financial-records/summary', { params }); + if (res.data.success) { + setSummary(res.data.data.totals); + setByMonth(res.data.data.byMonth); + setByCategory(res.data.data.byCategory); + } + } catch (e) { console.error(e); } + setLoading(false); + }; + + useEffect(() => { fetchData(); }, [selectedMonth]); + + const monthData = byMonth.map(m => ({ + ...m, + profit: m.income - m.expense + })); + + const desktopColumns = [ + { title: '月份', dataIndex: 'month', key: 'month' }, + { title: '总收入', dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` }, + { title: '总支出', dataIndex: 'expense', key: 'expense', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` }, + { + title: '净利润', dataIndex: 'profit', key: 'profit', + render: (v: number) => = 0 ? 'green' : 'red', fontWeight: 'bold' }}>¥{v.toLocaleString()} + }, + ]; + + const mobileColumns = [ + { title: '月份', dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') }, + { title: '收入', dataIndex: 'income', key: 'income', render: (v: number) => ¥{(parseFloat(v) / 10000).toFixed(1)}万 }, + { title: '支出', dataIndex: 'expense', key: 'expense', render: (v: number) => ¥{(parseFloat(v) / 10000).toFixed(1)}万 }, + { + title: '利润', dataIndex: 'profit', key: 'profit', + render: (v: number) => = 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}万 + }, + ]; + + const categoryColumns = [ + { title: '分类', dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v }, + { title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const }, + { title: '笔数', dataIndex: 'count', align: 'center' as const }, + { + title: '占比', render: (_: unknown, r: any) => { + const total = byCategory.filter(c => c.category_level1 === r.category_level1).reduce((s, c) => s + parseFloat(c.total_amount), 0); + return total > 0 ? `${(parseFloat(r.total_amount) / total * 100).toFixed(1)}%` : '-'; + } + }, + ]; + + const projectCategories = byCategory.filter(c => c.category_level1 === 'project'); + const companyCategories = byCategory.filter(c => c.category_level1 === 'company'); + const incomeCategories = byCategory.filter(c => c.category_level1 === 'income'); + + return ( +
+
+ 统计报表 + 查看项目财务报表和统计分析数据 +
+ + +
+ + + + + + + + + + + + = 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} /> + + + + + setSelectedMonth(d)} placeholder="选择月份" />} + > + +
{ + let ti = 0, te = 0; + pageData.forEach(({ income, expense }) => { ti += parseFloat(income); te += parseFloat(expense); }); + return ( + + + 合计 + ¥{ti.toLocaleString()} + ¥{te.toLocaleString()} + + = 0 ? 'green' : 'red' }}>¥{(ti - te).toLocaleString()} + + + + ); + }} + /> + + + + + {incomeCategories.length > 0 && ( + + 收入收入分类} size="small"> +
+ + + )} + {projectCategories.length > 0 && ( + + 项目项目支出分类} size="small"> +
+ + + )} + {companyCategories.length > 0 && ( + + 公司公司支出分类} size="small"> +
+ + + )} + + + ); +}; + +export default ReportsPage;