project-load.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  1. /**
  2. * 组长看板数据云函数 - 支持时间范围筛选
  3. *
  4. * 请求参数:
  5. * - companyId: 公司ID
  6. * - baseDate: 基准日期 (可选,格式: YYYY-MM-DD,默认为当前日期)
  7. * - startDate: 开始日期 (可选,覆盖 baseDate 计算)
  8. * - endDate: 结束日期 (可选,覆盖 baseDate 计算)
  9. *
  10. * 时间范围逻辑:
  11. * - 如果不传时间参数,默认查询当前进行中的项目
  12. * - 如果传 baseDate,则以该日期为基准,查询该月的项目
  13. * - 如果传 startDate/endDate,则使用指定的日期范围
  14. */
  15. async function handler(request, response) {
  16. console.log('🚀 执行高性能 SQL 统计 (时间范围筛选版)...');
  17. try {
  18. // 获取公司ID
  19. let companyId = 'cDL6R1hgSi';
  20. if (request.company && request.company.id) companyId = request.company.id;
  21. else if (request.params && request.params.companyId) companyId = request.params.companyId;
  22. else if (request.body && request.body.companyId) companyId = request.body.companyId;
  23. // 获取时间参数
  24. let baseDate = request.body?.baseDate || request.params?.baseDate || null;
  25. let startDate = request.body?.startDate || request.params?.startDate || null;
  26. let endDate = request.body?.endDate || request.params?.endDate || null;
  27. // 计算时间范围
  28. const now = new Date();
  29. let rangeStart, rangeEnd;
  30. if (startDate && endDate) {
  31. // 使用指定范围
  32. rangeStart = new Date(startDate);
  33. rangeEnd = new Date(endDate);
  34. } else if (baseDate) {
  35. // 基于 baseDate 计算该月的范围
  36. const base = new Date(baseDate);
  37. rangeStart = new Date(base.getFullYear(), base.getMonth(), 1); // 月初
  38. rangeEnd = new Date(base.getFullYear(), base.getMonth() + 1, 0, 23, 59, 59); // 月末
  39. } else {
  40. // 默认:查询进行中的项目(不限制时间范围,或使用较宽的时间窗口)
  41. rangeStart = new Date(now.getFullYear() - 1, 0, 1); // 一年前
  42. rangeEnd = new Date(now.getFullYear() + 1, 11, 31); // 一年后
  43. }
  44. console.log(`📅 查询时间范围: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`);
  45. // --- SQL 定义 ---
  46. const workloadSql = `
  47. SELECT
  48. u."objectId" as "id",
  49. u."name",
  50. COALESCE((u."data"->'tags'->'capacity'->>'weeklyProjects')::int, 3) as "weeklyCapacity",
  51. COUNT(DISTINCT pt."project") as "projectCount",
  52. COUNT(DISTINCT CASE WHEN p."deadline" < NOW() AND p."status" != '已完成' THEN p."objectId" END) as "overdueCount",
  53. SUM(CASE
  54. WHEN p."status" = '已完成' THEN 0
  55. ELSE ((CASE WHEN p."data"->>'projectType' = 'hard' THEN 2.0 ELSE 1.0 END) * (CASE WHEN p."deadline" < NOW() THEN 1.5 ELSE 1.0 END))
  56. END) as "weightedLoad"
  57. FROM "Profile" u
  58. LEFT JOIN "ProjectTeam" pt ON pt."profile" = u."objectId" AND pt."isDeleted" IS NOT TRUE
  59. LEFT JOIN "Project" p ON pt."project" = p."objectId" AND p."isDeleted" IS NOT TRUE AND p."status" != '已完成'
  60. WHERE u."company" = $1 AND u."roleName" = '组员' AND u."isDeleted" IS NOT TRUE
  61. GROUP BY u."objectId", u."name", u."data"
  62. ORDER BY "weightedLoad" DESC
  63. `;
  64. // 项目查询 - 添加时间范围筛选
  65. // 筛选逻辑:项目的 deadline 或 createdAt 在指定时间范围内
  66. const projectsSql = `
  67. SELECT
  68. p."objectId" as "id",
  69. p."title" as "name",
  70. p."status",
  71. p."currentStage",
  72. p."deadline",
  73. p."updatedAt",
  74. p."createdAt",
  75. p."data",
  76. p."date" as "projectDate",
  77. EXTRACT(DAY FROM (p."deadline" - NOW())) as "daysLeft",
  78. (
  79. SELECT string_agg(pr."name", ', ')
  80. FROM "ProjectTeam" pt
  81. JOIN "Profile" pr ON pt."profile" = pr."objectId"
  82. WHERE pt."project" = p."objectId" AND pt."isDeleted" IS NOT TRUE
  83. ) as "designerName",
  84. (
  85. SELECT array_agg(pt."profile")
  86. FROM "ProjectTeam" pt
  87. WHERE pt."project" = p."objectId" AND pt."isDeleted" IS NOT TRUE
  88. ) as "designerIds"
  89. FROM "Project" p
  90. WHERE p."company" = $1
  91. AND p."isDeleted" IS NOT TRUE
  92. AND p."status" != '已完成'
  93. AND (
  94. -- 项目截止时间在范围内
  95. (p."deadline" >= $2 AND p."deadline" <= $3)
  96. -- 或者项目创建时间在范围内
  97. OR (p."createdAt" >= $2 AND p."createdAt" <= $3)
  98. -- 或者项目跨越整个范围(开始于范围前,结束于范围后)
  99. OR (p."createdAt" < $2 AND p."deadline" > $3)
  100. -- 或者没有截止时间但状态为进行中
  101. OR (p."deadline" IS NULL AND p."status" = '进行中')
  102. )
  103. ORDER BY p."updatedAt" DESC
  104. LIMIT 1000
  105. `;
  106. const spaceStatsSql = `
  107. WITH ActiveProjects AS (
  108. SELECT p."objectId"
  109. FROM "Project" p
  110. WHERE p."company" = $1
  111. AND p."isDeleted" IS NOT TRUE
  112. AND p."status" != '已完成'
  113. AND (
  114. (p."deadline" >= $2 AND p."deadline" <= $3)
  115. OR (p."createdAt" >= $2 AND p."createdAt" <= $3)
  116. OR (p."createdAt" < $2 AND p."deadline" > $3)
  117. OR (p."deadline" IS NULL AND p."status" = '进行中')
  118. )
  119. LIMIT 1000
  120. ),
  121. ProjectSpaces AS (
  122. SELECT
  123. p."objectId" as "spaceId",
  124. p."productName" as "spaceName",
  125. p."productType" as "spaceType",
  126. p."project" as "projectId"
  127. FROM "Product" p
  128. WHERE p."project" IN (SELECT "objectId" FROM ActiveProjects)
  129. AND (p."isDeleted" IS NULL OR p."isDeleted" = false)
  130. ),
  131. Deliverables AS (
  132. SELECT
  133. COALESCE(d."data"->>'spaceId', d."data"->>'productId') as "spaceId",
  134. COUNT(*) as "fileCount",
  135. SUM(CASE WHEN
  136. d."fileType" = 'delivery_white_model' OR
  137. d."data"->>'deliveryType' IN ('white_model', 'delivery_white_model')
  138. THEN 1 ELSE 0 END) as "whiteModelCount",
  139. SUM(CASE WHEN
  140. d."fileType" = 'delivery_soft_decor' OR
  141. d."data"->>'deliveryType' IN ('soft_decor', 'delivery_soft_decor')
  142. THEN 1 ELSE 0 END) as "softDecorCount",
  143. SUM(CASE WHEN
  144. d."fileType" = 'delivery_rendering' OR
  145. d."data"->>'deliveryType' IN ('rendering', 'delivery_rendering')
  146. THEN 1 ELSE 0 END) as "renderingCount",
  147. SUM(CASE WHEN
  148. d."fileType" = 'delivery_post_process' OR
  149. d."data"->>'deliveryType' IN ('post_process', 'delivery_post_process')
  150. THEN 1 ELSE 0 END) as "postProcessCount"
  151. FROM "ProjectFile" d
  152. WHERE d."project" IN (SELECT "objectId" FROM ActiveProjects)
  153. AND (d."isDeleted" IS NULL OR d."isDeleted" = false)
  154. AND (
  155. d."fileType" LIKE 'delivery_%' OR
  156. d."data"->>'uploadStage' = 'delivery'
  157. )
  158. GROUP BY COALESCE(d."data"->>'spaceId', d."data"->>'productId')
  159. )
  160. SELECT
  161. ps."projectId",
  162. ps."spaceId",
  163. ps."spaceName",
  164. ps."spaceType",
  165. COALESCE(d."fileCount", 0) as "totalFiles",
  166. COALESCE(d."whiteModelCount", 0) as "whiteModel",
  167. COALESCE(d."softDecorCount", 0) as "softDecor",
  168. COALESCE(d."renderingCount", 0) as "rendering",
  169. COALESCE(d."postProcessCount", 0) as "postProcess"
  170. FROM ProjectSpaces ps
  171. LEFT JOIN Deliverables d ON ps."spaceId" = d."spaceId"
  172. `;
  173. // Issue 查询 - 添加时间范围筛选
  174. const issuesSql = `
  175. SELECT
  176. i."objectId" as "id",
  177. i."title",
  178. i."description",
  179. i."priority",
  180. i."issueType",
  181. i."status",
  182. i."dueDate",
  183. i."createdAt",
  184. i."updatedAt",
  185. i."data",
  186. p."objectId" as "projectId",
  187. p."title" as "projectName",
  188. c."name" as "creatorName",
  189. a."name" as "assigneeName"
  190. FROM "ProjectIssue" i
  191. JOIN "Project" p ON i."project" = p."objectId"
  192. LEFT JOIN "Profile" c ON i."creator" = c."objectId"
  193. LEFT JOIN "Profile" a ON i."assignee" = a."objectId"
  194. WHERE p."company" = $1
  195. AND (i."isDeleted" IS NULL OR i."isDeleted" = false)
  196. AND i."status" IN ('待处理', '处理中')
  197. AND (
  198. i."createdAt" >= $2 OR i."dueDate" >= $2 OR i."dueDate" IS NULL
  199. )
  200. ORDER BY i."updatedAt" DESC
  201. LIMIT 50
  202. `;
  203. // --- 执行 SQL ---
  204. const queryParams = [companyId, rangeStart, rangeEnd];
  205. const [workloadResult, projectsResult, spaceStatsResult, issuesResult] = await Promise.all([
  206. Psql.query(workloadSql, [companyId]),
  207. Psql.query(projectsSql, queryParams),
  208. Psql.query(spaceStatsSql, queryParams),
  209. Psql.query(issuesSql, queryParams)
  210. ]);
  211. // --- 格式化数据 ---
  212. // 1. Workload
  213. const workload = workloadResult.map(w => {
  214. const capacity = w.weeklyCapacity || 3;
  215. const load = parseFloat(w.weightedLoad || 0);
  216. const loadRate = Math.round((load / capacity) * 100);
  217. let status = 'idle';
  218. if (loadRate > 80) status = 'overload';
  219. else if (loadRate > 50) status = 'busy';
  220. return {
  221. id: w.id,
  222. name: w.name,
  223. weeklyCapacity: capacity,
  224. projectCount: parseInt(w.projectCount),
  225. overdueCount: parseInt(w.overdueCount),
  226. weightedLoad: load,
  227. loadRate,
  228. status
  229. };
  230. });
  231. // 2. Projects
  232. const spaceAssigneeMap = {};
  233. const projects = projectsResult.map(p => {
  234. // 解析设计师分配信息
  235. if (p.projectDate && p.projectDate.designerAssignmentStats) {
  236. const stats = p.projectDate.designerAssignmentStats;
  237. if (stats.projectLeader && stats.projectLeader.assignedSpaces) {
  238. stats.projectLeader.assignedSpaces.forEach(s => { if (s.id) spaceAssigneeMap[s.id] = stats.projectLeader.name; });
  239. }
  240. if (Array.isArray(stats.teamMembers)) {
  241. stats.teamMembers.forEach(member => {
  242. if (member.assignedSpaces && member.name) {
  243. member.assignedSpaces.forEach(s => { if (s.id) spaceAssigneeMap[s.id] = member.name; });
  244. }
  245. });
  246. }
  247. if (Array.isArray(stats.crossTeamCollaborators)) {
  248. stats.crossTeamCollaborators.forEach(member => {
  249. if (member.assignedSpaces && member.name) {
  250. member.assignedSpaces.forEach(s => { if (s.id) spaceAssigneeMap[s.id] = member.name; });
  251. }
  252. });
  253. }
  254. }
  255. let statusStr = 'normal';
  256. const days = parseFloat(p.daysLeft);
  257. if (days < 0) statusStr = 'overdue';
  258. else if (days <= 3) statusStr = 'urgent';
  259. // 从 data 字段提取扩展属性
  260. const data = p.data || {};
  261. return {
  262. id: p.id,
  263. name: p.name,
  264. status: p.status,
  265. currentStage: p.currentStage,
  266. deadline: p.deadline,
  267. updatedAt: p.updatedAt,
  268. createdAt: p.createdAt,
  269. urgency: data.urgency,
  270. type: data.projectType,
  271. phaseDeadlines: data.phaseDeadlines || {},
  272. daysLeft: Math.ceil(days),
  273. isOverdue: days < 0,
  274. statusStr,
  275. designerName: p.designerName || '待分配',
  276. designerIds: p.designerIds || [],
  277. // 扩展字段
  278. data: data
  279. };
  280. });
  281. // 3. Space Stats (完全修复聚合逻辑)
  282. const spaceStats = {};
  283. // 创建项目名称映射
  284. const projectNameMap = {};
  285. projects.forEach(p => {
  286. projectNameMap[p.id] = p.name;
  287. });
  288. spaceStatsResult.forEach(row => {
  289. if (!spaceStats[row.projectId]) {
  290. spaceStats[row.projectId] = {
  291. spaces: []
  292. };
  293. }
  294. // 计算单个空间的完成度
  295. const hasFiles = parseInt(row.totalFiles) > 0;
  296. let completion = 0;
  297. if (hasFiles) {
  298. if (parseInt(row.whiteModel) > 0) completion += 25;
  299. if (parseInt(row.softDecor) > 0) completion += 25;
  300. if (parseInt(row.rendering) > 0) completion += 25;
  301. if (parseInt(row.postProcess) > 0) completion += 25;
  302. }
  303. const spaceInfo = {
  304. spaceId: row.spaceId,
  305. spaceName: row.spaceName,
  306. spaceType: row.spaceType,
  307. totalFiles: parseInt(row.totalFiles),
  308. deliverableTypes: {
  309. whiteModel: parseInt(row.whiteModel),
  310. softDecor: parseInt(row.softDecor),
  311. rendering: parseInt(row.rendering),
  312. postProcess: parseInt(row.postProcess)
  313. },
  314. hasDeliverables: hasFiles,
  315. completionRate: Math.min(100, completion)
  316. };
  317. spaceStats[row.projectId].spaces.push(spaceInfo);
  318. });
  319. Object.keys(spaceStats).forEach(pid => {
  320. const proj = spaceStats[pid];
  321. const totalSpaces = proj.spaces.length;
  322. // 计算整体完成率
  323. const sumCompletion = proj.spaces.reduce((sum, s) => sum + s.completionRate, 0);
  324. const overallCompletionRate = totalSpaces > 0 ? Math.round(sumCompletion / totalSpaces) : 0;
  325. const calcPhaseDetails = (typeKey) => {
  326. const spacesWithFile = proj.spaces.filter(s => s.deliverableTypes[typeKey] > 0);
  327. const completedCount = spacesWithFile.length;
  328. const rate = totalSpaces > 0 ? Math.round((completedCount / totalSpaces) * 100) : 0;
  329. const fileCount = proj.spaces.reduce((sum, s) => sum + s.deliverableTypes[typeKey], 0);
  330. const incomplete = proj.spaces
  331. .filter(s => s.deliverableTypes[typeKey] === 0)
  332. .map(s => ({
  333. spaceName: s.spaceName,
  334. assignee: spaceAssigneeMap[s.spaceId] || '未分配',
  335. spaceId: s.spaceId
  336. }));
  337. return {
  338. completionRate: rate,
  339. completedSpaces: completedCount,
  340. requiredSpaces: totalSpaces,
  341. totalFiles: fileCount,
  342. incompleteSpaces: incomplete
  343. };
  344. };
  345. const phaseProgress = {
  346. modeling: calcPhaseDetails('whiteModel'),
  347. softDecor: calcPhaseDetails('softDecor'),
  348. rendering: calcPhaseDetails('rendering'),
  349. postProcessing: calcPhaseDetails('postProcess')
  350. };
  351. spaceStats[pid] = {
  352. projectId: pid,
  353. projectName: projectNameMap[pid] || '未命名项目',
  354. totalSpaces,
  355. spaces: proj.spaces,
  356. totalDeliverableFiles: proj.spaces.reduce((sum, s) => sum + s.totalFiles, 0),
  357. totalByType: {
  358. whiteModel: proj.spaces.reduce((sum, s) => sum + s.deliverableTypes.whiteModel, 0),
  359. softDecor: proj.spaces.reduce((sum, s) => sum + s.deliverableTypes.softDecor, 0),
  360. rendering: proj.spaces.reduce((sum, s) => sum + s.deliverableTypes.rendering, 0),
  361. postProcess: proj.spaces.reduce((sum, s) => sum + s.deliverableTypes.postProcess, 0)
  362. },
  363. overallCompletionRate,
  364. phaseProgress
  365. };
  366. });
  367. // 4. Issues
  368. const zh2enStatus = (status) => {
  369. const map = {
  370. '待处理': 'open',
  371. '处理中': 'in_progress',
  372. '已解决': 'resolved',
  373. '已关闭': 'closed'
  374. };
  375. return map[status] || 'open';
  376. };
  377. const issues = issuesResult.map(row => ({
  378. id: row.id,
  379. title: row.title || (row.description ? row.description.slice(0, 40) : '未命名问题'),
  380. description: row.description,
  381. priority: row.priority || 'medium',
  382. type: row.issueType || 'task',
  383. status: zh2enStatus(row.status),
  384. projectId: row.projectId || '',
  385. projectName: row.projectName || '未知项目',
  386. relatedSpace: row.data?.relatedSpace,
  387. relatedStage: row.data?.relatedStage,
  388. assigneeName: row.assigneeName || '未指派',
  389. creatorName: row.creatorName || '未知',
  390. createdAt: row.createdAt,
  391. updatedAt: row.updatedAt,
  392. dueDate: row.dueDate,
  393. tags: row.data?.tags || []
  394. }));
  395. // 5. Stats
  396. const stats = {
  397. totalActive: projects.length,
  398. overdueCount: projects.filter(p => p.isOverdue).length,
  399. urgentCount: projects.filter(p => p.statusStr === 'urgent').length,
  400. avgLoadRate: workload.length > 0 ? Math.round(workload.reduce((sum, w) => sum + w.loadRate, 0) / workload.length) : 0
  401. };
  402. // 6. 返回时间范围信息
  403. const timeRange = {
  404. start: rangeStart.toISOString(),
  405. end: rangeEnd.toISOString(),
  406. baseDate: baseDate || now.toISOString().split('T')[0]
  407. };
  408. response.json({
  409. code: 200,
  410. success: true,
  411. data: {
  412. stats,
  413. workload,
  414. projects,
  415. spaceStats,
  416. issues,
  417. timeRange // 添加时间范围信息
  418. }
  419. });
  420. } catch (error) {
  421. console.error('❌ SQL 执行失败:', error.message);
  422. response.json({ code: 500, success: false, error: error.message });
  423. }
  424. }