866 lines
31 KiB
JavaScript
866 lines
31 KiB
JavaScript
const express = require('express');
|
|
const db = require('../db');
|
|
const { authenticate, requireAdmin } = require('../middleware/auth');
|
|
|
|
const router = express.Router();
|
|
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const result = await db.query(`
|
|
SELECT
|
|
p.id,
|
|
p.name,
|
|
p.customer_id,
|
|
p.project_manager_id,
|
|
p.contract_amount,
|
|
p.start_date,
|
|
p.end_date,
|
|
p.description,
|
|
p.status,
|
|
p.location,
|
|
c.name as customer_name,
|
|
u.name as manager_name,
|
|
COALESCE(expense_summary.total_expense, 0) as total_expense
|
|
FROM projects p
|
|
LEFT JOIN customers c ON p.customer_id = c.id
|
|
LEFT JOIN users u ON p.project_manager_id = u.id
|
|
LEFT JOIN (
|
|
SELECT project_id, SUM(amount_cny::numeric) as total_expense
|
|
FROM financial_records
|
|
WHERE txn_type = 'expense' AND status = 'confirmed'
|
|
GROUP BY project_id
|
|
) expense_summary ON p.id = expense_summary.project_id
|
|
ORDER BY p.created_at DESC
|
|
LIMIT 50
|
|
`);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows,
|
|
count: result.rows.length
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// 获取项目基本信息
|
|
const projectResult = await db.query(`
|
|
SELECT
|
|
p.*,
|
|
c.name as customer_name,
|
|
u.name as manager_name
|
|
FROM projects p
|
|
LEFT JOIN customers c ON p.customer_id = c.id
|
|
LEFT JOIN users u ON p.project_manager_id = u.id
|
|
WHERE p.id = $1
|
|
`, [id]);
|
|
|
|
if (projectResult.rows.length > 0) {
|
|
const project = projectResult.rows[0];
|
|
|
|
// 获取项目合同信息
|
|
const contractResult = await db.query(`
|
|
SELECT * FROM project_contracts
|
|
WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`, [id]);
|
|
|
|
const contract = contractResult.rows[0];
|
|
|
|
// 从合同表读取质保金数据,如果没有则使用默认值
|
|
const warrantyPercent = contract?.warranty_deposit_percentage || 5;
|
|
const warrantyMonths = contract?.warranty_period || 12;
|
|
const contractAmount = parseFloat(project.contract_amount || 0);
|
|
|
|
// 计算质保金金额:合同金额 * 质保比例 / 100
|
|
const warrantyAmount = Math.round(contractAmount * warrantyPercent / 100);
|
|
|
|
// 计算质保期结束日期
|
|
const warrantyStartDate = project.end_date;
|
|
const warrantyEndDate = warrantyStartDate
|
|
? new Date(new Date(warrantyStartDate).getTime() + warrantyMonths * 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
: null;
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
id: project.id,
|
|
project_code: project.code || `PROJ-${String(project.id).padStart(4, '0')}`,
|
|
name: project.name,
|
|
customer_id: project.customer_id,
|
|
customer_name: project.customer_name || '未知客户',
|
|
status: project.status || 'planning',
|
|
budget: '0',
|
|
spent: '0',
|
|
start_date: project.start_date,
|
|
end_date: project.end_date,
|
|
description: project.description,
|
|
contract_type: 'lump_sum',
|
|
contract_amount: project.contract_amount?.toString() || '0',
|
|
currency: 'CNY',
|
|
contract_days: contract?.contract_period || 180,
|
|
project_manager_id: project.project_manager_id,
|
|
manager_id: project.project_manager_id,
|
|
manager_name: project.manager_name || '未知经理',
|
|
location: project.location || '',
|
|
work_quantity: '',
|
|
project_situation: project.description || '',
|
|
settlement_type: contract?.settlement_method || 'lump_sum',
|
|
has_warranty: true,
|
|
warranty_amount: warrantyAmount.toString(),
|
|
warranty_percent: warrantyPercent.toString(),
|
|
warranty_months: warrantyMonths,
|
|
warranty_start_date: warrantyStartDate,
|
|
warranty_end_date: warrantyEndDate,
|
|
warranty_status: 'pending'
|
|
}
|
|
});
|
|
} else {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: '项目不存在'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('获取项目详情失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目详情失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id/contracts', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.query(`
|
|
SELECT * FROM project_contracts
|
|
WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
`, [id]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目合同失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目合同失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id/subcontracts', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.query(`
|
|
SELECT * FROM subcontracts
|
|
WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
`, [id]);
|
|
|
|
// 解析unit_price_items字段
|
|
const subcontracts = result.rows.map(subcontract => {
|
|
if (subcontract.unit_price_items) {
|
|
try {
|
|
subcontract.unit_price_items = JSON.parse(subcontract.unit_price_items);
|
|
} catch (error) {
|
|
subcontract.unit_price_items = [];
|
|
}
|
|
} else {
|
|
subcontract.unit_price_items = [];
|
|
}
|
|
return subcontract;
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: subcontracts
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目分包失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目分包失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.post('/:id/subcontracts', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { subcontractor_id, subcontractor_name, contract_amount, currency, settlement_type, other_terms, payment_description, unit_price_items, start_date, end_date, work_days, status } = req.body;
|
|
|
|
const unitPriceItemsJson = unit_price_items ? JSON.stringify(unit_price_items) : null;
|
|
|
|
const result = await db.query(
|
|
`INSERT INTO subcontracts (project_id, subcontractor_id, subcontractor_name, contract_amount, currency, settlement_type, other_terms, payment_description, unit_price_items, start_date, end_date, work_days, paid_amount, status, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
RETURNING id`,
|
|
[id, subcontractor_id, subcontractor_name, contract_amount, currency || 'CNY', settlement_type || 'lump_sum', other_terms, payment_description, unitPriceItemsJson, start_date, end_date, work_days, 0, status || 'active']
|
|
);
|
|
|
|
const subcontractId = (result.rows[0]?.id || result.rows?.[0]?.id);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: '新增分包成功',
|
|
data: {
|
|
id: subcontractId,
|
|
project_id: id,
|
|
subcontractor_id,
|
|
subcontractor_name,
|
|
contract_amount,
|
|
currency: currency || 'CNY',
|
|
settlement_type: settlement_type || 'lump_sum',
|
|
other_terms,
|
|
payment_description,
|
|
unit_price_items,
|
|
start_date,
|
|
end_date,
|
|
work_days,
|
|
paid_amount: 0,
|
|
status: status || 'active',
|
|
created_at: new Date().toISOString()
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('新增项目分包失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '新增项目分包失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id/materials', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.query(`
|
|
SELECT * FROM project_materials
|
|
WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
`, [id]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目材料失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目材料失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id/milestones', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.query(`
|
|
SELECT * FROM project_milestones
|
|
WHERE project_id = $1
|
|
ORDER BY expected_date ASC
|
|
`, [id]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目施工节点失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目施工节点失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id/finances', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.query(`
|
|
SELECT * FROM project_finances
|
|
WHERE project_id = $1
|
|
ORDER BY payment_date DESC
|
|
`, [id]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目财务失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目财务失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id/warranty-deposits', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const result = await db.query(`
|
|
SELECT * FROM warranty_deposits
|
|
WHERE project_id = $1
|
|
ORDER BY created_at DESC
|
|
`, [id]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.rows
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目质保金失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目质保金失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.get('/:id/construction-logs', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// 由于施工日志表可能不存在,返回空数组
|
|
res.json({
|
|
success: true,
|
|
data: []
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目施工日志失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目施工日志失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.post('/', async (req, res) => {
|
|
try {
|
|
const { name, code, customer_id, manager_id, contract_amount, start_date, end_date, description, status, location, type_template_id } = req.body;
|
|
|
|
const projectCode = code || 'PROJ' + new Date().toISOString().slice(0, 10).replace(/-/g, '') +
|
|
String(Math.floor(Math.random() * 10000)).padStart(4, '0');
|
|
|
|
const result = await db.query(
|
|
`INSERT INTO projects (name, project_code, customer_id, project_manager_id, contract_amount, start_date, end_date, description, status, location, type_template_id, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
RETURNING id`,
|
|
[name, projectCode, customer_id || null, manager_id || null, contract_amount || 0, start_date || '', end_date || '', description || '', status || 'planning', location || '', type_template_id || null]
|
|
);
|
|
|
|
const projectId = result.rows[0]?.id || result.rows?.[0]?.id;
|
|
|
|
if (type_template_id && projectId) {
|
|
const tplResult = await db.query('SELECT phases FROM project_type_templates WHERE id = $1', [type_template_id]);
|
|
if (tplResult.rows.length > 0) {
|
|
const phases = tplResult.rows[0].phases;
|
|
if (Array.isArray(phases)) {
|
|
for (const phase of phases) {
|
|
const subItems = (phase.sub_items || []).map(item => ({ name: item, completed: false }));
|
|
await db.query(
|
|
`INSERT INTO project_phases (project_id, template_key, phase_name, phase_order, phase_type, depends_on, sub_items, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[projectId, phase.key || `phase_${phase.order}`, phase.name, phase.order, phase.type || 'serial', phase.depends || [], JSON.stringify(subItems), phase.order === 1 ? 'in_progress' : 'pending']
|
|
);
|
|
}
|
|
const firstPhase = phases.find(p => p.order === 1);
|
|
await db.query('UPDATE projects SET current_phase = $1 WHERE id = $2', [firstPhase ? firstPhase.name : '', projectId]);
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: '项目创建成功',
|
|
data: { id: projectId, code: projectCode }
|
|
});
|
|
} catch (error) {
|
|
console.error('创建项目失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '创建项目失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
router.delete('/:id', authenticate, requireAdmin, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
await db.query('DELETE FROM projects WHERE id = $1', [id]);
|
|
res.json({ success: true, message: '项目已删除' });
|
|
} catch (error) {
|
|
console.error('删除项目失败:', error);
|
|
res.status(500).json({ success: false, message: '操作失败' });
|
|
}
|
|
});
|
|
|
|
router.put('/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { name, manager_id, location, start_date, end_date, description, status, contract_amount } = req.body;
|
|
|
|
const updates = [];
|
|
const values = [];
|
|
let paramIndex = 1;
|
|
|
|
if (name !== undefined && name !== null) {
|
|
updates.push(`name = $${paramIndex++}`);
|
|
values.push(name);
|
|
}
|
|
if (manager_id !== undefined && manager_id !== null) {
|
|
updates.push(`project_manager_id = $${paramIndex++}`);
|
|
values.push(manager_id);
|
|
}
|
|
if (location !== undefined && location !== null) {
|
|
updates.push(`location = $${paramIndex++}`);
|
|
values.push(location);
|
|
}
|
|
if (start_date !== undefined && start_date !== null) {
|
|
updates.push(`start_date = $${paramIndex++}`);
|
|
values.push(start_date);
|
|
}
|
|
if (end_date !== undefined && end_date !== null) {
|
|
updates.push(`end_date = $${paramIndex++}`);
|
|
values.push(end_date);
|
|
}
|
|
if (description !== undefined && description !== null) {
|
|
updates.push(`description = $${paramIndex++}`);
|
|
values.push(description);
|
|
}
|
|
if (status !== undefined && status !== null) {
|
|
updates.push(`status = $${paramIndex++}`);
|
|
values.push(status);
|
|
}
|
|
if (contract_amount !== undefined && contract_amount !== null) {
|
|
updates.push(`contract_amount = $${paramIndex++}`);
|
|
values.push(contract_amount);
|
|
}
|
|
|
|
updates.push(`updated_at = CURRENT_TIMESTAMP`);
|
|
|
|
if (updates.length > 1) {
|
|
values.push(id);
|
|
await db.query(
|
|
`UPDATE projects SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
|
values
|
|
);
|
|
}
|
|
|
|
// 合同金额变更时,自动按比例更新付款节点金额
|
|
if (contract_amount !== undefined && contract_amount !== null) {
|
|
await db.query(
|
|
`UPDATE project_milestones SET amount = ROUND($1 * percentage / 100, 2) WHERE project_id = $2`,
|
|
[contract_amount, id]
|
|
);
|
|
}
|
|
|
|
if (start_date && end_date) {
|
|
const start = new Date(start_date);
|
|
const end = new Date(end_date);
|
|
const contractPeriod = Math.floor((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
|
|
|
await db.query(
|
|
'UPDATE project_contracts SET start_date = $1, end_date = $2, contract_period = $3 WHERE project_id = $4',
|
|
[start_date, end_date, contractPeriod, id]
|
|
);
|
|
}
|
|
|
|
const updatedResult = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
|
|
res.json({ success: true, data: updatedResult.rows[0] });
|
|
} catch (error) {
|
|
console.error('更新项目失败:', error);
|
|
res.status(500).json({ success: false, message: '操作失败' });
|
|
}
|
|
});
|
|
|
|
router.put('/:id/contract', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const {
|
|
project_overview,
|
|
settlement_type,
|
|
contract_total,
|
|
tax_included,
|
|
unit_price_items,
|
|
payment_nodes,
|
|
other_info,
|
|
contract_file
|
|
} = req.body;
|
|
|
|
|
|
// 1. 更新项目基本信息
|
|
await db.query(
|
|
`UPDATE projects
|
|
SET description = $1, contract_amount = $2
|
|
WHERE id = $3`,
|
|
[project_overview, contract_total, id]
|
|
);
|
|
|
|
// 2. 更新或创建项目合同
|
|
const contractResult = await db.query(
|
|
`SELECT * FROM project_contracts WHERE project_id = $1`,
|
|
[id]
|
|
);
|
|
|
|
if (contractResult.rows.length > 0) {
|
|
// 更新现有合同
|
|
await db.query(
|
|
`UPDATE project_contracts
|
|
SET settlement_method = $1, contract_amount = $2, contract_file = $3, other_info = $4, tax_included = $5
|
|
WHERE project_id = $6`,
|
|
[settlement_type, contract_total, contract_file, other_info, tax_included, id]
|
|
);
|
|
} else {
|
|
// 创建新合同
|
|
const contractCode = `CONTRACT-${Date.now()}`;
|
|
await db.query(
|
|
`INSERT INTO project_contracts (project_id, contract_code, contract_amount, settlement_method, contract_file, other_info, tax_included, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
RETURNING id`,
|
|
[id, contractCode, contract_total, settlement_type, contract_file, other_info, tax_included]
|
|
);
|
|
}
|
|
|
|
// 3. 处理付款节点
|
|
if (payment_nodes && Array.isArray(payment_nodes)) {
|
|
// 删除旧的付款节点
|
|
await db.query(`DELETE FROM project_milestones WHERE project_id = $1`, [id]);
|
|
|
|
// 创建新的付款节点
|
|
for (const node of payment_nodes) {
|
|
await db.query(
|
|
`INSERT INTO project_milestones (project_id, milestone_name, condition, percentage, amount, status, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
RETURNING id`,
|
|
[id, node.name, node.condition || '', node.percentage, node.amount, 'pending']
|
|
);
|
|
}
|
|
} else if (contract_total) {
|
|
// 没有传付款节点但合同金额变了,按比例更新现有里程碑金额
|
|
await db.query(
|
|
`UPDATE project_milestones SET amount = ROUND($1 * percentage / 100, 2) WHERE project_id = $2`,
|
|
[contract_total, id]
|
|
);
|
|
}
|
|
|
|
// 4. 处理单价项
|
|
if (unit_price_items && Array.isArray(unit_price_items) && settlement_type === 'unit_price') {
|
|
// 删除旧的材料项
|
|
await db.query(`DELETE FROM project_materials WHERE project_id = $1`, [id]);
|
|
|
|
// 创建新的材料项
|
|
for (const item of unit_price_items) {
|
|
await db.query(
|
|
`INSERT INTO project_materials (project_id, product_name, unit, budget_quantity, average_price, total_amount, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
RETURNING id`,
|
|
[id, item.name, item.unit, item.quantity, item.price, item.total]
|
|
);
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: '合同细节保存成功'
|
|
});
|
|
} catch (error) {
|
|
console.error('保存合同细节失败:', error);
|
|
res.status(500).json({ success: false, message: '操作失败' });
|
|
}
|
|
});
|
|
|
|
router.get('/:id/financial-details', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { txn_type, category_level1, category_level2, date_from, date_to, page = 1, pageSize = 20 } = 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 fr.project_id = $1 AND fr.status != 'voided'`;
|
|
const params = [id];
|
|
let idx = 2;
|
|
|
|
if (txn_type) { params.push(txn_type); sql += ` AND fr.txn_type = $${idx++}`; }
|
|
if (category_level1) { params.push(category_level1); sql += ` AND fr.category_level1 = $${idx++}`; }
|
|
if (category_level2) { params.push(category_level2); sql += ` AND fr.category_level2 = $${idx++}`; }
|
|
if (date_from) { params.push(date_from); sql += ` AND fr.record_date >= $${idx++}`; }
|
|
if (date_to) { params.push(date_to); sql += ` AND fr.record_date <= $${idx++}`; }
|
|
|
|
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 $${idx++}`;
|
|
params.push(offset);
|
|
sql += ` OFFSET $${idx++}`;
|
|
|
|
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('/:id/cost-summary', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const projectResult = await db.query('SELECT * FROM projects WHERE id = $1', [id]);
|
|
if (projectResult.rows.length === 0) {
|
|
return res.status(404).json({ success: false, message: '项目不存在' });
|
|
}
|
|
const project = projectResult.rows[0];
|
|
|
|
const incomeResult = await db.query(`
|
|
SELECT category_level2, COALESCE(SUM(amount_cny), 0) as total_amount
|
|
FROM financial_records
|
|
WHERE project_id = $1 AND txn_type = 'income' AND status != 'voided'
|
|
GROUP BY category_level2
|
|
`, [id]);
|
|
|
|
const expenseResult = await db.query(`
|
|
SELECT category_level1, category_level2, COALESCE(SUM(amount_cny), 0) as total_amount
|
|
FROM financial_records
|
|
WHERE project_id = $1 AND txn_type = 'expense' AND status != 'voided'
|
|
GROUP BY category_level1, category_level2
|
|
`, [id]);
|
|
|
|
const totalIncomeRow = 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'
|
|
`, [id]);
|
|
|
|
const totalExpenseRow = 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'
|
|
`, [id]);
|
|
|
|
const incomeByCategory = {};
|
|
let totalIncome = parseFloat(totalIncomeRow.rows[0].total);
|
|
incomeResult.rows.forEach(row => {
|
|
incomeByCategory[row.category_level2] = parseFloat(row.total_amount);
|
|
});
|
|
|
|
const expenseByCategory = {};
|
|
const expenseByLevel1 = {};
|
|
let totalExpense = parseFloat(totalExpenseRow.rows[0].total);
|
|
expenseResult.rows.forEach(row => {
|
|
expenseByCategory[row.category_level2] = parseFloat(row.total_amount);
|
|
if (!expenseByLevel1[row.category_level1]) expenseByLevel1[row.category_level1] = 0;
|
|
expenseByLevel1[row.category_level1] += parseFloat(row.total_amount);
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
project_name: project.name,
|
|
contract_amount: project.contract_amount || 0,
|
|
income: {
|
|
total: totalIncome,
|
|
by_category: incomeByCategory
|
|
},
|
|
expense: {
|
|
total: totalExpense,
|
|
by_category: expenseByCategory,
|
|
by_level1: expenseByLevel1
|
|
},
|
|
total_cost: totalExpense,
|
|
profit: totalIncome - totalExpense
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('获取项目成本统计失败:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: '获取项目成本统计失败',
|
|
error: process.env.NODE_ENV === 'development' ? error.message : '操作失败'
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
module.exports = router;
|
|
router.get('/:id/phases', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const result = await db.query(
|
|
'SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order',
|
|
[id]
|
|
);
|
|
res.json({ success: true, data: result.rows });
|
|
} catch (error) {
|
|
console.error('获取项目阶段失败:', error);
|
|
res.status(500).json({ success: false, message: '获取项目阶段失败' });
|
|
}
|
|
});
|
|
|
|
router.post('/:id/phases/init', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { template_id } = req.body;
|
|
const templateResult = await db.query('SELECT phases FROM project_type_templates WHERE id = $1', [template_id]);
|
|
if (templateResult.rows.length === 0) {
|
|
return res.status(404).json({ success: false, message: '模板不存在' });
|
|
}
|
|
const phases = templateResult.rows[0].phases;
|
|
if (!Array.isArray(phases)) {
|
|
return res.status(400).json({ success: false, message: '模板阶段数据格式错误' });
|
|
}
|
|
const existing = await db.query('SELECT id FROM project_phases WHERE project_id = $1', [id]);
|
|
if (existing.rows.length > 0) {
|
|
return res.status(400).json({ success: false, message: '该项目已有阶段数据' });
|
|
}
|
|
for (const phase of phases) {
|
|
const subItems = (phase.sub_items || []).map(item => ({ name: item, completed: false }));
|
|
await db.query(
|
|
`INSERT INTO project_phases (project_id, template_key, phase_name, phase_order, phase_type, depends_on, sub_items, status)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
[id, phase.key || `phase_${phase.order}`, phase.name, phase.order, phase.type || 'serial', phase.depends || [], JSON.stringify(subItems), phase.order === 1 ? 'in_progress' : 'pending']
|
|
);
|
|
}
|
|
const firstPhase = phases.find(p => p.order === 1);
|
|
await db.query(
|
|
'UPDATE projects SET type_template_id = $1, current_phase = $2, phase_progress = 0, updated_at = CURRENT_TIMESTAMP WHERE id = $3',
|
|
[template_id, firstPhase ? firstPhase.name : '', id]
|
|
);
|
|
res.json({ success: true, message: '阶段初始化成功' });
|
|
} catch (error) {
|
|
console.error('初始化项目阶段失败:', error);
|
|
res.status(500).json({ success: false, message: '初始化项目阶段失败' });
|
|
}
|
|
});
|
|
|
|
router.put('/:id/phases/:phaseId/complete', async (req, res) => {
|
|
try {
|
|
const { id, phaseId } = req.params;
|
|
const { remark, photos, completed_by, attachments } = req.body;
|
|
const phaseResult = await db.query('SELECT * FROM project_phases WHERE id = $1 AND project_id = $2', [phaseId, id]);
|
|
if (phaseResult.rows.length === 0) {
|
|
return res.status(404).json({ success: false, message: '阶段不存在' });
|
|
}
|
|
const phase = phaseResult.rows[0];
|
|
const allAttachments = attachments || photos || phase.photos || [];
|
|
await db.query(
|
|
`UPDATE project_phases SET status = 'completed', completed_at = NOW(), completed_by = $1, remark = $2, photos = $3 WHERE id = $4`,
|
|
[completed_by || null, remark || phase.remark, JSON.stringify(allAttachments), phaseId]
|
|
);
|
|
const allPhases = await db.query('SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order', [id]);
|
|
const completedCount = allPhases.rows.filter(p => p.status === 'completed').length;
|
|
const totalCount = allPhases.rows.length;
|
|
const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
|
let nextPhase = null;
|
|
for (const p of allPhases.rows) {
|
|
if (p.status === 'pending') {
|
|
const deps = p.depends_on || [];
|
|
const depsMet = deps.length === 0 || deps.every(depId => {
|
|
const depPhase = allPhases.rows.find(ap => ap.id === depId || ap.phase_order === depId);
|
|
return depPhase && depPhase.status === 'completed';
|
|
});
|
|
if (depsMet) {
|
|
nextPhase = p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (nextPhase) {
|
|
await db.query("UPDATE project_phases SET status = 'in_progress', started_at = NOW() WHERE id = $1", [nextPhase.id]);
|
|
const newStatus = progress === 100 ? 'completed' : 'active';
|
|
await db.query(
|
|
'UPDATE projects SET current_phase = $1, phase_progress = $2, status = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4',
|
|
[nextPhase.phase_name, progress, newStatus, id]
|
|
);
|
|
} else {
|
|
const newStatus = progress === 100 ? 'completed' : 'active';
|
|
await db.query(
|
|
'UPDATE projects SET current_phase = $1, phase_progress = $2, status = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4',
|
|
['', progress, newStatus, id]
|
|
);
|
|
}
|
|
res.json({ success: true, message: '阶段完成', data: { progress, nextPhase: nextPhase ? nextPhase.phase_name : null } });
|
|
} catch (error) {
|
|
console.error('完成阶段失败:', error);
|
|
res.status(500).json({ success: false, message: '完成阶段失败' });
|
|
}
|
|
});
|
|
|
|
router.put('/:id/phases/:phaseId/reopen', async (req, res) => {
|
|
try {
|
|
const { id, phaseId } = req.params;
|
|
await db.query(
|
|
"UPDATE project_phases SET status = 'in_progress', completed_at = NULL, completed_by = NULL WHERE id = $1 AND project_id = $2",
|
|
[phaseId, id]
|
|
);
|
|
const allPhases = await db.query('SELECT * FROM project_phases WHERE project_id = $1 ORDER BY phase_order', [id]);
|
|
const completedCount = allPhases.rows.filter(p => p.status === 'completed').length;
|
|
const totalCount = allPhases.rows.length;
|
|
const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
|
const reopenedPhase = allPhases.rows.find(p => p.id === parseInt(phaseId));
|
|
await db.query(
|
|
'UPDATE projects SET current_phase = $1, phase_progress = $2, status = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4',
|
|
[reopenedPhase ? reopenedPhase.phase_name : '', progress, 'active', id]
|
|
);
|
|
res.json({ success: true, message: '阶段已重新打开', data: { progress } });
|
|
} catch (error) {
|
|
console.error('重新打开阶段失败:', error);
|
|
res.status(500).json({ success: false, message: '重新打开阶段失败' });
|
|
}
|
|
});
|
|
|
|
router.put('/:id/phases/:phaseId/sub-item', async (req, res) => {
|
|
try {
|
|
const { id, phaseId } = req.params;
|
|
const { sub_item_index, completed } = req.body;
|
|
const phaseResult = await db.query('SELECT sub_items FROM project_phases WHERE id = $1 AND project_id = $2', [phaseId, id]);
|
|
if (phaseResult.rows.length === 0) {
|
|
return res.status(404).json({ success: false, message: '阶段不存在' });
|
|
}
|
|
const subItems = phaseResult.rows[0].sub_items || [];
|
|
if (sub_item_index >= 0 && sub_item_index < subItems.length) {
|
|
subItems[sub_item_index].completed = completed;
|
|
await db.query('UPDATE project_phases SET sub_items = $1 WHERE id = $2', [JSON.stringify(subItems), phaseId]);
|
|
}
|
|
res.json({ success: true, message: '子项状态更新成功' });
|
|
} catch (error) {
|
|
console.error('更新子项状态失败:', error);
|
|
res.status(500).json({ success: false, message: '更新子项状态失败' });
|
|
}
|
|
});
|