project_screen.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. 'use strict';
  2. const projectScreenConst = require('../const/project_screen');
  3. const Ledger = require('../lib/ledger');
  4. module.exports = app => {
  5. class ProjectScreen extends app.BaseService {
  6. constructor(ctx) {
  7. super(ctx);
  8. this.tableName = 'project_screen';
  9. }
  10. async getList(pid, transaction = null) {
  11. const db = transaction || this.db;
  12. const result = await db.select(this.tableName, {
  13. where: { pid },
  14. orders: [['id', 'asc']],
  15. });
  16. return result.map(item => {
  17. const template = projectScreenConst.getTemplate(item.template_code);
  18. return Object.assign(item, {
  19. template: template || {
  20. code: item.template_code,
  21. name: item.name,
  22. description: '',
  23. previewClass: '',
  24. },
  25. });
  26. });
  27. }
  28. async hasScreen(pid) {
  29. return (await this.count({ pid })) > 0;
  30. }
  31. async getViewScreen(pid, id = '') {
  32. const screens = await this.getList(pid);
  33. if (screens.length === 0) return null;
  34. if (id) {
  35. const selected = screens.find(item => item.id === parseInt(id));
  36. if (selected) return selected;
  37. }
  38. return screens.find(item => item.is_default === 1) || screens[0];
  39. }
  40. async addScreens(pid, codes) {
  41. const selectedCodes = [];
  42. for (const code of codes || []) {
  43. if (!projectScreenConst.getTemplate(code)) throw '大屏模板不存在';
  44. if (selectedCodes.indexOf(code) < 0) selectedCodes.push(code);
  45. }
  46. if (selectedCodes.length === 0) throw '请至少选择一个大屏';
  47. const transaction = await this.db.beginTransaction();
  48. try {
  49. const activeList = await transaction.select(this.tableName, {
  50. where: { pid },
  51. orders: [['id', 'asc']],
  52. });
  53. const needDefault = activeList.length === 0;
  54. const existingCodes = activeList.map(item => item.template_code);
  55. const insertCodes = selectedCodes.filter(code => existingCodes.indexOf(code) < 0);
  56. const insertData = insertCodes.map((code, index) => {
  57. const template = projectScreenConst.getTemplate(code);
  58. return {
  59. pid,
  60. template_code: code,
  61. name: template.name,
  62. is_default: needDefault && index === 0 ? 1 : 0,
  63. };
  64. });
  65. if (insertData.length > 0) await transaction.insert(this.tableName, insertData);
  66. await transaction.commit();
  67. return await this.getList(pid);
  68. } catch (error) {
  69. await transaction.rollback();
  70. throw error;
  71. }
  72. }
  73. async setDefault(pid, id) {
  74. const transaction = await this.db.beginTransaction();
  75. try {
  76. const target = await transaction.get(this.tableName, {
  77. id, pid,
  78. });
  79. if (!target) throw '大屏不存在';
  80. await transaction.update(this.tableName, { is_default: 0 }, {
  81. where: { pid },
  82. });
  83. await transaction.update(this.tableName, {
  84. is_default: 1,
  85. }, {
  86. where: { id, pid },
  87. });
  88. await transaction.commit();
  89. return await this.getList(pid);
  90. } catch (error) {
  91. await transaction.rollback();
  92. throw error;
  93. }
  94. }
  95. async deleteScreen(pid, id) {
  96. const transaction = await this.db.beginTransaction();
  97. try {
  98. const target = await transaction.get(this.tableName, { id, pid });
  99. if (!target) throw '大屏不存在或已删除';
  100. await transaction.delete(this.tableName, { id, pid });
  101. if (target.is_default === 1) {
  102. const remaining = await transaction.select(this.tableName, {
  103. where: { pid },
  104. orders: [['id', 'asc']],
  105. });
  106. if (remaining.length > 0) {
  107. await transaction.update(this.tableName, { is_default: 0 }, {
  108. where: { pid },
  109. });
  110. await transaction.update(this.tableName, { is_default: 1 }, {
  111. where: { id: remaining[0].id, pid },
  112. });
  113. }
  114. }
  115. await transaction.commit();
  116. return await this.getList(pid);
  117. } catch (error) {
  118. await transaction.rollback();
  119. throw error;
  120. }
  121. }
  122. async getFinishedProjects() {
  123. return this.getStatusProjects('已建');
  124. }
  125. async getStatusProjects(status, options = {}) {
  126. const ctx = this.ctx;
  127. const pid = ctx.session.sessionProject.id;
  128. const nodes = await ctx.service.projectScreenTender.getDisplayProjects(pid);
  129. const infos = await ctx.service.subProjInfo.getAllDataByCondition({ where: { project_id: pid, proj_status: status || ['拟建', '在建', '已建'] } });
  130. const calc = ctx.helper;
  131. // Preserve decimal precision during aggregation and unit conversion.
  132. // 元 fields have eight decimal places; conversion to 万元 needs twelve.
  133. const toWan = (value, unit) => {
  134. if (value === null || value === undefined || value === '') return null;
  135. if (unit === '万元') return calc.add(0, value);
  136. if (unit === '亿元') return calc.mul(value, 10000, 12);
  137. return calc.div(value, 10000, 12);
  138. };
  139. const result = [];
  140. // Follow the project list's tree order before filtering by status.
  141. const projectTree = new Ledger.baseTree(ctx, { id: 'id', pid: 'tree_pid', level: 'tree_level', order: 'tree_order', rootId: '-1' });
  142. projectTree.loadDatas(nodes);
  143. const selectedNodes = projectTree.nodes.filter(n => !n.is_folder && (!options.id || n.id === options.id) && infos.some(info => info.id === n.id));
  144. // Match the live budget totals displayed on the project info page.
  145. const budgetIds = Array.from(new Set(selectedNodes.map(node => node.budget_id).filter(Boolean)));
  146. const budgetTotals = (await Promise.all([ctx.service.budgetGu, ctx.service.budgetGai, ctx.service.budgetYu]
  147. .map(service => service.getSumTpByBudgetIds(budgetIds))))
  148. .map(rows => new Map(rows.map(row => [String(row.bid), row.total_price])));
  149. const allTenders = await ctx.service.projectScreenTender.getDisplayTenders(pid, selectedNodes.map(node => node.id));
  150. await ctx.service.tenderCache.loadTenderCaches(allTenders, '');
  151. const allChanges = !options.summary && status === '在建' && allTenders.length ? await ctx.service.change.getAllCheckedChanges(allTenders.map(t => t.id)) : [];
  152. const allProgress = !options.summary && selectedNodes.length ? await ctx.service.subProjProgress.getAllDataByCondition({ where: { spid: selectedNodes.map(n => n.id) }, orders: [['update_time', 'desc'], ['id', 'asc']] }) : [];
  153. for (const node of selectedNodes) {
  154. const info = infos.find(i => i.id === node.id);
  155. if (!info) continue;
  156. const tenders = allTenders.filter(t => t.spid === node.id);
  157. let contract = 0;
  158. let measured = 0;
  159. let hasMeasured = false;
  160. const tenderInvestments = [];
  161. const changeRows = [];
  162. for (const tender of tenders) {
  163. contract = calc.add(contract, tender.contract_price);
  164. const stage = tender.stage_tp || {};
  165. let tenderMeasured = 0;
  166. let tenderHasMeasured = false;
  167. for (const key of ['pre_contract_tp', 'contract_tp', 'contract_pc_tp', 'pre_qc_tp', 'qc_tp', 'qc_pc_tp']) {
  168. if (stage[key] !== null && stage[key] !== undefined && stage[key] !== '') {
  169. tenderMeasured = calc.add(tenderMeasured, stage[key]);
  170. tenderHasMeasured = true;
  171. }
  172. }
  173. measured = calc.add(measured, tenderMeasured);
  174. hasMeasured = hasMeasured || tenderHasMeasured;
  175. tenderInvestments.push({ id: tender.id, name: tender.name, measured: tenderHasMeasured ? toWan(tenderMeasured, '元') : null });
  176. if (!options.summary && status === '在建') {
  177. const changes = allChanges.filter(change => change.tid === tender.id);
  178. const totals = [1, 2, 3].map(quality => changes.filter(c => Number(c.quality) === quality).reduce((sum, c) => calc.add(sum, c.total_price), 0));
  179. const total = changes.reduce((sum, c) => calc.add(sum, c.total_price), 0);
  180. const ledger = tender.ledger_tp && tender.ledger_tp.total_price;
  181. changeRows.push({ id: tender.id, name: tender.name, contract: toWan(ledger, '元'),
  182. changes: totals.map(value => toWan(value, '元')), total: toWan(total, '元'),
  183. ratio: Number(ledger) ? calc.div(calc.mul(total, 100, 12), ledger, 2) : null });
  184. }
  185. }
  186. let finalAmount = null;
  187. if (!options.summary && status === '已建' && node.budget_id) {
  188. const budget = await ctx.service.budget.getDataById(node.budget_id);
  189. if (budget && budget.final_id) {
  190. const final = await ctx.service.budgetFinal.getAllDataByCondition({ where: { final_id: budget.final_id } });
  191. const top = final.filter(row => Number(row.tree_pid) === -1);
  192. if (top.length) finalAmount = toWan(top.reduce((sum, row) => calc.add(sum, row.final_tp), 0), '元');
  193. }
  194. }
  195. const parent = nodes.find(n => n.id === node.tree_pid);
  196. const progressRows = allProgress.filter(row => row.spid === node.id);
  197. const progressPaths = this.getProgressPaths(progressRows);
  198. const latest = progressRows[0];
  199. const keys = ['edit', 'submit', 'reply'];
  200. const latestEvent = latest && keys.map((key, index) => ({ date: latest[key + '_date'], department: latest[key + '_department'], step: ['编制', '报审', '批复'][index] }))
  201. .filter(event => event.date).sort((a, b) => String(b.date).localeCompare(String(a.date)))[0];
  202. const stages = this.sortProgressRows(progressRows, progressPaths).filter(row => row.tree_level > 1 && row.tree_is_leaf === 1).map(row => ({
  203. id: row.id, name: row.name, code: row.code || '',
  204. path: this.formatProgressPath(progressPaths.get(row.id)),
  205. shortPath: this.formatProgressPath(progressPaths.get(row.id), true),
  206. states: keys.map(key => row[key + '_progress'] === '已完成' ? 0 : row[key + '_progress'] === '进行中' ? 1 : 2),
  207. dates: keys.map(key => row[key + '_date'] || ''), departments: keys.map(key => row[key + '_department'] || '')
  208. }));
  209. result.push({ id: node.id, name: node.name, status: info.proj_status, group: parent ? parent.name : '',
  210. folders: projectTree.getAllParents(node).filter(folder => folder.is_folder).map(folder => ({ id: folder.id, name: folder.name })),
  211. intro: info.proj_intro || '', date: info.jg_date || '', department: info.jg_department || '',
  212. measured: hasMeasured ? toWan(measured, '元') : null,
  213. tenders: tenderInvestments,
  214. changeRows, stages,
  215. progressText: latestEvent ? `${latestEvent.date},${latestEvent.department || ''}已完成「${latest.name}」${latestEvent.step}。` : '暂无阶段动态',
  216. overviewAmounts: ['lx', 'cb', 'sgt'].map(key => toWan(info[key + '_tp'], info[key + '_tp_unit'])),
  217. amounts: budgetTotals.map(totals => toWan(totals.get(String(node.budget_id)), '元'))
  218. .concat([toWan(info.pf_tp, info.pf_tp_unit), tenders.length ? toWan(contract, '元') : null, hasMeasured ? toWan(measured, '元') : null, finalAmount]) });
  219. }
  220. return result;
  221. }
  222. async getOverview(projects) {
  223. const ctx = this.ctx;
  224. const calc = ctx.helper;
  225. const [planned, building, finished] = ['拟建', '在建', '已建'].map(status => projects.filter(project => project.status === status));
  226. const lists = [planned, building, finished];
  227. const present = value => value !== null && value !== undefined && value !== '';
  228. const rank = (list, amount) => list.map(p => ({ id: p.id, name: p.name, value: amount(p) }))
  229. .filter(p => present(p.value)).sort((a, b) => b.value - a.value).slice(0, 10);
  230. const rows = projects.length ? await ctx.service.subProjProgress.getAllDataByCondition({
  231. where: { spid: projects.map(p => p.id) }, orders: [['update_time', 'desc'], ['id', 'asc']]
  232. }) : [];
  233. 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);
  234. const trees = new Map(projects.map(p => [p.id, rows.filter(row => row.spid === p.id)]));
  235. const leafColumns = tree => {
  236. const paths = this.getProgressPaths(tree);
  237. return tree.filter(row => Number(row.tree_level) > 1 && Number(row.tree_is_leaf) === 1)
  238. .sort((a, b) => a.tree_order - b.tree_order).map(row => {
  239. const path = paths.get(row.id);
  240. return { key: JSON.stringify([path[0].name, row.name]), root: path[0].name,
  241. rootOrder: Number(path[0].tree_order) || 0,
  242. orderPath: path.slice(1).map(n => Number(n.tree_order) || 0),
  243. name: row.name, date: dateText(row.reply_date) };
  244. }).sort((a, b) => {
  245. if (a.root !== b.root) return a.rootOrder - b.rootOrder || a.root.localeCompare(b.root, 'zh-CN');
  246. for (let i = 0; i < Math.min(a.orderPath.length, b.orderPath.length); i++) {
  247. if (a.orderPath[i] !== b.orderPath[i]) return a.orderPath[i] - b.orderPath[i];
  248. }
  249. return a.orderPath.length - b.orderPath.length;
  250. });
  251. };
  252. const columnsByProject = new Map(projects.map(p => [p.id, leafColumns(trees.get(p.id))]));
  253. const approvedKeys = new Set([].concat(...Array.from(columnsByProject.values())).filter(column => column.date).map(column => column.key));
  254. // Merge same leaf names under the same root, regardless of intermediate paths.
  255. const rootGroups = new Map();
  256. for (const project of projects) {
  257. for (const column of columnsByProject.get(project.id)) {
  258. // Only expose a column when at least one project has an approval date.
  259. if (!approvedKeys.has(column.key)) continue;
  260. if (!rootGroups.has(column.root)) rootGroups.set(column.root, new Map());
  261. const group = rootGroups.get(column.root);
  262. if (!group.has(column.key)) group.set(column.key, column);
  263. }
  264. }
  265. // Sort roots only; keep leaf insertion order so different projects do not interleave.
  266. const columns = [].concat(...Array.from(rootGroups.values()).sort((a, b) => {
  267. const firstA = a.values().next().value;
  268. const firstB = b.values().next().value;
  269. return firstA.rootOrder - firstB.rootOrder || firstA.root.localeCompare(firstB.root, 'zh-CN');
  270. }).map(group => Array.from(group.values())));
  271. return {
  272. counts: lists.map(list => list.length),
  273. investment: lists.map(list => [0, 1, 2, 3, 4].map(index => {
  274. const values = list.map(p => p.amounts[index]).filter(present);
  275. return values.length ? values.reduce((sum, value) => calc.add(sum, value), 0) : null;
  276. })),
  277. planned: rank(planned, p => p.amounts[1]), finished: rank(finished, p => p.measured),
  278. building: building.map(p => ({ id: p.id, name: p.name, contract: p.amounts[4], measured: p.measured,
  279. 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
  280. })).sort((a, b) => a.rate === null ? (b.rate === null ? 0 : 1) : b.rate === null ? -1 : b.rate - a.rate),
  281. columns,
  282. preview: projects.map(p => ({ id: p.id, name: p.name, group: p.group,
  283. dates: columns.map(column => {
  284. const dates = columnsByProject.get(p.id).filter(c => c.key === column.key && c.date).map(c => c.date);
  285. return dates.sort().pop() || '';
  286. }) })),
  287. events: rows.slice(0, 20).map(row => {
  288. const project = projects.find(p => p.id === row.spid);
  289. const latest = ['edit', 'submit', 'reply'].map((key, index) => ({ date: dateText(row[key + '_date']),
  290. department: row[key + '_department'] || '', step: ['编制', '报审', '批复'][index] }))
  291. .filter(event => event.date).sort((a, b) => b.date.localeCompare(a.date))[0];
  292. return { name: project.name, date: latest ? latest.date : dateText(row.update_time),
  293. text: latest ? `${latest.department}已完成「${row.name}」${latest.step}` : `更新「${row.name}」` };
  294. })
  295. };
  296. }
  297. getProgressPaths(rows) {
  298. const nodes = new Map(rows.map(row => [String(row.tree_id), row]));
  299. const paths = new Map();
  300. for (const row of rows) {
  301. const path = [];
  302. const visited = new Set();
  303. let node = row;
  304. while (node && !visited.has(String(node.tree_id))) {
  305. path.unshift(node);
  306. visited.add(String(node.tree_id));
  307. node = nodes.get(String(node.tree_pid));
  308. }
  309. paths.set(row.id, path);
  310. }
  311. return paths;
  312. }
  313. sortProgressRows(rows, paths) {
  314. return rows.slice().sort((a, b) => {
  315. const pathA = paths.get(a.id);
  316. const pathB = paths.get(b.id);
  317. for (let i = 0; i < Math.min(pathA.length, pathB.length); i++) {
  318. if (pathA[i].id === pathB[i].id) continue;
  319. return pathA[i].tree_order - pathB[i].tree_order || String(pathA[i].id).localeCompare(String(pathB[i].id));
  320. }
  321. return pathA.length - pathB.length;
  322. });
  323. }
  324. formatProgressPath(path, abbreviated = false) {
  325. const names = path.map(node => node.name);
  326. if (abbreviated && names.length > 2) return [names[0], '...', names[names.length - 1]].join(' / ');
  327. return names.join(' / ');
  328. }
  329. async saveModuleStatus(pid, enabled) {
  330. const ctx = this.ctx;
  331. const service = ctx.service.projectScreenTender;
  332. const pageShow = await service.withTransaction(async transaction => {
  333. const settings = service.parseSettings(await service.getProject(pid, transaction, true));
  334. settings.pageShow.openProjectScreen = enabled;
  335. await service.saveSettings(pid, settings.pageShow, transaction);
  336. return settings.pageShow;
  337. });
  338. ctx.session.sessionProject.page_show = await ctx.service.projectAccount.getPageShow(JSON.stringify(pageShow));
  339. }
  340. }
  341. return ProjectScreen;
  342. };