import { Injectable } from '@angular/core'; import { FmodeParse, FmodeObject } from 'fmode-ng/parse'; import { ProjectFileService } from './project-file.service'; const Parse = FmodeParse.with('nova'); /** * 空间交付物统计信息 */ export interface SpaceDeliverableInfo { /** 空间ID(Product ID) */ spaceId: string; /** 空间名称 */ spaceName: string; /** 空间类型 */ spaceType: string; /** 交付物类型统计 */ deliverableTypes: { /** 白模文件数量 */ whiteModel: number; /** 软装文件数量 */ softDecor: number; /** 渲染文件数量 */ rendering: number; /** 后期文件数量 */ postProcess: number; }; /** 总文件数 */ totalFiles: number; /** 是否已上传交付物 */ hasDeliverables: boolean; /** 完成度(0-100) */ completionRate: number; } /** * 🆕 阶段进度信息 */ export interface PhaseProgressInfo { /** 阶段名称 */ phaseName: 'modeling' | 'softDecor' | 'rendering' | 'postProcessing'; /** 阶段中文标签 */ phaseLabel: string; /** 应完成空间数(有此类型交付物要求的空间数) */ requiredSpaces: number; /** 已完成空间数(已上传此类型交付物的空间数) */ completedSpaces: number; /** 完成率(0-100) */ completionRate: number; /** 总文件数 */ totalFiles: number; /** 未完成空间列表 */ incompleteSpaces: Array<{ spaceId: string; spaceName: string; assignee?: string; // 负责人 }>; } /** * 项目空间与交付物统计信息 */ export interface ProjectSpaceDeliverableSummary { /** 项目ID */ projectId: string; /** 项目名称 */ projectName: string; /** 空间总数 */ totalSpaces: number; /** 已上传交付物的空间数 */ spacesWithDeliverables: number; /** 空间详细列表 */ spaces: SpaceDeliverableInfo[]; /** 总交付文件数 */ totalDeliverableFiles: number; /** 各类型总计 */ totalByType: { whiteModel: number; softDecor: number; rendering: number; postProcess: number; }; /** 整体完成率(0-100) */ overallCompletionRate: number; /** 🆕 各阶段进度详情 */ phaseProgress: { modeling: PhaseProgressInfo; softDecor: PhaseProgressInfo; rendering: PhaseProgressInfo; postProcessing: PhaseProgressInfo; }; } /** * 项目空间与交付物统计服务 * * 功能: * 1. 计算项目中有多少个空间(基于Product表) * 2. 统计每个空间对应的交付物上传情况(基于ProjectFile表) * 3. 提供详细的统计数据,方便在不同地方使用(如时间轴、看板等) */ @Injectable({ providedIn: 'root' }) export class ProjectSpaceDeliverableService { // ✅ 新增:内存缓存 private summaryCache = new Map(); constructor( private projectFileService: ProjectFileService ) {} /** * 🆕 注入统计数据(用于云函数预加载) */ injectSummary(projectId: string, summary: ProjectSpaceDeliverableSummary): void { this.summaryCache.set(projectId, summary); } /** * 🆕 清除缓存 */ clearCache(projectId?: string): void { if (projectId) { this.summaryCache.delete(projectId); } else { this.summaryCache.clear(); } } /** * 获取项目的空间与交付物统计信息 * * @param projectId 项目ID * @param forceRefresh 是否强制刷新(忽略缓存) * @returns 项目空间与交付物统计摘要 */ async getProjectSpaceDeliverableSummary( projectId: string, forceRefresh: boolean = false ): Promise { // ✅ 检查缓存 if (!forceRefresh && this.summaryCache.has(projectId)) { return this.summaryCache.get(projectId)!; } try { // 1. 获取项目信息 const projectQuery = new Parse.Query('Project'); const project = await projectQuery.get(projectId); const projectName = project.get('title') || project.get('name') || '未命名项目'; // ✅ 应用方案:获取项目的所有空间(Product),包含负责人信息 let products: FmodeObject[] = []; try { const productQuery = new Parse.Query('Product'); productQuery.equalTo('project', project.toPointer()); productQuery.ascending('createdAt'); productQuery.include('profile'); // ✅ 包含负责人信息(如果存在) // ✅ 优化:如果 profile 是 Pointer,需要 include profile 的 name 字段 // 注意:Parse 的 include 会自动包含关联对象的基本字段,但为了确保 name 字段可用,我们显式 include products = await productQuery.find(); // ✅ 调试:检查 profile 字段是否正确加载 // console.log(`📊 查询到 ${products.length} 个 Product,检查 profile 字段:`); // products.forEach((product, index) => { // const profile = product.get('profile'); // const productName = product.get('productName') || '未命名'; // if (profile) { // const profileName = profile.get?.('name') || profile.name || '未知'; // console.log(` ${index + 1}. ${productName}: profile = ${profileName}`); // } else { // console.log(` ${index + 1}. ${productName}: 无 profile`); // } // }); // console.log(`📊 项目 ${projectName} 共有 ${products.length} 个空间(Product)`); } catch (productError: any) { // ✅ 容错处理:Product 查询失败时,记录警告但继续处理 console.warn(`⚠️ Product 查询失败,将使用空的空间列表:`, productError.message || productError.toString()); // console.warn(`⚠️ Product 错误详情:`, productError); products = []; // 查询失败时使用空数组 } // 3. 去重:按空间名称去重(忽略大小写和首尾空格) const uniqueProducts = this.deduplicateProducts(products); // console.log(`📊 去重后空间数:${uniqueProducts.length}`); // 4. 统计每个空间的交付物 const spaceInfos: SpaceDeliverableInfo[] = []; let totalDeliverableFiles = 0; let spacesWithDeliverables = 0; const totalByType = { whiteModel: 0, softDecor: 0, rendering: 0, postProcess: 0 }; // ✅ 应用方案:优化性能,一次性查询所有交付文件 const allDeliveryFiles = await this.projectFileService.getProjectFiles(projectId, { stage: 'delivery' }); // console.log(`📊 项目 ${projectName} 共有 ${allDeliveryFiles.length} 个交付文件`); for (const product of uniqueProducts) { // ✅ 应用方案:传入所有文件列表,避免重复查询 const spaceInfo = await this.getSpaceDeliverableInfo(projectId, product, allDeliveryFiles); spaceInfos.push(spaceInfo); // 累加统计 totalDeliverableFiles += spaceInfo.totalFiles; if (spaceInfo.hasDeliverables) { spacesWithDeliverables++; } totalByType.whiteModel += spaceInfo.deliverableTypes.whiteModel; totalByType.softDecor += spaceInfo.deliverableTypes.softDecor; totalByType.rendering += spaceInfo.deliverableTypes.rendering; totalByType.postProcess += spaceInfo.deliverableTypes.postProcess; } // 5. 计算整体完成率 const overallCompletionRate = this.calculateOverallCompletionRate(spaceInfos); // ✅ 应用方案:计算各阶段进度详情,传入 products 以获取负责人信息 const phaseProgress = this.calculatePhaseProgress(spaceInfos, project, uniqueProducts); const result = { projectId, projectName, totalSpaces: uniqueProducts.length, spacesWithDeliverables, spaces: spaceInfos, totalDeliverableFiles, totalByType, overallCompletionRate, phaseProgress }; // ✅ 存入缓存 this.summaryCache.set(projectId, result); return result; } catch (error) { console.error('获取项目空间交付物统计失败:', error); throw error; } } /** * 获取单个空间的交付物信息 * * @param projectId 项目ID * @param product Product对象 * @param allDeliveryFiles 所有交付文件列表(可选,用于性能优化) * @returns 空间交付物信息 */ private async getSpaceDeliverableInfo( projectId: string, product: FmodeObject, allDeliveryFiles?: FmodeObject[] ): Promise { const spaceId = product.id!; const spaceName = product.get('productName') || '未命名空间'; const spaceType = product.get('productType') || 'other'; // 定义交付物类型映射 const deliveryTypeMap = { whiteModel: 'delivery_white_model', softDecor: 'delivery_soft_decor', rendering: 'delivery_rendering', postProcess: 'delivery_post_process' }; // 统计各类型文件数量 const deliverableTypes = { whiteModel: 0, softDecor: 0, rendering: 0, postProcess: 0 }; // ✅ 应用方案:优化文件查询逻辑,支持 deliveryType 字段 // 如果已传入文件列表,直接使用;否则查询 let deliveryFiles: FmodeObject[]; if (allDeliveryFiles) { deliveryFiles = allDeliveryFiles; } else { deliveryFiles = await this.projectFileService.getProjectFiles(projectId, { stage: 'delivery' }); } // 定义交付类型映射(支持多种字段格式) const deliveryTypeMappings = { whiteModel: { fileType: 'delivery_white_model', deliveryType: ['white_model', 'delivery_white_model', 'whiteModel'] }, softDecor: { fileType: 'delivery_soft_decor', deliveryType: ['soft_decor', 'delivery_soft_decor', 'softDecor'] }, rendering: { fileType: 'delivery_rendering', deliveryType: ['rendering', 'delivery_rendering'] }, postProcess: { fileType: 'delivery_post_process', deliveryType: ['post_process', 'delivery_post_process', 'postProcess'] } }; // 统计各类型文件数量 for (const [key, mapping] of Object.entries(deliveryTypeMappings)) { // 过滤当前空间的该类型文件 const spaceFiles = deliveryFiles.filter(file => { const data = file.get('data') || {}; const fileType = file.get('fileType') || ''; const deliveryType = data.deliveryType || ''; const uploadStage = data.uploadStage || ''; // ✅ 应用方案:优先使用 data.spaceId,其次使用 data.productId const isSpaceMatch = data.spaceId === spaceId || data.productId === spaceId; // ✅ 应用方案:支持多种字段格式匹配 const isTypeMatch = fileType === mapping.fileType || mapping.deliveryType.includes(deliveryType) || mapping.deliveryType.includes(fileType); // ✅ 应用方案:确认是交付阶段文件(推荐检查 uploadStage) const isDeliveryStage = uploadStage === 'delivery' || !uploadStage || fileType.includes('delivery'); return isSpaceMatch && isTypeMatch && isDeliveryStage; }); deliverableTypes[key as keyof typeof deliverableTypes] = spaceFiles.length; } // 计算总文件数 const totalFiles = Object.values(deliverableTypes).reduce((sum, count) => sum + count, 0); // 判断是否已上传交付物 const hasDeliverables = totalFiles > 0; // 计算完成度(假设每种类型至少需要1个文件才算完成) const completedTypes = Object.values(deliverableTypes).filter(count => count > 0).length; const completionRate = Math.round((completedTypes / 4) * 100); return { spaceId, spaceName, spaceType, deliverableTypes, totalFiles, hasDeliverables, completionRate }; } /** * 去重Product列表(按名称去重) * * @param products Product对象数组 * @returns 去重后的Product数组 */ private deduplicateProducts(products: FmodeObject[]): FmodeObject[] { const seen = new Set(); const unique: FmodeObject[] = []; for (const product of products) { const name = (product.get('productName') || '').trim().toLowerCase(); if (!seen.has(name) && name) { seen.add(name); unique.push(product); } } return unique; } /** * 计算整体完成率 * * @param spaceInfos 空间信息列表 * @returns 完成率(0-100) */ private calculateOverallCompletionRate(spaceInfos: SpaceDeliverableInfo[]): number { if (spaceInfos.length === 0) return 0; const totalCompletionRate = spaceInfos.reduce( (sum, space) => sum + space.completionRate, 0 ); return Math.round(totalCompletionRate / spaceInfos.length); } /** * ✅ 应用方案:计算各阶段进度详情 * * @param spaceInfos 空间信息列表 * @param project 项目对象 * @param products Product对象数组(可选,用于获取空间负责人) * @returns 各阶段进度信息 */ private calculatePhaseProgress( spaceInfos: SpaceDeliverableInfo[], project: FmodeObject, products?: FmodeObject[] ): ProjectSpaceDeliverableSummary['phaseProgress'] { // 获取项目阶段截止信息中的负责人 const projectData = project.get('data') || {}; const phaseDeadlines = projectData.phaseDeadlines || {}; // ✅ 🆕 获取 designerAssignmentStats 统计数据(主要数据源) const projectDate = project.get('date') || {}; const designerAssignmentStats = projectDate.designerAssignmentStats || {}; const projectLeader = designerAssignmentStats.projectLeader || null; const teamMembers = designerAssignmentStats.teamMembers || []; const crossTeamCollaborators = designerAssignmentStats.crossTeamCollaborators || []; // 阶段映射:交付物类型 -> 阶段名称 const phaseMap = { modeling: { key: 'whiteModel' as const, label: '建模', phaseName: 'modeling' as const }, softDecor: { key: 'softDecor' as const, label: '软装', phaseName: 'softDecor' as const }, rendering: { key: 'rendering' as const, label: '渲染', phaseName: 'rendering' as const }, postProcessing: { key: 'postProcess' as const, label: '后期', phaseName: 'postProcessing' as const } }; const result: any = {}; // ✅ 🆕 构建空间ID到负责人姓名的映射(优先级:designerAssignmentStats > Product.profile > phaseDeadlines.assignee) const spaceAssigneeMap = new Map(); // 优先级1:从 designerAssignmentStats 获取空间分配信息(最准确) // 1.1 项目负责人的空间分配 if (projectLeader && projectLeader.assignedSpaces && Array.isArray(projectLeader.assignedSpaces)) { projectLeader.assignedSpaces.forEach((space: { id: string; name: string; area?: number }) => { if (space.id && projectLeader.name) { spaceAssigneeMap.set(space.id, projectLeader.name); // console.log(`📊 [designerAssignmentStats] 空间 ${space.name} (ID: ${space.id}) 的负责人: ${projectLeader.name} (项目负责人)`); } }); } // 1.2 团队成员的空间分配 teamMembers.forEach((member: { id: string; name: string; isProjectLeader?: boolean; assignedSpaces?: Array<{ id: string; name: string; area?: number }> }) => { if (member.assignedSpaces && Array.isArray(member.assignedSpaces) && member.name) { member.assignedSpaces.forEach((space: { id: string; name: string; area?: number }) => { if (space.id && !spaceAssigneeMap.has(space.id)) { // 如果该空间还没有分配负责人,则使用当前成员 spaceAssigneeMap.set(space.id, member.name); // console.log(`📊 [designerAssignmentStats] 空间 ${space.name} (ID: ${space.id}) 的负责人: ${member.name}${member.isProjectLeader ? ' (项目负责人)' : ''}`); } }); } }); // 1.3 跨组合作者的空间分配 crossTeamCollaborators.forEach((collaborator: { id: string; name: string; assignedSpaces?: Array<{ id: string; name: string; area?: number }> }) => { if (collaborator.assignedSpaces && Array.isArray(collaborator.assignedSpaces) && collaborator.name) { collaborator.assignedSpaces.forEach((space: { id: string; name: string; area?: number }) => { if (space.id && !spaceAssigneeMap.has(space.id)) { spaceAssigneeMap.set(space.id, collaborator.name); // console.log(`📊 [designerAssignmentStats] 空间 ${space.name} (ID: ${space.id}) 的负责人: ${collaborator.name} (跨组合作)`); } }); } }); // 优先级2:从 Product.profile 获取空间负责人(如果 designerAssignmentStats 中没有) if (products) { products.forEach(product => { const spaceId = product.id!; // 如果该空间还没有分配负责人,才尝试使用 Product.profile if (!spaceAssigneeMap.has(spaceId)) { const profile = product.get('profile'); if (profile) { // ✅ 优化:支持多种方式获取设计师姓名 let profileName: string | undefined; // 方式1:profile 是 Parse Object,使用 get('name') if (profile.get && typeof profile.get === 'function') { profileName = profile.get('name') || profile.get('username') || ''; } // 方式2:profile 是普通对象,直接访问 name 属性 else if (profile.name) { profileName = profile.name; } // 方式3:profile 是字符串(ID),需要查询(暂时跳过,性能考虑) else if (typeof profile === 'string') { // 可以后续实现查询逻辑 profileName = undefined; } if (profileName) { spaceAssigneeMap.set(spaceId, profileName); // console.log(`📊 [Product.profile] 空间 ${product.get('productName')} (ID: ${spaceId}) 的负责人: ${profileName}`); } else { // console.warn(`⚠️ 空间 ${product.get('productName')} (ID: ${spaceId}) 的负责人信息无法获取`, profile); } } } }); } // 计算每个阶段的进度 Object.entries(phaseMap).forEach(([phaseKey, phaseConfig]) => { const requiredSpaces = spaceInfos.length; // 假设所有空间都需要各阶段交付物 let completedSpaces = 0; let totalFiles = 0; const incompleteSpaces: Array<{ spaceId: string; spaceName: string; assignee?: string; }> = []; // ✅ 优先级3:获取阶段负责人信息(作为最后备选) const phaseInfo = phaseDeadlines[phaseKey]; const assignee = phaseInfo?.assignee; let phaseAssigneeName: string | undefined; if (assignee) { // 如果 assignee 是对象(Parse Pointer) if (assignee.objectId) { // 为了性能,暂时不实时查询,但保留接口 phaseAssigneeName = undefined; // 可以后续实现查询逻辑 } // 如果 assignee 是字符串(ID) else if (typeof assignee === 'string') { phaseAssigneeName = undefined; // 可以后续实现查询逻辑 } // 如果 assignee 是对象且包含 name 字段 else if (assignee.name) { phaseAssigneeName = assignee.name; } } spaceInfos.forEach(space => { const fileCount = space.deliverableTypes[phaseConfig.key]; totalFiles += fileCount; if (fileCount > 0) { completedSpaces++; } else { // ✅ 应用方案:未完成空间列表,按优先级获取负责人 // 优先级:designerAssignmentStats > Product.profile > phaseDeadlines.assignee let spaceAssignee = spaceAssigneeMap.get(space.spaceId); // 如果找不到,尝试使用阶段负责人 if (!spaceAssignee) { spaceAssignee = phaseAssigneeName; } // 如果还是没有,设置为"未分配" if (!spaceAssignee) { spaceAssignee = '未分配'; } // console.log(`📊 未完成空间: ${space.spaceName} (ID: ${space.spaceId}), 负责人: ${spaceAssignee}`); incompleteSpaces.push({ spaceId: space.spaceId, spaceName: space.spaceName, assignee: spaceAssignee }); } }); const completionRate = requiredSpaces > 0 ? Math.round((completedSpaces / requiredSpaces) * 100) : 0; result[phaseKey] = { phaseName: phaseConfig.phaseName, phaseLabel: phaseConfig.label, requiredSpaces, completedSpaces, completionRate, totalFiles, incompleteSpaces }; }); return result; } /** * 检查项目是否所有空间都已上传交付物 * * @param projectId 项目ID * @returns 是否全部完成 */ async isAllSpacesDelivered(projectId: string): Promise { try { const summary = await this.getProjectSpaceDeliverableSummary(projectId); return summary.spacesWithDeliverables === summary.totalSpaces && summary.totalSpaces > 0; } catch (error) { console.error('检查项目交付完成状态失败:', error); return false; } } /** * 获取项目未完成空间列表 * * @param projectId 项目ID * @returns 未完成空间的名称列表 */ async getIncompleteSpaces(projectId: string): Promise { try { const summary = await this.getProjectSpaceDeliverableSummary(projectId); return summary.spaces .filter(space => !space.hasDeliverables) .map(space => space.spaceName); } catch (error) { console.error('获取未完成空间列表失败:', error); return []; } } /** * 获取项目交付进度百分比 * * @param projectId 项目ID * @returns 进度百分比(0-100) */ async getProjectDeliveryProgress(projectId: string): Promise { try { const summary = await this.getProjectSpaceDeliverableSummary(projectId); return summary.overallCompletionRate; } catch (error) { console.error('获取项目交付进度失败:', error); return 0; } } /** * 获取空间类型显示名称 * * @param spaceType 空间类型 * @returns 显示名称 */ getSpaceTypeName(spaceType: string): string { const nameMap: Record = { 'living_room': '客厅', 'bedroom': '卧室', 'kitchen': '厨房', 'bathroom': '卫生间', 'dining_room': '餐厅', 'study': '书房', 'balcony': '阳台', 'corridor': '走廊', 'storage': '储物间', 'entrance': '玄关', 'other': '其他' }; return nameMap[spaceType] || '其他'; } /** * 格式化统计摘要为文本 * * @param summary 统计摘要 * @returns 格式化的文本 */ formatSummaryText(summary: ProjectSpaceDeliverableSummary): string { const lines = [ `项目:${summary.projectName}`, `空间总数:${summary.totalSpaces}`, `已完成空间:${summary.spacesWithDeliverables}/${summary.totalSpaces}`, `总文件数:${summary.totalDeliverableFiles}`, ` - 白模:${summary.totalByType.whiteModel}`, ` - 软装:${summary.totalByType.softDecor}`, ` - 渲染:${summary.totalByType.rendering}`, ` - 后期:${summary.totalByType.postProcess}`, `完成率:${summary.overallCompletionRate}%` ]; return lines.join('\n'); } /** * 获取项目交付状态标签 * * @param completionRate 完成率 * @returns 状态标签 */ getDeliveryStatusLabel(completionRate: number): string { if (completionRate === 0) return '未开始'; if (completionRate < 25) return '刚开始'; if (completionRate < 50) return '进行中'; if (completionRate < 75) return '接近完成'; if (completionRate < 100) return '即将完成'; return '已完成'; } /** * 获取项目交付状态颜色 * * @param completionRate 完成率 * @returns 颜色类名或颜色值 */ getDeliveryStatusColor(completionRate: number): string { if (completionRate === 0) return '#94a3b8'; // 灰色 if (completionRate < 25) return '#fbbf24'; // 黄色 if (completionRate < 50) return '#fb923c'; // 橙色 if (completionRate < 75) return '#60a5fa'; // 蓝色 if (completionRate < 100) return '#818cf8'; // 紫色 return '#34d399'; // 绿色 } }