|
|
@@ -0,0 +1,304 @@
|
|
|
+import { Injectable } from '@angular/core';
|
|
|
+import { UrgentEvent } from '../dashboard/dashboard.model';
|
|
|
+import type { ProjectTimeline } from '../project-timeline/project-timeline';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 紧急事件服务
|
|
|
+ * 负责识别和生成项目中的紧急事件(如即将到期、逾期、停滞等)
|
|
|
+ */
|
|
|
+@Injectable({
|
|
|
+ providedIn: 'root'
|
|
|
+})
|
|
|
+export class UrgentEventService {
|
|
|
+
|
|
|
+ constructor() { }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算紧急事件
|
|
|
+ * 识别截止时间已到或即将到达但未完成的关键节点
|
|
|
+ * @param projectTimelineData 项目时间轴数据
|
|
|
+ * @param handledEventIds 已处理的事件ID集合
|
|
|
+ * @param mutedEventIds 已静音的事件ID集合
|
|
|
+ */
|
|
|
+ calculateUrgentEvents(
|
|
|
+ projectTimelineData: ProjectTimeline[],
|
|
|
+ handledEventIds: Set<string> = new Set(),
|
|
|
+ mutedEventIds: Set<string> = new Set()
|
|
|
+ ): UrgentEvent[] {
|
|
|
+ const events: UrgentEvent[] = [];
|
|
|
+ const now = new Date();
|
|
|
+ const oneDayMs = 24 * 60 * 60 * 1000;
|
|
|
+ const projectEventCount = new Map<string, number>(); // 追踪每个项目生成的事件数
|
|
|
+ const MAX_EVENTS_PER_PROJECT = 2; // 每个项目最多生成2个最紧急的事件
|
|
|
+
|
|
|
+ // 辅助函数:解析事件分类
|
|
|
+ const resolveCategory = (
|
|
|
+ eventType: UrgentEvent['eventType'],
|
|
|
+ category?: 'customer' | 'phase' | 'review' | 'delivery'
|
|
|
+ ): 'customer' | 'phase' | 'review' | 'delivery' => {
|
|
|
+ if (category) return category;
|
|
|
+ switch (eventType) {
|
|
|
+ case 'phase_deadline':
|
|
|
+ return 'phase';
|
|
|
+ case 'delivery':
|
|
|
+ return 'delivery';
|
|
|
+ case 'customer_alert':
|
|
|
+ return 'customer';
|
|
|
+ default:
|
|
|
+ return 'review';
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 辅助函数:获取逾期原因
|
|
|
+ const getOverdueReason = (daysDiff: number, stalledDays: number = 0) => {
|
|
|
+ if (daysDiff >= 0) return ''; // 未逾期
|
|
|
+
|
|
|
+ // 如果项目超过3天未更新/无反馈,推测为客户原因
|
|
|
+ if (stalledDays >= 3) {
|
|
|
+ return '原因:客户未跟进反馈导致逾期';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 否则推测为设计师进度原因
|
|
|
+ return '原因:设计师进度原因导致逾期';
|
|
|
+ };
|
|
|
+
|
|
|
+ // 辅助函数:添加事件
|
|
|
+ const addEvent = (
|
|
|
+ partial: Omit<UrgentEvent, 'category' | 'statusType' | 'labels' | 'allowConfirmOnTime' | 'allowMarkHandled' | 'allowCreateTodo' | 'followUpNeeded'> &
|
|
|
+ Partial<UrgentEvent>
|
|
|
+ ) => {
|
|
|
+ // 检查该项目是否已达到事件数量上限
|
|
|
+ const currentCount = projectEventCount.get(partial.projectId) || 0;
|
|
|
+ if (currentCount >= MAX_EVENTS_PER_PROJECT) {
|
|
|
+ return; // 跳过,避免单个项目产生过多事件
|
|
|
+ }
|
|
|
+
|
|
|
+ const category = resolveCategory(partial.eventType, partial.category);
|
|
|
+ const statusType: UrgentEvent['statusType'] =
|
|
|
+ partial.statusType || (partial.overdueDays && partial.overdueDays > 0 ? 'overdue' : 'dueSoon');
|
|
|
+
|
|
|
+ // 简化描述,避免过长字符串
|
|
|
+ const description = partial.description?.substring(0, 100) || '';
|
|
|
+
|
|
|
+ const event: UrgentEvent = {
|
|
|
+ ...partial,
|
|
|
+ description,
|
|
|
+ category,
|
|
|
+ statusType,
|
|
|
+ labels: partial.labels ?? [],
|
|
|
+ followUpNeeded: partial.followUpNeeded ?? false,
|
|
|
+ allowConfirmOnTime:
|
|
|
+ partial.allowConfirmOnTime ?? (category !== 'customer' && statusType === 'dueSoon'),
|
|
|
+ allowMarkHandled: partial.allowMarkHandled ?? true,
|
|
|
+ allowCreateTodo: partial.allowCreateTodo ?? category === 'customer'
|
|
|
+ };
|
|
|
+ events.push(event);
|
|
|
+ projectEventCount.set(partial.projectId, currentCount + 1);
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 从 projectTimelineData 中提取数据
|
|
|
+ projectTimelineData.forEach(project => {
|
|
|
+ // 1. 检查小图对图事件
|
|
|
+ if (project.reviewDate) {
|
|
|
+ const reviewTime = project.reviewDate.getTime();
|
|
|
+ const timeDiff = reviewTime - now.getTime();
|
|
|
+ const daysDiff = Math.ceil(timeDiff / oneDayMs);
|
|
|
+
|
|
|
+ // 如果小图对图已经到期或即将到期(1天内),且不在已完成状态
|
|
|
+ if (daysDiff <= 1 && project.currentStage !== 'delivery') {
|
|
|
+ const reason = getOverdueReason(daysDiff, project.stalledDays);
|
|
|
+ const descSuffix = reason ? `,${reason}` : '';
|
|
|
+
|
|
|
+ addEvent({
|
|
|
+ id: `${project.projectId}-review`,
|
|
|
+ title: `小图对图截止`,
|
|
|
+ description: `项目「${project.projectName}」的小图对图时间已${daysDiff < 0 ? '逾期' : '临近'}${descSuffix}`,
|
|
|
+ eventType: 'review',
|
|
|
+ deadline: project.reviewDate,
|
|
|
+ projectId: project.projectId,
|
|
|
+ projectName: project.projectName,
|
|
|
+ designerName: project.designerName,
|
|
|
+ urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
|
|
|
+ overdueDays: -daysDiff,
|
|
|
+ labels: daysDiff < 0 ? ['逾期'] : ['临近'],
|
|
|
+ followUpNeeded: (project.stageName || '').includes('图') || project.status === 'warning'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 检查交付事件
|
|
|
+ if (project.deliveryDate) {
|
|
|
+ const deliveryTime = project.deliveryDate.getTime();
|
|
|
+ const timeDiff = deliveryTime - now.getTime();
|
|
|
+ const daysDiff = Math.ceil(timeDiff / oneDayMs);
|
|
|
+
|
|
|
+ // 如果交付已经到期或即将到期(1天内),且不在已完成状态
|
|
|
+ if (daysDiff <= 1 && project.currentStage !== 'delivery') {
|
|
|
+ const summary = project.spaceDeliverableSummary;
|
|
|
+ const completionRate = summary?.overallCompletionRate || 0;
|
|
|
+ const reason = getOverdueReason(daysDiff, project.stalledDays);
|
|
|
+ const descSuffix = reason ? `,${reason}` : '';
|
|
|
+
|
|
|
+ addEvent({
|
|
|
+ id: `${project.projectId}-delivery`,
|
|
|
+ title: `项目交付截止`,
|
|
|
+ description: `项目「${project.projectName}」需要在 ${project.deliveryDate.toLocaleDateString()} 交付(当前完成率 ${completionRate}%)${descSuffix}`,
|
|
|
+ eventType: 'delivery',
|
|
|
+ deadline: project.deliveryDate,
|
|
|
+ projectId: project.projectId,
|
|
|
+ projectName: project.projectName,
|
|
|
+ designerName: project.designerName,
|
|
|
+ urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
|
|
|
+ overdueDays: -daysDiff,
|
|
|
+ completionRate,
|
|
|
+ labels: daysDiff < 0 ? ['逾期'] : ['临近']
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 检查各阶段截止时间
|
|
|
+ if (project.phaseDeadlines) {
|
|
|
+ const phaseMap = {
|
|
|
+ modeling: '建模',
|
|
|
+ softDecor: '软装',
|
|
|
+ rendering: '渲染',
|
|
|
+ postProcessing: '后期'
|
|
|
+ };
|
|
|
+
|
|
|
+ Object.entries(project.phaseDeadlines).forEach(([key, phaseInfo]: [string, any]) => {
|
|
|
+ if (phaseInfo && phaseInfo.deadline) {
|
|
|
+ const deadline = new Date(phaseInfo.deadline);
|
|
|
+ const phaseTime = deadline.getTime();
|
|
|
+ const timeDiff = phaseTime - now.getTime();
|
|
|
+ const daysDiff = Math.ceil(timeDiff / oneDayMs);
|
|
|
+
|
|
|
+ // 如果阶段已经到期或即将到期(1天内),且状态不是已完成
|
|
|
+ if (daysDiff <= 1 && phaseInfo.status !== 'completed') {
|
|
|
+ const phaseName = phaseMap[key as keyof typeof phaseMap] || key;
|
|
|
+
|
|
|
+ // 获取该阶段的完成率
|
|
|
+ const summary = project.spaceDeliverableSummary;
|
|
|
+ let completionRate = 0;
|
|
|
+ if (summary && summary.phaseProgress) {
|
|
|
+ const phaseProgress = summary.phaseProgress[key as keyof typeof summary.phaseProgress];
|
|
|
+ completionRate = phaseProgress?.completionRate || 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ const reason = getOverdueReason(daysDiff, project.stalledDays);
|
|
|
+ const descSuffix = reason ? `,${reason}` : '';
|
|
|
+
|
|
|
+ addEvent({
|
|
|
+ id: `${project.projectId}-phase-${key}`,
|
|
|
+ title: `${phaseName}阶段截止`,
|
|
|
+ description: `项目「${project.projectName}」的${phaseName}阶段截止时间已${daysDiff < 0 ? '逾期' : '临近'}(完成率 ${completionRate}%)${descSuffix}`,
|
|
|
+ eventType: 'phase_deadline',
|
|
|
+ phaseName,
|
|
|
+ deadline,
|
|
|
+ projectId: project.projectId,
|
|
|
+ projectName: project.projectName,
|
|
|
+ designerName: project.designerName,
|
|
|
+ urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
|
|
|
+ overdueDays: -daysDiff,
|
|
|
+ completionRate,
|
|
|
+ labels: daysDiff < 0 ? ['逾期'] : ['临近']
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (project.stalledDays && project.stalledDays >= 7) {
|
|
|
+ addEvent({
|
|
|
+ id: `${project.projectId}-stagnant`,
|
|
|
+ title: project.stalledDays >= 14 ? '客户停滞预警' : '停滞期提醒',
|
|
|
+ description: `项目「${project.projectName}」已有 ${project.stalledDays} 天未收到客户反馈,请主动跟进。`,
|
|
|
+ eventType: 'customer_alert',
|
|
|
+ deadline: new Date(),
|
|
|
+ projectId: project.projectId,
|
|
|
+ projectName: project.projectName,
|
|
|
+ designerName: project.designerName,
|
|
|
+ urgencyLevel: project.stalledDays >= 14 ? 'critical' : 'high',
|
|
|
+ statusType: 'stagnant',
|
|
|
+ stagnationDays: project.stalledDays,
|
|
|
+ labels: ['停滞期'],
|
|
|
+ followUpNeeded: true,
|
|
|
+ allowCreateTodo: true,
|
|
|
+ allowConfirmOnTime: false,
|
|
|
+ category: 'customer'
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const inReviewStage = (project.stageName || '').includes('图') || (project.currentStage || '').includes('图');
|
|
|
+ if (inReviewStage && project.status === 'warning') {
|
|
|
+ addEvent({
|
|
|
+ id: `${project.projectId}-review-followup`,
|
|
|
+ title: '对图反馈待跟进',
|
|
|
+ description: `项目「${project.projectName}」客户反馈尚未处理,请尽快跟进。`,
|
|
|
+ eventType: 'customer_alert',
|
|
|
+ deadline: project.reviewDate || new Date(),
|
|
|
+ projectId: project.projectId,
|
|
|
+ projectName: project.projectName,
|
|
|
+ designerName: project.designerName,
|
|
|
+ urgencyLevel: 'high',
|
|
|
+ statusType: project.reviewDate && project.reviewDate < now ? 'overdue' : 'dueSoon',
|
|
|
+ labels: ['对图期'],
|
|
|
+ followUpNeeded: true,
|
|
|
+ allowCreateTodo: true,
|
|
|
+ customerIssueType: 'feedback_pending',
|
|
|
+ category: 'customer'
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ if (project.priority === 'critical') {
|
|
|
+ addEvent({
|
|
|
+ id: `${project.projectId}-customer-alert`,
|
|
|
+ title: '客户服务预警',
|
|
|
+ description: `项目「${project.projectName}」存在客户不满或抱怨,需要立即处理并记录。`,
|
|
|
+ eventType: 'customer_alert',
|
|
|
+ deadline: new Date(),
|
|
|
+ projectId: project.projectId,
|
|
|
+ projectName: project.projectName,
|
|
|
+ designerName: project.designerName,
|
|
|
+ urgencyLevel: 'critical',
|
|
|
+ statusType: 'dueSoon',
|
|
|
+ labels: ['客户预警'],
|
|
|
+ followUpNeeded: true,
|
|
|
+ allowCreateTodo: true,
|
|
|
+ customerIssueType: 'complaint',
|
|
|
+ category: 'customer'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 按紧急程度和时间排序
|
|
|
+ events.sort((a, b) => {
|
|
|
+ // 首先按紧急程度排序
|
|
|
+ const urgencyOrder = { critical: 0, high: 1, medium: 2 };
|
|
|
+ const urgencyDiff = urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
|
|
|
+ if (urgencyDiff !== 0) return urgencyDiff;
|
|
|
+
|
|
|
+ // 相同紧急程度,按截止时间排序(越早越靠前)
|
|
|
+ return a.deadline.getTime() - b.deadline.getTime();
|
|
|
+ });
|
|
|
+
|
|
|
+ // 过滤已处理和静音的事件
|
|
|
+ const filteredEvents = events.filter(event => !handledEventIds.has(event.id) && !mutedEventIds.has(event.id));
|
|
|
+
|
|
|
+ // 限制最大显示数量
|
|
|
+ const MAX_URGENT_EVENTS = 50;
|
|
|
+
|
|
|
+ if (filteredEvents.length > MAX_URGENT_EVENTS) {
|
|
|
+ console.warn(`⚠️ 紧急事件过多(${filteredEvents.length}个),已限制为前 ${MAX_URGENT_EVENTS} 个最紧急的事件`);
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`✅ 计算紧急事件完成,共 ${filteredEvents.slice(0, MAX_URGENT_EVENTS).length} 个紧急事件`);
|
|
|
+
|
|
|
+ return filteredEvents.slice(0, MAX_URGENT_EVENTS);
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('❌ 计算紧急事件失败:', error);
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|