Преглед изворни кода

Merge branch 'dev' of http://192.168.1.41:3000/maixinrong/Calculation into dev

MaiXinRong пре 7 часа
родитељ
комит
7edd51e79d

+ 49 - 0
AGENTS.md

@@ -0,0 +1,49 @@
+# 项目工作规则
+
+以下规则适用于本项目的所有开发与修改。
+
+## SQL 文件命名
+
+- 生成或更新 SQL 脚本时,必须统一写入 `update.sql`。
+- 不得创建带日期后缀或日期标记的替代文件,例如 `updateYYYYMMDD.sql`、`update-YYYY-MM-DD.sql`,或任何以年、月、日或日期作为 `XXX` 的 `updateXXX.sql` 文件。
+- 如果 `update.sql` 已存在,直接更新该文件并保留无关的原有内容,不得另行创建其他 SQL 更新文件。
+
+## 方法位置
+
+- 新增方法必须写在目标文件的最下方;类或对象中的方法应追加在其最后一个已有方法之后、闭合括号之前。
+- 不得将新增方法插入文件顶部,也不得放在已有方法之前。
+- 修改已有方法时,保留其原有位置。
+
+## Controller 代码风格
+
+- Controller 写法参考项目现有实现(如 `app/controller/tender_controller.js`),保持项目代码风格一致。
+- 页面渲染使用 `const renderData` 组织数据,再传给 `this.layout(...)`。常规数据可以直接在 `renderData` 对象初始化时集中设置;后续补充的数据,或 `if` 等条件分支中仅对部分用户可见的数据,再通过 `renderData.xxx` 赋值。不要求所有字段都逐项赋值。
+
+## Service 与查询代码风格
+
+- Service 写法必须参考系统现有实现,优先复用已有方法,保持项目代码风格一致。
+- 普通数据库操作优先使用系统已有的 Service、基类方法或数据库封装;只有遇到复杂 SQL 查询时才使用手写 SQL。
+- 手写 SQL 时,使用 `const sql` 和 `const sqlParam` 分别声明 SQL 语句与参数。
+- 参数必须通过占位符传递,不得直接拼接参数到 SQL 语句中。
+- 所有权限查询均不使用 `SqlBuilder`。
+
+## 弹窗组织
+
+- 弹窗模板独立放置,不得直接混写在主页面模板中。
+- 首页或列表页的弹窗可以使用对应模块下的 `modal.ejs`。
+- 其他页面的弹窗使用 `<页面名>_modal.ejs`,例如广元大屏使用 `guangyuan_modal.ejs`,避免不同页面统一使用 `modal.ejs` 而混淆归属。
+- 如果需要独立的弹窗 JavaScript 文件,同样按页面命名为 `<页面名>_modal.js`;首页或列表页可以使用 `modal.js`。不必仅为命名而新建独立脚本。
+
+## 测试文件
+
+- 不新增测试文件。
+
+## 页面样式组织
+
+- 页面自定义 CSS 统一写入 `app/public/css/main.css`,不另建页面专用 CSS 文件。
+- 使用页面专属类名限定样式作用范围,避免影响其他页面。
+
+## 前端 JavaScript 代码风格
+
+- 前端 JavaScript 的业务接口请求优先使用系统封装的 `postData`,不另行使用 `fetch` 或重复封装底层 Ajax;页面导航和静态资源加载不在此限制内。
+- `postData` 的成功回调接收的是解包后的 `data`,不要再次按完整响应对象读取;异步包装时必须保留失败回调,使加载失败和重试流程正常工作。

+ 29 - 3
app/controller/project_screen_controller.js

@@ -24,17 +24,43 @@ module.exports = app => {
                         template: projectScreenConst.getTemplate(item.template_code),
                     });
                 });
-                await this.layout('project_screen/index.ejs', {
+                const summaries = template.code === 'guangyuan' ? await ctx.service.projectScreen.getStatusProjects(null, { summary: true }) : [];
+                const finishedProjects = summaries.filter(project => project.status === '已建');
+                const plannedProjects = summaries.filter(project => project.status === '拟建');
+                const buildingProjects = summaries.filter(project => project.status === '在建');
+                const listOnly = projects => projects.map(project => ({ id: project.id, name: project.name, group: project.group }));
+                const renderData = {
                     screens: screenList,
                     screen,
                     template,
-                });
+                };
+                renderData.finishedProjects = listOnly(finishedProjects);
+                renderData.plannedProjects = listOnly(plannedProjects);
+                renderData.buildingProjects = listOnly(buildingProjects);
+                renderData.overview = template.code === 'guangyuan' ? await ctx.service.projectScreen.getOverview(plannedProjects, buildingProjects, finishedProjects) : null;
+                await this.layout('project_screen/index.ejs', renderData, template.code === 'guangyuan' ? 'project_screen/guangyuan_modal.ejs' : '');
             } catch (error) {
-                ctx.log(error);
+                this.log(error);
                 ctx.session.postError = error.toString();
                 ctx.redirect('/dashboard');
             }
         }
+        async detail(ctx) {
+            try {
+                if (!ctx.showProjectScreen) throw '项目大屏未开启或没有访问权限';
+                const screens = await ctx.service.projectScreen.getList(ctx.session.sessionProject.id);
+                if (!screens.some(screen => screen.template_code === 'guangyuan')) throw '项目尚未添加广元大屏';
+                const data = JSON.parse(ctx.request.body.data);
+                const status = { planned: '拟建', building: '在建', finished: '已建' }[data.module];
+                if (!status || typeof data.id !== 'string' || !data.id) throw '项目参数错误';
+                const projects = await ctx.service.projectScreen.getStatusProjects(status, { id: data.id });
+                if (!projects.length) throw '项目不存在或没有访问权限';
+                ctx.body = { err: 0, msg: '', data: projects[0] };
+            } catch (error) {
+                this.log(error);
+                ctx.body = { err: 1, msg: error.toString(), data: null };
+            }
+        }
     }
 
     return ProjectScreenController;

Разлика између датотеке није приказан због своје велике величине
+ 107 - 4
app/public/css/main.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 61
app/public/css/project_screen_guangyuan.css


+ 0 - 78
app/public/css/project_screen_setting.css

@@ -1,78 +0,0 @@
-.project-screen-setting { padding: 20px; background: #f5f7fb; }
-.project-screen-title-row { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
-.project-screen-title-row h5 { margin:0 0 4px; font-weight:600; }
-.project-screen-title-row > div > span { color:#8993a5; font-size:12px; }
-.project-screen-switch-box { display:flex; align-items:center; gap:14px; margin:0; padding:9px 12px; border:1px solid #e1e5ee; border-radius:8px; background:#fff; font-size:13px; }
-.project-screen-switch-box .custom-control { min-height:20px; }
-.project-screen-panel { display:grid; grid-template-columns:220px 1fr; height:calc(100vh - 185px); min-height:540px; overflow:hidden; border:1px solid #e1e5ee; border-radius:10px; background:#fff; }
-.project-screen-module-list { padding:15px 12px; border-right:1px solid #e6e9f0; }
-.project-screen-module-list h6 { margin:0 8px 14px; font-weight:600; }
-.project-screen-module-active { display:flex; align-items:center; gap:10px; padding:13px 12px; border-radius:7px; color:#3870f6; background:#edf3ff; font-size:13px; font-weight:600; }
-.project-screen-workarea { min-width:0; }
-.project-screen-workhead { height:62px; display:flex; align-items:center; gap:24px; padding:0 18px; border-bottom:1px solid #e6e9f0; }
-.project-screen-worktitle { margin-right:auto; }
-.project-screen-worktitle strong, .project-screen-worktitle small { display:block; }
-.project-screen-worktitle small { margin-top:3px; color:#8a94a6; }
-.project-screen-tab { align-self:stretch; padding:0 7px; border:0; border-bottom:2px solid transparent; color:#7d8798; background:transparent; cursor:pointer; }
-.project-screen-tab.active { color:#3870f6; border-bottom-color:#4f7cff; }
-.project-screen-config { display:grid; grid-template-columns:280px 1fr; height:calc(100% - 62px); }
-.project-screen-list-panel { padding:14px; border-right:1px solid #e6e9f0; overflow:auto; }
-.project-screen-list-tools { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; color:#8b95a6; font-size:12px; }
-.project-screen-list-item { display:grid; grid-template-columns:1fr auto; align-items:center; min-height:48px; padding:8px 10px; margin-bottom:5px; border:1px solid transparent; border-radius:7px; cursor:pointer; font-size:13px; }
-.project-screen-list-item:hover { background:#f7f9fc; }
-.project-screen-list-item.active { border-color:#cddaff; background:#edf3ff; }
-.project-screen-default-mark { margin-left:7px; color:#3970f6; font-size:11px; }
-.project-screen-list-delete { opacity:0; border:0; color:#dc5965; background:transparent; cursor:pointer; font-size:12px; }
-.project-screen-list-item:hover .project-screen-list-delete, .project-screen-list-item.active .project-screen-list-delete { opacity:1; }
-.project-screen-preview-panel { padding:16px; min-width:0; }
-.project-screen-preview-head { display:flex; justify-content:space-between; align-items:center; min-height:32px; margin-bottom:10px; }
-.project-screen-preview-wrap { height:calc(100% - 42px); min-height:360px; display:grid; place-items:center; padding:28px; border-radius:8px; background:#e7e9f1; }
-.project-screen-preview { width:min(100%, 650px); aspect-ratio:16/9; display:grid; grid-template-columns:22% 1fr; overflow:hidden; border:8px solid #d6d9e4; border-radius:5px; background:#f7f8fd; }
-.project-screen-preview-nav { padding:12px 10px; background:#4d7881; }
-.project-screen-preview-nav span { display:block; width:28px; height:28px; margin-bottom:22px; border-radius:6px; background:#6f8cff; }
-.project-screen-preview-nav i { display:block; width:78%; height:8px; margin:14px 0; border-radius:20px; background:rgba(231,244,247,.7); }
-.project-screen-preview-content { display:grid; grid-template-rows:32px 1fr; gap:12px; padding:13px; }
-.project-screen-preview-content header, .project-screen-preview-content aside, .project-screen-preview-content main { border-radius:5px; background:#fff; }
-.project-screen-preview-content section { display:grid; grid-template-columns:34% 1fr; gap:12px; }
-.project-screen-preview-content main { position:relative; overflow:hidden; background:#edf1ff; }
-.project-screen-preview-content main:before, .project-screen-preview-content main:after { content:""; position:absolute; border-radius:50%; background:#c9d6ff; }
-.project-screen-preview-content main:before { width:90px; height:90px; right:36px; top:25px; }
-.project-screen-preview-content main:after { width:170px; height:170px; right:62px; bottom:-90px; }
-.project-screen-preview-general3 .project-screen-preview-content main { background:#eef2ff; }
-.project-screen-preview-guangyuan .project-screen-preview-nav { background:#295f70; }
-.project-screen-empty { display:none; color:#8a94a6; }
-.project-screen-template-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:14px; }
-.project-screen-template-item { position:relative; padding:10px; border:1px solid #dfe3eb; border-radius:8px; cursor:pointer; }
-.project-screen-template-item:hover { border-color:#91aaf8; }
-.project-screen-template-item.selected { padding:9px; border:2px solid #4f7cff; background:#f6f8ff; }
-.project-screen-template-item.added { opacity:.5; cursor:not-allowed; }
-.project-screen-template-check { position:absolute; top:17px; right:17px; width:20px; height:20px; display:grid; place-items:center; border:1px solid #c9d0dd; border-radius:4px; color:transparent; background:#fff; z-index:2; }
-.project-screen-template-item.selected .project-screen-template-check { color:#fff; border-color:#4f7cff; background:#4f7cff; }
-.project-screen-template-preview { height:135px; display:grid; place-items:center; border-radius:6px; background:#e8ebf3; }
-.project-screen-template-preview > div { width:82%; aspect-ratio:16/9; display:grid; grid-template-columns:22% 1fr; border:4px solid #d5d9e5; background:#4d7881; }
-.project-screen-template-preview > div:after { content:""; margin:7px; background:#f4f6fc; }
-.project-screen-template-name { display:flex; justify-content:space-between; margin-top:9px; font-size:13px; }
-.project-screen-template-name span { color:#8a94a6; font-size:11px; }
-.project-screen-permission { height:calc(100% - 62px); padding:16px; }
-.project-screen-permission-tools { display:flex; align-items:center; margin-bottom:10px; font-size:12px; }
-.project-screen-permission-tools > span { margin-right:auto; color:#7d8798; }
-.project-screen-search { width:280px; }
-.project-screen-permission-table { height:calc(100% - 82px); overflow:auto; border:1px solid #e0e4ec; }
-.project-screen-permission-table thead th { position:sticky; top:0; z-index:2; text-align:center; background:#f1f3f9; }
-.project-screen-permission-table tbody td { text-align:center; vertical-align:middle; }
-.project-screen-permission-empty { display:none; padding:45px; text-align:center; color:#8a94a6; }
-.project-screen-page { height:42px; display:flex; justify-content:center; align-items:end; gap:12px; font-size:12px; }
-.project-screen-view-page { height:100vh; padding:18px; color:#dce9ff; background:#07152e; }
-.project-screen-view-head { height:55px; display:flex; justify-content:space-between; align-items:flex-start; }
-.project-screen-view-head strong, .project-screen-view-head span { display:block; }
-.project-screen-view-head span { margin-top:3px; color:#83a4d7; font-size:12px; }
-.project-screen-view-tabs { display:flex; gap:8px; }
-.project-screen-view-tabs a { padding:7px 12px; border:1px solid #244b78; border-radius:5px; color:#8fb4e6; }
-.project-screen-view-tabs a.active { color:#fff; border-color:#3c88e8; background:#153c6b; }
-.project-screen-live { height:calc(100% - 55px); padding:16px; border:1px solid #1d4f83; background:linear-gradient(135deg,#081a36,#0c2a4d); }
-.project-screen-live-title { margin-bottom:15px; text-align:center; font-size:22px; letter-spacing:5px; }
-.project-screen-live-grid { height:calc(100% - 48px); display:grid; grid-template-columns:repeat(2,1fr); gap:14px; }
-.project-screen-live-grid section { padding:14px; border:1px solid #1b568f; background:rgba(8,33,65,.76); }
-.project-screen-live-grid h6 { color:#78bafa; }
-.project-screen-live-grid section div { height:calc(100% - 25px); background:linear-gradient(180deg,rgba(30,99,156,.22),rgba(9,42,77,.06)); }
-@media(max-width:900px){.project-screen-panel{grid-template-columns:160px 1fr}.project-screen-config{grid-template-columns:220px 1fr}.project-screen-template-grid{grid-template-columns:1fr}.project-screen-search{width:210px}}

Разлика између датотеке није приказан због своје велике величине
+ 88 - 13
app/public/js/project_screen_guangyuan.js


Разлика између датотеке није приказан због своје велике величине
+ 65 - 28
app/public/js/project_screen_guangyuan_building.js


Разлика између датотеке није приказан због своје велике величине
+ 79 - 22
app/public/js/project_screen_guangyuan_finished.js


Разлика између датотеке није приказан због своје велике величине
+ 85 - 24
app/public/js/project_screen_guangyuan_planned.js


+ 1 - 0
app/router.js

@@ -211,6 +211,7 @@ module.exports = app => {
     // ------------------------- 项目相关 -----------------------------
 
     app.get('/projectscreen', sessionAuth, 'projectScreenController.index');
+    app.post('/projectscreen/data/detail', sessionAuth, 'projectScreenController.detail');
     app.get('/projectscreen/:sid', sessionAuth, 'projectScreenController.index');
 
 

+ 183 - 0
app/service/project_screen.js

@@ -128,6 +128,189 @@ module.exports = app => {
                 throw error;
             }
         }
+
+        async getFinishedProjects() {
+            return this.getStatusProjects('已建');
+        }
+
+        async getStatusProjects(status, options = {}) {
+            const ctx = this.ctx;
+            const user = ctx.session.sessionUser;
+            const pid = ctx.session.sessionProject.id;
+            const nodes = await ctx.service.subProject.getSubProject(pid, user.accountId, user.is_admin);
+            const infos = await ctx.service.subProjInfo.getAllDataByCondition({ where: { project_id: pid, proj_status: status || ['拟建', '在建', '已建'] } });
+            const account = await ctx.service.projectAccount.getDataById(user.accountId);
+            const permission = account ? ctx.service.projectAccount.parseProjectScreenPermission(account.permission) : null;
+            const calc = ctx.helper;
+            // Preserve decimal precision during aggregation and unit conversion.
+            // 元 fields have eight decimal places; conversion to 万元 needs twelve.
+            const toWan = (value, unit) => {
+                if (value === null || value === undefined || value === '') return null;
+                if (unit === '万元') return calc.add(0, value);
+                if (unit === '亿元') return calc.mul(value, 10000, 12);
+                return calc.div(value, 10000, 12);
+            };
+            const result = [];
+            const selectedNodes = nodes.filter(n => !n.is_folder && (!options.id || n.id === options.id) && infos.some(info => info.id === n.id)).sort((a, b) => a.tree_order - b.tree_order);
+            const allTenders = [];
+            if (selectedNodes.length && (user.is_admin === 1 || (permission && permission.tender && permission.tender.indexOf('2') !== -1))) {
+                allTenders.push(...await ctx.service.tender.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) } }));
+                allTenders.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
+            } else {
+                // Batch projects with identical permission-related page settings.
+                const permissionGroups = new Map();
+                for (const node of selectedNodes) {
+                    const sub = Object.assign({}, node, { page_show: ctx.service.subProject.getPageShow(node.page_show) });
+                    const key = JSON.stringify(['openChangeProject', 'openChangeApply', 'openChangePlan'].map(flag => !!sub.page_show[flag]));
+                    if (!permissionGroups.has(key)) permissionGroups.set(key, { id: [], page_show: sub.page_show });
+                    permissionGroups.get(key).id.push(node.id);
+                }
+                for (const sub of permissionGroups.values()) {
+                    allTenders.push(...await ctx.service.tender.getList('', permission, user.is_admin, '', sub));
+                }
+            }
+            await ctx.service.tenderCache.loadTenderCaches(allTenders, user.accountId);
+            const allChanges = !options.summary && status === '在建' && allTenders.length ? await ctx.service.change.getAllCheckedChanges(allTenders.map(t => t.id)) : [];
+            const allProgress = !options.summary && selectedNodes.length && status !== '已建' ? await ctx.service.subProjProgress.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) }, orders: [['update_time', 'desc'], ['id', 'asc']] }) : [];
+            for (const node of selectedNodes) {
+                const info = infos.find(i => i.id === node.id);
+                if (!info) continue;
+                const tenders = allTenders.filter(t => t.spid === node.id);
+                let contract = 0;
+                let measured = 0;
+                let hasMeasured = false;
+                const tenderInvestments = [];
+                const changeRows = [];
+                for (const tender of tenders) {
+                    contract = calc.add(contract, tender.contract_price);
+                    const stage = tender.stage_tp || {};
+                    let tenderMeasured = 0;
+                    let tenderHasMeasured = false;
+                    for (const key of ['pre_contract_tp', 'contract_tp', 'contract_pc_tp', 'pre_qc_tp', 'qc_tp', 'qc_pc_tp']) {
+                        if (stage[key] !== null && stage[key] !== undefined && stage[key] !== '') {
+                            tenderMeasured = calc.add(tenderMeasured, stage[key]);
+                            tenderHasMeasured = true;
+                        }
+                    }
+                    measured = calc.add(measured, tenderMeasured);
+                    hasMeasured = hasMeasured || tenderHasMeasured;
+                    tenderInvestments.push({ id: tender.id, name: tender.name, measured: tenderHasMeasured ? toWan(tenderMeasured, '元') : null });
+                    if (!options.summary && status === '在建') {
+                        const changes = allChanges.filter(change => change.tid === tender.id);
+                        const totals = [1, 2, 3].map(quality => changes.filter(c => Number(c.quality) === quality).reduce((sum, c) => calc.add(sum, c.total_price), 0));
+                        const total = changes.reduce((sum, c) => calc.add(sum, c.total_price), 0);
+                        const ledger = tender.ledger_tp && tender.ledger_tp.total_price;
+                        changeRows.push({ id: tender.id, name: tender.name, contract: toWan(ledger, '元'),
+                            changes: totals.map(value => toWan(value, '元')), total: toWan(total, '元'),
+                            ratio: Number(ledger) ? calc.div(calc.mul(total, 100, 12), ledger, 2) : null });
+                    }
+                }
+                let finalAmount = null;
+                if (!options.summary && status === '已建' && node.budget_id) {
+                    const budget = await ctx.service.budget.getDataById(node.budget_id);
+                    if (budget && budget.final_id) {
+                        const final = await ctx.service.budgetFinal.getAllDataByCondition({ where: { final_id: budget.final_id } });
+                        const top = final.filter(row => Number(row.tree_pid) === -1);
+                        if (top.length) finalAmount = toWan(top.reduce((sum, row) => calc.add(sum, row.final_tp), 0), '元');
+                    }
+                }
+                const parent = nodes.find(n => n.id === node.tree_pid);
+                const progressRows = allProgress.filter(row => row.spid === node.id);
+                const latest = progressRows[0];
+                const keys = ['edit', 'submit', 'reply'];
+                const latestEvent = latest && keys.map((key, index) => ({ date: latest[key + '_date'], department: latest[key + '_department'], step: ['编制', '报审', '批复'][index] }))
+                    .filter(event => event.date).sort((a, b) => String(b.date).localeCompare(String(a.date)))[0];
+                const stages = progressRows.filter(row => row.tree_level > 1 && row.tree_is_leaf === 1).map(row => ({
+                    id: row.id, name: row.name,
+                    path: String(row.tree_full_path).split('-').map(id => progressRows.find(p => String(p.tree_id) === id)).filter(Boolean).map(p => p.name).join(' / ') || row.name,
+                    states: keys.map(key => row[key + '_progress'] === '已完成' ? 0 : row[key + '_progress'] === '进行中' ? 1 : 2),
+                    dates: keys.map(key => row[key + '_date'] || ''), departments: keys.map(key => row[key + '_department'] || '')
+                }));
+                result.push({ id: node.id, name: node.name, status: info.proj_status, group: parent ? parent.name : '未分组',
+                    intro: info.proj_intro || '', date: info.jg_date || '', department: info.jg_department || '',
+                    measured: hasMeasured ? toWan(measured, '元') : null,
+                    tenders: tenderInvestments,
+                    changeRows, stages,
+                    progressText: latestEvent ? `${latestEvent.date},${latestEvent.department || ''}已完成「${latest.name}」${latestEvent.step}。` : '暂无阶段动态',
+                    amounts: ['lx', 'cb', 'sgt', 'pf'].map(prefix => toWan(info[prefix + '_tp'], info[prefix + '_tp_unit']))
+                        .concat([tenders.length ? toWan(contract, '元') : null, hasMeasured ? toWan(measured, '元') : null, finalAmount]) });
+            }
+            return result;
+        }
+
+        async getOverview(planned, building, finished) {
+            const ctx = this.ctx;
+            const calc = ctx.helper;
+            const lists = [planned, building, finished];
+            const projects = [].concat(...lists);
+            const present = value => value !== null && value !== undefined && value !== '';
+            const rank = (list, amount) => list.map(p => ({ id: p.id, name: p.name, value: amount(p) }))
+                .filter(p => present(p.value)).sort((a, b) => b.value - a.value).slice(0, 10);
+            const rows = projects.length ? await ctx.service.subProjProgress.getAllDataByCondition({
+                where: { spid: projects.map(p => p.id) }, orders: [['update_time', 'desc'], ['id', 'asc']]
+            }) : [];
+            const dateText = value => value instanceof Date ? [value.getFullYear(), String(value.getMonth() + 1).padStart(2, '0'), String(value.getDate()).padStart(2, '0')].join('-') : String(value || '').slice(0, 10);
+            const trees = new Map(projects.map(p => [p.id, rows.filter(row => row.spid === p.id)]));
+            const leafColumns = tree => tree.filter(row => Number(row.tree_level) > 1 && Number(row.tree_is_leaf) === 1)
+                .sort((a, b) => a.tree_order - b.tree_order).map(row => {
+                    const path = String(row.tree_full_path).split('-').map(id => tree.find(n => String(n.tree_id) === id)).filter(Boolean);
+                    if (!path.length || path[path.length - 1].id !== row.id) path.push(row);
+                    return { key: JSON.stringify([path[0].name, row.name]), root: path[0].name,
+                        rootOrder: Number(path[0].tree_order) || 0,
+                        orderPath: path.slice(1).map(n => Number(n.tree_order) || 0),
+                        name: row.name, date: dateText(row.reply_date) };
+                }).sort((a, b) => {
+                    if (a.root !== b.root) return a.rootOrder - b.rootOrder || a.root.localeCompare(b.root, 'zh-CN');
+                    for (let i = 0; i < Math.min(a.orderPath.length, b.orderPath.length); i++) {
+                        if (a.orderPath[i] !== b.orderPath[i]) return a.orderPath[i] - b.orderPath[i];
+                    }
+                    return a.orderPath.length - b.orderPath.length;
+                });
+            const columnsByProject = new Map(projects.map(p => [p.id, leafColumns(trees.get(p.id))]));
+            const approvedKeys = new Set([].concat(...Array.from(columnsByProject.values())).filter(column => column.date).map(column => column.key));
+            // Merge same leaf names under the same root, regardless of intermediate paths.
+            const rootGroups = new Map();
+            for (const project of projects) {
+                for (const column of columnsByProject.get(project.id)) {
+                    // Only expose a column when at least one project has an approval date.
+                    if (!approvedKeys.has(column.key)) continue;
+                    if (!rootGroups.has(column.root)) rootGroups.set(column.root, new Map());
+                    const group = rootGroups.get(column.root);
+                    if (!group.has(column.key)) group.set(column.key, column);
+                }
+            }
+            // Sort roots only; keep leaf insertion order so different projects do not interleave.
+            const columns = [].concat(...Array.from(rootGroups.values()).sort((a, b) => {
+                const firstA = a.values().next().value;
+                const firstB = b.values().next().value;
+                return firstA.rootOrder - firstB.rootOrder || firstA.root.localeCompare(firstB.root, 'zh-CN');
+            }).map(group => Array.from(group.values())));
+            return {
+                counts: lists.map(list => list.length),
+                investment: lists.map(list => [0, 1, 2, 3, 4].map(index => {
+                    const values = list.map(p => p.amounts[index]).filter(present);
+                    return values.length ? values.reduce((sum, value) => calc.add(sum, value), 0) : null;
+                })),
+                planned: rank(planned, p => p.amounts[1]), finished: rank(finished, p => p.measured),
+                building: building.map(p => ({ id: p.id, name: p.name, contract: p.amounts[4], measured: p.measured,
+                    rate: present(p.measured) && Number(p.amounts[4]) !== 0 && present(p.amounts[4]) ? calc.div(calc.mul(p.measured, 100, 12), p.amounts[4], 2) : null
+                })).sort((a, b) => a.rate === null ? (b.rate === null ? 0 : 1) : b.rate === null ? -1 : b.rate - a.rate),
+                columns,
+                preview: projects.map(p => ({ id: p.id, name: p.name, group: p.group,
+                    dates: columns.map(column => {
+                        const dates = columnsByProject.get(p.id).filter(c => c.key === column.key && c.date).map(c => c.date);
+                        return dates.sort().pop() || '';
+                    }) })),
+                events: rows.slice(0, 20).map(row => {
+                    const project = projects.find(p => p.id === row.spid);
+                    const latest = ['edit', 'submit', 'reply'].map((key, index) => ({ date: dateText(row[key + '_date']),
+                        department: row[key + '_department'] || '', step: ['编制', '报审', '批复'][index] }))
+                        .filter(event => event.date).sort((a, b) => b.date.localeCompare(a.date))[0];
+                    return { name: project.name, date: latest ? latest.date : dateText(row.update_time),
+                        text: latest ? `${latest.department}已完成「${row.name}」${latest.step}` : `更新「${row.name}」` };
+                })
+            };
+        }
     }
 
     return ProjectScreen;

+ 4 - 4
app/service/tender.js

@@ -103,7 +103,7 @@ module.exports = app => {
                     '  FROM ?? As t ' +
                     '  Left Join ?? As pa ' +
                     '  ON t.`user_id` = pa.`id` ' +
-                    '  WHERE t.`spid` = ? ' + buildStatusFilter + userFilter + ' ORDER BY CONVERT(t.`name` USING GBK) ASC';
+                    '  WHERE t.`spid` IN (?) ' + buildStatusFilter + userFilter + ' ORDER BY CONVERT(t.`name` USING GBK) ASC';
                 sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, subProject.id];
             } else if (getAll === 1 || (permission !== null && permission.tender !== undefined && permission.tender.indexOf('2') !== -1)) {
                 // 具有查看所有标段权限的用户查阅标段
@@ -112,7 +112,7 @@ module.exports = app => {
                     '  FROM ?? As t ' +
                     '  Left Join ?? As pa ' +
                     '  ON t.`user_id` = pa.`id` ' +
-                    '  WHERE t.`spid` = ?' + buildStatusFilter + ' ORDER BY CONVERT(t.`name` USING GBK) ASC';
+                    '  WHERE t.`spid` IN (?)' + buildStatusFilter + ' ORDER BY CONVERT(t.`name` USING GBK) ASC';
                 sqlParam = [this.tableName, this.ctx.service.projectAccount.tableName, subProject.id];
             } else {
                 // 根据用户权限查阅标段
@@ -127,14 +127,14 @@ module.exports = app => {
                 const changeProjectXsSql = subProject.page_show.openChangeProject ? '    OR (t.`ledger_status` = ' + auditConst.ledger.status.checked + ' AND ' +
                     '        t.id IN ( SELECT cpxa.`tid` FROM ' + this.ctx.service.changeProjectXsAudit.tableName + ' AS cpxa WHERE cpxa.`aid` = ' + session.sessionUser.accountId + ' GROUP BY cpxa.`tid`))' : '';
                 // const xxjdSql = subProject.page_show.xxjd ? '    OR (t.id IN ( SELECT xxjd.`tid` FROM ' + this.ctx.service.scheduleAudit.tableName + ' AS xxjd WHERE xxjd.`permission` != 0 AND xxjd.`audit_id` = ' + session.sessionUser.accountId + ' GROUP BY xxjd.`tid`))' : '';
-                sql = 'SELECT t.`id`, t.`project_id`, t.`name`, t.`status`, t.`category`, t.`ledger_times`, t.`ledger_status`, t.`measure_type`, t.`user_id`, t.`create_time`, t.`total_price`, t.`deal_tp`,' +
+                sql = 'SELECT t.`id`, t.`project_id`, t.`name`, t.`status`, t.`category`, t.`ledger_times`, t.`ledger_status`, t.`measure_type`, t.`user_id`, t.`create_time`, t.`total_price`, t.`deal_tp`, t.`spid`,' +
                     '    pa.`name` As `user_name`, pa.`role` As `user_role`, pa.`company` As `user_company` ' +
                     // '  FROM ?? As t, ?? As pa ' +
                     // '  WHERE t.`project_id` = ? AND t.`user_id` = pa.`id` AND (' +
                     '  FROM ?? As t ' +
                     '  Left Join ?? As pa ' +
                     '  ON t.`user_id` = pa.`id` ' +
-                    '  WHERE t.`spid` = ? ' + buildStatusFilter + ' AND (' +
+                    '  WHERE t.`spid` IN (?) ' + buildStatusFilter + ' AND (' +
                     // 创建的标段
                     '    t.`user_id` = ?' +
                     // 参与审批 台账 的标段

+ 14 - 0
app/service/tender_cache.js

@@ -638,6 +638,20 @@ module.exports = app => {
                 conn.insert(this.tableName, data);
             }
         }
+        async loadTenderCaches(tenders, uid) {
+            if (!tenders.length) return;
+            const caches = await this.getAllDataByCondition({ where: { id: tenders.map(tender => tender.id) } });
+            const byId = new Map(caches.map(cache => [cache.id, cache]));
+            for (const tender of tenders) {
+                const cache = byId.get(tender.id);
+                if (!cache) continue;
+                try {
+                    this._analysisTenderCache(tender, cache, uid);
+                } catch (error) {
+                    this.ctx.log(error);
+                }
+            }
+        }
     }
 
     return TenderCache;

Разлика између датотеке није приказан због своје велике величине
+ 9 - 9
app/view/project_screen/guangyuan.ejs


+ 1 - 0
app/view/project_screen/guangyuan_building.ejs

@@ -1,3 +1,4 @@
+<script id="gy-building-data" type="application/json"><%- JSON.stringify(buildingProjects).replace(/</g, '\\u003c') %></script>
 <div id="gy-building-page" class="gy-planned-page" hidden>
     <section class="gy-card gy-project-list"><div class="gy-title"><h2>项目列表</h2></div><div class="gy-list-heading">项目名称</div><div id="gy-building-tree" class="gy-scroll"></div></section>
     <div class="gy-planned-detail">

+ 1 - 0
app/view/project_screen/guangyuan_finished.ejs

@@ -1,3 +1,4 @@
+<script id="gy-finished-data" type="application/json"><%- JSON.stringify(finishedProjects).replace(/</g, '\\u003c') %></script>
 <div id="gy-finished-page" class="gy-finished-page" hidden>
     <section class="gy-card"><div class="gy-title"><h2>项目完工投资</h2><span>单位:万元</span></div><div id="gy-finished-investment-chart" class="gy-chart"></div></section>
     <div class="gy-finished-layout">

+ 14 - 0
app/view/project_screen/guangyuan_modal.ejs

@@ -0,0 +1,14 @@
+<div id="gy-stage-dialog" class="modal fade gy-stage-dialog" tabindex="-1" role="dialog" aria-labelledby="gy-stage-dialog-title" aria-hidden="true">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 id="gy-stage-dialog-title" class="modal-title">阶段进度详情</h5>
+                <button id="gy-close-details" class="close" type="button" data-dismiss="modal" aria-label="关闭阶段详情"><span aria-hidden="true">&times;</span></button>
+            </div>
+            <div class="modal-body">
+                <p id="gy-dialog-project"></p>
+                <div id="gy-dialog-content"></div>
+            </div>
+        </div>
+    </div>
+</div>

+ 0 - 8
app/view/project_screen/guanyuan_modal.ejs

@@ -1,8 +0,0 @@
-<dialog id="gy-stage-dialog" class="gy-stage-dialog">
-    <div class="gy-title">
-        <h2>阶段进度详情</h2>
-        <button id="gy-close-details" class="gy-detail-button" type="button" aria-label="关闭阶段详情">关闭 ×</button>
-    </div>
-    <p id="gy-dialog-project"></p>
-    <div id="gy-dialog-content" class="gy-scroll"></div>
-</dialog>