财务分类体系+统一账本+Excel导入+报表改造
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
<Route path="logs" element={<SystemLogsPage />} />
|
||||
<Route path="backup" element={<BackupPage />} />
|
||||
<Route path="about" element={<AboutPage />} />
|
||||
<Route path="expense-categories" element={<ExpenseCategories />} />
|
||||
<Route path="excel-import" element={<ExcelImport />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -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: <AppstoreOutlined />,
|
||||
label: '工程模板管理'
|
||||
},
|
||||
{
|
||||
key: '/admin/expense-categories',
|
||||
icon: <AccountBookOutlined />,
|
||||
label: '财务分类管理'
|
||||
},
|
||||
{
|
||||
key: '/admin/excel-import',
|
||||
icon: <ImportOutlined />,
|
||||
label: 'Excel批量导入'
|
||||
},
|
||||
{
|
||||
key: '/admin/logs',
|
||||
icon: <FileTextOutlined />,
|
||||
|
||||
@@ -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<string, string> = { '收入': 'income', '支出': 'expense' };
|
||||
const LEVEL1_MAP: Record<string, string> = { '收入': 'income', '项目支出': 'project', '公司支出': 'company' };
|
||||
const COUNTERPARTY_TYPE_MAP: Record<string, string> = {
|
||||
'供应商': '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<ParsedRow[]>([]);
|
||||
const [categories, setCategories] = useState<any>({});
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get('/expense-categories/grouped').then(res => {
|
||||
if (res.data.success) setCategories(res.data.data);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const getCategoryMap = () => {
|
||||
const map: Record<string, string> = {};
|
||||
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) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> },
|
||||
{ title: '一级', dataIndex: 'category_level1', width: 80, render: (v: string) => {
|
||||
const m: Record<string, string> = { 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
|
||||
? <CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
: <CloseCircleOutlined style={{ color: '#ff4d4f' }} title={r.errors.join('; ')} />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Title level={4}>Excel 批量导入财务记录</Title>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="Excel 模板格式要求"
|
||||
description={
|
||||
<div>
|
||||
<p>列顺序:日期 | 收支类型 | 一级分类 | 二级分类 | 项目名称 | 金额 | 币种 | 汇率 | 等效人民币 | 对方名称 | 对方类型 | 人员姓名 | 描述 | 凭证编号</p>
|
||||
<p>收支类型:收入 / 支出 | 一级分类:收入 / 项目支出 / 公司支出 | 币种:CNY / USD / LAK / THB</p>
|
||||
<p>二级分类必须使用系统中已有的分类名称(如:材料采购、工资薪酬等)</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleFileUpload}
|
||||
>
|
||||
<Button icon={<FileExcelOutlined />} type="primary">选择 Excel 文件</Button>
|
||||
</Upload>
|
||||
|
||||
{parsedData.length > 0 && (
|
||||
<>
|
||||
<div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<span>共 {parsedData.length} 条</span>
|
||||
<Tag color="green">有效 {validCount}</Tag>
|
||||
{errorCount > 0 && <Tag color="red">有误 {errorCount}</Tag>}
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImport}
|
||||
loading={importing}
|
||||
disabled={validCount === 0}
|
||||
>
|
||||
导入 {validCount} 条有效记录
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
dataSource={parsedData}
|
||||
columns={columns}
|
||||
rowKey="row"
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 20 }}
|
||||
rowClassName={(r) => r.errors.length > 0 ? 'row-error' : ''}
|
||||
/>
|
||||
|
||||
{importResult && (
|
||||
<Alert
|
||||
type={importResult.failed > 0 ? 'warning' : 'success'}
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
message={`导入完成:成功 ${importResult.imported} 条,失败 ${importResult.failed} 条`}
|
||||
description={
|
||||
importResult.errors?.length > 0
|
||||
? importResult.errors.map((e: any, i: number) => <div key={i}>第 {e.row} 行:{e.message}</div>)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<style>{`.row-error { background: #fff2f0 !important; }`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExcelImport;
|
||||
@@ -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<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(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 <Tag color={opt?.color}>{opt?.label || level1}</Tag>;
|
||||
};
|
||||
|
||||
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) => (
|
||||
<Switch size="small" checked={v} onChange={(checked) => handleToggleActive(r.id, checked)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作', width: 100,
|
||||
render: (_: unknown, r: any) => (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>财务分类管理</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchCategories}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>新增分类</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={categories}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑分类' : '新增分类'}
|
||||
open={modalVisible}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}>
|
||||
<Select options={LEVEL1_OPTIONS.map(o => ({ value: o.value, label: o.label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category_level2" label="二级编码" rules={[{ required: true, message: '请输入' }]}>
|
||||
<Input placeholder="如 material, salary 等" />
|
||||
</Form.Item>
|
||||
<Form.Item name="label" label="显示名称" rules={[{ required: true, message: '请输入' }]}>
|
||||
<Input placeholder="如 材料采购" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenseCategories;
|
||||
@@ -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: <DollarOutlined />,
|
||||
trend: '',
|
||||
color: '#3f8600',
|
||||
},
|
||||
{
|
||||
title: '本月总支出',
|
||||
value: 0,
|
||||
prefix: '¥',
|
||||
icon: <FileTextOutlined />,
|
||||
trend: '',
|
||||
color: '#cf1322',
|
||||
},
|
||||
{
|
||||
title: '待审批报销',
|
||||
value: 0,
|
||||
prefix: '¥',
|
||||
icon: <CheckCircleOutlined />,
|
||||
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) => (
|
||||
<span style={{ color: type === '收入' ? 'green' : 'red' }}>
|
||||
{type === '收入' ? '↑ 收入' : '↓ 支出'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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<string, string> = {
|
||||
'已入账': 'green',
|
||||
'已付款': 'blue',
|
||||
'处理中': 'orange',
|
||||
};
|
||||
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 移动端简化表格列
|
||||
const mobileColumns = [
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 60,
|
||||
render: (type: string) => (
|
||||
<span style={{ color: type === '收入' ? 'green' : 'red', fontSize: 12 }}>
|
||||
{type === '收入' ? '↑' : '↓'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount: number) => (
|
||||
<div style={{ fontWeight: 'bold' }}>¥{amount.toLocaleString()}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === '已入账' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
|
||||
{status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>财务管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
查看公司财务状况、收支明细
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 - 移动端优化 */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
|
||||
{stats.map((stat, index) => (
|
||||
<Col xs={24} sm={8} key={index}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: 12 }}>{stat.title}</span>}
|
||||
value={stat.value}
|
||||
prefix={stat.prefix}
|
||||
suffix={stat.trend}
|
||||
valueStyle={{
|
||||
color: stat.color,
|
||||
fontSize: isMobile ? 18 : undefined
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 财务明细表 */}
|
||||
<Card
|
||||
title="财务明细"
|
||||
size="small"
|
||||
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
>
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
size: isMobile ? 'small' : 'default'
|
||||
}}
|
||||
scroll={isMobile ? { x: 500 } : undefined}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancePage;
|
||||
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<string, string> = { income: '收入', project: '项目支出', company: '公司支出' };
|
||||
const LEVEL2_LABELS: Record<string, string> = {
|
||||
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<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
|
||||
const [byCategory, setByCategory] = useState<any[]>([]);
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
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<string | undefined>(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) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag>
|
||||
},
|
||||
{
|
||||
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<string, string> = { manual: '手动导入', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' };
|
||||
return <Tag>{m[v] || v}</Tag>;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const mobileColumns = [
|
||||
{ title: '日期', dataIndex: 'record_date', width: 80 },
|
||||
{ title: '收支', dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> },
|
||||
{ title: '分类', dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
|
||||
{ title: '金额', dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>财务管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>查看公司财务状况、收支明细</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>总收入</span>} value={summary.total_income} prefix="¥"
|
||||
valueStyle={{ color: '#3f8600', fontSize: isMobile ? 18 : undefined }}
|
||||
icon={<RiseOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>总支出</span>} value={summary.total_expense} prefix="¥"
|
||||
valueStyle={{ color: '#cf1322', fontSize: isMobile ? 18 : undefined }}
|
||||
icon={<FallOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title={<span style={{ fontSize: 12 }}>净利润</span>} value={summary.net_profit} prefix="¥"
|
||||
valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 18 : undefined }}
|
||||
icon={<DollarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{byCategory.length > 0 && (
|
||||
<Card title="支出分类汇总" size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[8, 8]}>
|
||||
{byCategory.filter(c => c.category_level1 !== 'income').map((c, i) => (
|
||||
<Col xs={12} sm={8} md={6} key={i}>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>{LEVEL2_LABELS[c.category_level2] || c.category_level2}</div>
|
||||
<div style={{ fontWeight: 'bold' }}>¥{parseFloat(c.total_amount).toLocaleString()}</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="财务明细" size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
extra={
|
||||
<Space size="small" wrap>
|
||||
<RangePicker size="small" onChange={(dates) => setDateRange(dates as any)} />
|
||||
<Select size="small" allowClear placeholder="筛选类型" style={{ width: 100 }}
|
||||
onChange={setFilterType} value={filterType}
|
||||
options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
dataSource={records}
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
rowKey="id"
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
scroll={isMobile ? { x: 400 } : { x: 1100 }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
total: pagination.total,
|
||||
onChange: (page) => fetchRecords(page),
|
||||
size: isMobile ? 'small' : 'default',
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancePage;
|
||||
|
||||
@@ -868,31 +868,94 @@ const ProjectDetail: React.FC = () => {
|
||||
</Card>
|
||||
)
|
||||
|
||||
// 财务信息 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<any>(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<string, string> = {
|
||||
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
|
||||
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
|
||||
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
|
||||
contract_payment: '合同收款', deposit_refund: '质保金退回'
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="财务信息">
|
||||
<Descriptions column={2} bordered>
|
||||
<Descriptions.Item label="合同金额">
|
||||
¥{contractAmount.toLocaleString()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="已收款">¥{totalIncome.toLocaleString()}</Descriptions.Item>
|
||||
<Descriptions.Item label="预支总额">¥0</Descriptions.Item>
|
||||
<Descriptions.Item label="报销总额">¥0</Descriptions.Item>
|
||||
<Descriptions.Item label="分包付款">¥0</Descriptions.Item>
|
||||
<Descriptions.Item label="支出合计">¥{totalExpense.toLocaleString()}</Descriptions.Item>
|
||||
<Descriptions.Item label="毛利润" span={2}>
|
||||
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}>
|
||||
¥{grossProfit.toLocaleString()}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Spin spinning={profitLoading}>
|
||||
<Card title="财务信息">
|
||||
<Descriptions column={2} bordered>
|
||||
<Descriptions.Item label="合同金额">¥{contractAmount.toLocaleString()}</Descriptions.Item>
|
||||
<Descriptions.Item label="已收款">
|
||||
<span style={{ color: '#3f8600', fontWeight: 'bold' }}>¥{totalIncome.toLocaleString()}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支出合计">
|
||||
<span style={{ color: '#cf1322' }}>¥{totalExpense.toLocaleString()}</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="毛利润">
|
||||
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold', fontSize: 16 }}>
|
||||
¥{grossProfit.toLocaleString()}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="毛利率" span={2}>
|
||||
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}>
|
||||
{grossMargin}%
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{profitData?.expense_by_category?.length > 0 && (
|
||||
<Card title="支出分类明细" size="small" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
dataSource={profitData.expense_by_category}
|
||||
rowKey="category_level2"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ 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 pct = totalExpense > 0 ? (parseFloat(r.total_amount) / totalExpense * 100).toFixed(1) : '0';
|
||||
return `${pct}%`;
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{profitData?.expense_by_user?.length > 0 && (
|
||||
<Card title="人员支出明细" size="small" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
dataSource={profitData.expense_by_user}
|
||||
rowKey="user_name"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '人员', dataIndex: 'user_name' },
|
||||
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
|
||||
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Spin>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<span style={{ color: amount > 0 ? 'green' : 'red' }}>
|
||||
¥{amount.toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '项目数量',
|
||||
dataIndex: 'projects',
|
||||
key: 'projects',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: () => (
|
||||
<Button size="small" icon={<DownloadOutlined />}>导出</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 移动端简化表格列
|
||||
const mobileColumns = [
|
||||
{
|
||||
title: '月份',
|
||||
dataIndex: 'month',
|
||||
key: 'month',
|
||||
render: (month: string) => month.replace('-', '/'),
|
||||
},
|
||||
{
|
||||
title: '收入',
|
||||
dataIndex: 'income',
|
||||
key: 'income',
|
||||
render: (amount: number) => (
|
||||
<div style={{ color: 'green' }}>¥{(amount / 10000).toFixed(0)}万</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '支出',
|
||||
dataIndex: 'expense',
|
||||
key: 'expense',
|
||||
render: (amount: number) => (
|
||||
<div style={{ color: 'red' }}>¥{(amount / 10000).toFixed(0)}万</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '利润',
|
||||
dataIndex: 'profit',
|
||||
key: 'profit',
|
||||
render: (amount: number) => (
|
||||
<div style={{ fontWeight: 'bold', color: amount > 0 ? 'green' : 'red' }}>
|
||||
¥{(amount / 10000).toFixed(0)}万
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>统计报表</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
查看项目财务报表和统计分析数据
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="月度财务报表"
|
||||
size="small"
|
||||
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||||
extra={
|
||||
isMobile ? (
|
||||
<Button size="small" icon={<DownloadOutlined />} />
|
||||
) : (
|
||||
<DatePicker picker="month" style={{ marginRight: 8 }} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={dataSource}
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
pagination={false}
|
||||
scroll={isMobile ? { x: 350 } : undefined}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
summary={(pageData) => {
|
||||
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 (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<strong>合计</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>
|
||||
<strong>¥{(totalIncome / 10000).toFixed(0)}万</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2}>
|
||||
<strong>¥{(totalExpense / 10000).toFixed(0)}万</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}>
|
||||
<strong style={{ color: totalProfit > 0 ? 'green' : 'red' }}>
|
||||
¥{(totalProfit / 10000).toFixed(0)}万
|
||||
</strong>
|
||||
</Table.Summary.Cell>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Table.Summary.Cell index={4}>
|
||||
<strong>{totalProjects}</strong>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} />
|
||||
</>
|
||||
)}
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
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<string, string> = {
|
||||
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<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
|
||||
const [byMonth, setByMonth] = useState<any[]>([]);
|
||||
const [byCategory, setByCategory] = useState<any[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState<dayjs.Dayjs | null>(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) => <span style={{ color: v >= 0 ? 'green' : 'red', fontWeight: 'bold' }}>¥{v.toLocaleString()}</span>
|
||||
},
|
||||
];
|
||||
|
||||
const mobileColumns = [
|
||||
{ title: '月份', dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') },
|
||||
{ title: '收入', dataIndex: 'income', key: 'income', render: (v: number) => <span style={{ color: 'green' }}>¥{(parseFloat(v) / 10000).toFixed(1)}万</span> },
|
||||
{ title: '支出', dataIndex: 'expense', key: 'expense', render: (v: number) => <span style={{ color: 'red' }}>¥{(parseFloat(v) / 10000).toFixed(1)}万</span> },
|
||||
{
|
||||
title: '利润', dataIndex: 'profit', key: 'profit',
|
||||
render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}万</b>
|
||||
},
|
||||
];
|
||||
|
||||
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 (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>统计报表</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>查看项目财务报表和统计分析数据</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title="总收入" value={summary.total_income} prefix="¥" valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title="总支出" value={summary.total_expense} prefix="¥" valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={8}>
|
||||
<Card size="small" style={{ textAlign: 'center' }}>
|
||||
<Statistic title="净利润" value={summary.net_profit} prefix="¥" valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="月度财务报表" size="small" style={{ marginBottom: 16 }}
|
||||
extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder="选择月份" />}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
dataSource={monthData}
|
||||
columns={isMobile ? mobileColumns : desktopColumns}
|
||||
rowKey="month"
|
||||
pagination={false}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
summary={(pageData) => {
|
||||
let ti = 0, te = 0;
|
||||
pageData.forEach(({ income, expense }) => { ti += parseFloat(income); te += parseFloat(expense); });
|
||||
return (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}><strong>合计</strong></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}><strong>¥{ti.toLocaleString()}</strong></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2}><strong>¥{te.toLocaleString()}</strong></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3}>
|
||||
<strong style={{ color: ti - te >= 0 ? 'green' : 'red' }}>¥{(ti - te).toLocaleString()}</strong>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{incomeCategories.length > 0 && (
|
||||
<Col xs={24} md={8}>
|
||||
<Card title={<span><Tag color="green">收入</Tag>收入分类</span>} size="small">
|
||||
<Table dataSource={incomeCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
{projectCategories.length > 0 && (
|
||||
<Col xs={24} md={8}>
|
||||
<Card title={<span><Tag color="blue">项目</Tag>项目支出分类</span>} size="small">
|
||||
<Table dataSource={projectCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
{companyCategories.length > 0 && (
|
||||
<Col xs={24} md={8}>
|
||||
<Card title={<span><Tag color="orange">公司</Tag>公司支出分类</span>} size="small">
|
||||
<Table dataSource={companyCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
|
||||
Reference in New Issue
Block a user