SPACE_SYNC_ANALYSIS_PART1.md 15 KB

项目空间数量同步分析报告 - 第一部分

📋 执行摘要

本报告分析了订单分配阶段、报价明细、交付执行板块以及设计师分配弹窗中的空间数量同步机制

核心发现:

  • 空间数据存储:使用 Product 表(对应 ProductSpace 服务)
  • 同步机制:通过 ProductSpaceService.getProjectProductSpaces() 统一查询
  • 数据字段:Project.data 中存储报价信息,Product 表存储空间产品信息
  • ⚠️ 同步状态部分同步,存在潜在的数据不一致风险

1. 空间数据存储字段

1.1 Product 表(ProductSpace)

表名Product(通过 ProductSpaceService 访问)

核心字段

interface ProductSpace {
  id: string;                    // 产品ID
  name: string;                  // 空间名称(如"客厅"、"卧室")
  type: string;                  // 空间类型(living_room, bedroom, kitchen等)
  area?: number;                 // 面积(㎡)
  priority?: number;             // 优先级
  status: string;                // 状态(not_started, in_progress, completed等)
  complexity?: string;           // 复杂度(low, medium, high)
  estimatedBudget?: number;      // 预算
  order?: number;                // 排序顺序
  metadata?: {
    description?: string;        // 描述
  };
  project: Pointer;              // 关联的项目指针
  company: Pointer;              // 关联的公司指针
}

存储位置:Parse Server 的 Product

查询方法

// ProductSpaceService
async getProjectProductSpaces(projectId: string): Promise<ProductSpace[]>

1.2 Project 表(项目数据)

表名Project

空间相关字段

interface ProjectData {
  // 报价信息(存储在 project.data.quotation)
  quotation?: {
    spaces: Array<{
      name: string;              // 空间名称
      spaceId?: string;          // 关联的Product ID
      processes: {
        modeling: { enabled, price, quantity };
        softDecor: { enabled, price, quantity };
        rendering: { enabled, price, quantity };
        postProcess: { enabled, price, quantity };
      };
      subtotal: number;          // 小计
    }>;
    total: number;               // 总价
    spaceBreakdown: Array<{
      spaceName: string;
      spaceId: string;
      amount: number;
      percentage: number;
    }>;
  };
  
  // 特殊需求(存储在 project.data.spaceSpecialRequirements)
  spaceSpecialRequirements?: {
    [spaceId: string]: {
      content: string;
      updatedAt: Date;
      updatedBy: string;
    };
  };
}

存储位置:Parse Server 的 Project 表的 data 字段(JSON格式)


2. 空间数量同步流程

2.1 订单分配阶段(stage-order.component.ts)

文件src/modules/project/pages/project-detail/stages/stage-order.component.ts

空间加载流程

// 第695-758行:loadProjectSpaces()
async loadProjectSpaces(): Promise<void> {
  // 1. 从ProductSpace表加载空间数据
  this.projectSpaces = await this.productSpaceService.getProjectProductSpaces(
    this.project.id
  );

  // 2. 如果没有空间数据,但从项目数据中有报价信息,则转换创建默认空间
  if (this.projectSpaces.length === 0) {
    const data = this.project.get('data') || {};
    if (data.quotation?.spaces) {
      await this.createSpacesFromQuotation(data.quotation.spaces);
    }
  }

  // 3. ⭐ 同步缺失空间:当已有部分 ProductSpace 时,补齐与报价明细一致
  if (this.projectSpaces.length > 0) {
    const quotationSpaces = Array.isArray(data.quotation?.spaces) 
      ? data.quotation.spaces 
      : [];
    
    // 找出报价中有但Product表中没有的空间
    const missing = quotationSpaces.filter((s: any) => {
      const n = (s?.name || '').trim().toLowerCase();
      return n && !existingNames.has(n);
    });
    
    // 为缺失的空间创建Product记录
    if (missing.length > 0) {
      for (const spaceData of missing) {
        await this.productSpaceService.createProductSpace(
          this.project!.id,
          {
            name: spaceData.name,
            type: this.inferSpaceType(spaceData.name),
            status: 'not_started',
            complexity: 'medium',
            order: this.projectSpaces.length
          }
        );
      }
      // 重新加载补齐后的空间列表
      this.projectSpaces = await this.productSpaceService
        .getProjectProductSpaces(this.project.id);
    }
  }
}

关键方法

方法名 功能 行号
loadProjectSpaces() 加载项目空间 695-758
createSpacesFromQuotation() 从报价创建空间 763-784
inferSpaceType() 推断空间类型 789-797
calculateSpaceRate() 计算空间预算 802-811
regenerateQuotationFromSpaces() 从空间重新生成报价 950-996
updateSpaceBreakdown() 更新空间占比 1001-1008

数据同步逻辑

  1. 优先从 Product 表加载空间
  2. 如果 Product 表为空,从 Project.data.quotation 创建
  3. 如果两者都有,检查缺失的空间并自动创建

2.2 报价明细(quotation-editor.component.ts)

文件src/modules/project/components/quotation-editor.component.ts

空间数量来源

// 从项目的Product表获取空间数量
this.projectSpaces = await this.productSpaceService
  .getProjectProductSpaces(this.projectId);

// 空间数量 = this.projectSpaces.length
const spaceCount = this.projectSpaces.length;

报价空间结构

quotation.spaces: Array<{
  name: string;           // 空间名称
  spaceId?: string;       // 关联的Product ID
  processes: {...};       // 工序明细
  subtotal: number;       // 小计
}>

// 空间数量 = quotation.spaces.length

同步机制

  • 报价中的 spaceId 字段关联 Product 表的 ID
  • 通过 spaceId 建立报价与Product的关联

2.3 交付执行板块(stage-delivery.component.ts)

文件src/modules/project/pages/project-detail/stages/stage-delivery.component.ts

空间加载流程

// 第145-188行:syncProductsWithQuotation()
private async syncProductsWithQuotation(): Promise<void> {
  if (!this.project) return;
  
  const data = this.project.get('data') || {};
  const quotationSpaces: any[] = Array.isArray(data.quotation?.spaces) 
    ? data.quotation.spaces 
    : [];
  
  if (quotationSpaces.length === 0) return;

  // 检查哪些空间在Product表中缺失
  const existingById = new Set(this.projectProducts.map(p => p.id));
  const existingByName = new Set(
    this.projectProducts.map(p => (p.name || '').trim().toLowerCase())
  );

  const missing = quotationSpaces.filter(s => {
    const n = (s?.name || '').trim().toLowerCase();
    const pid = s?.productId || '';
    if (pid && existingById.has(pid)) return false;
    if (n && existingByName.has(n)) return false;
    return true;
  });

  // 为缺失的空间创建Product记录
  if (missing.length > 0) {
    for (const s of missing) {
      await this.productSpaceService.createProductSpace(
        this.project!.id,
        {
          name: s?.name || '未命名空间',
          type: 'other',
          priority: 5,
          status: 'not_started',
          complexity: 'medium',
          order: this.projectProducts.length
        }
      );
    }
    
    // 重新加载并去重
    this.projectProducts = await this.productSpaceService
      .getProjectProductSpaces(this.project.id);
    
    this.projectProducts = this.projectProducts.filter((p, idx, arr) => {
      const key = (p.name || '').trim().toLowerCase();
      return arr.findIndex(x => (x.name || '').trim().toLowerCase() === key) === idx;
    });
  }
}

关键特性

  • 自动去重(按名称)
  • 同步报价中的空间到Product表
  • 支持按ID或名称匹配

2.4 设计师分配弹窗(designer-team-assignment-modal.component.ts)

文件src/app/pages/designer/project-detail/components/designer-team-assignment-modal/designer-team-assignment-modal.component.ts

空间加载流程

// 第84-88行:输入属性
@Input() spaceScenes: SpaceScene[] = [];           // 空间场景列表
@Input() enableSpaceAssignment: boolean = false;   // 是否启用空间分配
@Input() projectId: string = '';                   // 项目ID
@Input() loadRealData: boolean = true;             // 是否加载真实数据
@Input() loadRealSpaces: boolean = true;           // 是否自动加载真实空间数据

// 第296-298行:ngOnInit()
if (this.loadRealSpaces && this.projectId) {
  await this.loadRealProjectSpaces();
}

// 第951-987行:loadRealProjectSpaces()
async loadRealProjectSpaces() {
  if (!this.projectId) {
    console.warn('未提供projectId,无法加载空间数据');
    return;
  }

  try {
    this.loadingSpaces = true;
    this.spaceLoadError = '';

    // 使用ProductSpaceService查询项目的所有空间产品
    this.parseProducts = await this.productSpaceService
      .getProjectProductSpaces(this.projectId);

    if (this.parseProducts.length === 0) {
      console.warn('未找到项目空间数据');
      this.spaceLoadError = '未找到项目空间数据';
      return;
    }

    // 转换为SpaceScene格式
    this.spaceScenes = this.parseProducts.map(product => ({
      id: product.id,
      name: product.name,
      area: product.area,
      description: this.getProductDescription(product)
    }));

    console.log('成功加载项目空间数据:', this.spaceScenes);
  } catch (err) {
    console.error('加载项目空间数据失败:', err);
    this.spaceLoadError = '加载项目空间数据失败';
  } finally {
    this.loadingSpaces = false;
    this.cdr.markForCheck();
  }
}

空间分配数据结构

// 第53-57行:DesignerSpaceAssignment
export interface DesignerSpaceAssignment {
  designerId: string;
  designerName: string;
  spaceIds: string[];  // 分配的空间ID列表
}

// 第1311-1315行:getDesignerSpacesText()
getDesignerSpacesText(designerId: string): string {
  const spaces = this.getDesignerSpaces(designerId);
  if (spaces.length === 0) return '未分配空间';
  return spaces.map(s => s.name).join(', ');
}

// 第1305-1308行:getDesignerSpaces()
getDesignerSpaces(designerId: string): SpaceScene[] {
  const spaceIds = this.designerSpaceMap.get(designerId) || [];
  return this.spaceScenes.filter(space => spaceIds.includes(space.id));
}

关键方法

方法名 功能 行号
loadRealProjectSpaces() 加载真实空间数据 951-987
getDesignerSpaces() 获取设计师分配的空间 1305-1308
getDesignerSpacesText() 获取空间名称文本 1311-1315
isSpaceSelected() 检查空间是否被选中 1299-1302
toggleSpaceSelection() 切换空间选择 (需查看完整代码)

3. 数据同步现状分析

3.1 同步机制总结

订单分配阶段 (stage-order)
    ↓
    ├─ 从Product表加载空间
    │  └─ productSpaceService.getProjectProductSpaces()
    │
    ├─ 从Project.data.quotation同步缺失空间
    │  └─ 自动创建Product记录
    │
    └─ 生成报价 (quotation.spaces)
       └─ 每个空间关联spaceId

报价明细 (quotation-editor)
    ↓
    ├─ 读取quotation.spaces
    │  └─ 空间数量 = quotation.spaces.length
    │
    └─ 通过spaceId关联Product表

交付执行 (stage-delivery)
    ↓
    ├─ 从Product表加载空间
    │  └─ productSpaceService.getProjectProductSpaces()
    │
    ├─ 同步报价中的缺失空间
    │  └─ 自动创建Product记录
    │
    └─ 去重处理
       └─ 按名称去重

设计师分配 (designer-team-assignment-modal)
    ↓
    ├─ 从Product表加载空间
    │  └─ productSpaceService.getProjectProductSpaces()
    │
    ├─ 转换为SpaceScene格式
    │  └─ { id, name, area, description }
    │
    └─ 分配空间给设计师
       └─ designerSpaceMap: Map<designerId, spaceIds[]>

3.2 同步数据字段对应关系

阶段 数据来源 字段 空间数量 同步方式
订单分配 Product表 projectSpaces[] length 自动创建缺失空间
报价明细 Project.data quotation.spaces[] length 通过spaceId关联
交付执行 Product表 projectProducts[] length 自动创建缺失空间
设计师分配 Product表 spaceScenes[] length 直接查询Product

3.3 同步一致性检查

✅ 已实现的同步

  1. Product表 ↔ 报价明细:通过 spaceId 字段关联
  2. Product表 ↔ 交付执行:通过 syncProductsWithQuotation() 自动同步
  3. Product表 ↔ 设计师分配:通过 loadRealProjectSpaces() 加载

⚠️ 潜在的不一致风险

  1. 报价修改后未同步Product表

    • 问题:在报价编辑器中添加/删除空间,不会自动更新Product表
    • 影响:交付执行和设计师分配看到的空间数量可能不一致
    • 解决方案:需要在报价保存时同步Product表
  2. Product表删除后未更新报价

    • 问题:直接删除Product记录,报价中仍保留该空间
    • 影响:报价中的spaceId可能指向不存在的Product
    • 解决方案:需要级联删除或软删除处理
  3. 空间名称修改后的同步

    • 问题:修改Product的name,报价中的name可能不同步
    • 影响:显示的空间名称可能不一致
    • 解决方案:需要定期同步或使用spaceId作为唯一标识

4. 关键代码文件清单

文件路径 功能 关键方法
src/modules/project/services/product-space.service.ts 空间产品服务 getProjectProductSpaces(), createProductSpace(), updateProductSpace(), deleteProductSpace()
src/modules/project/pages/project-detail/stages/stage-order.component.ts 订单分配阶段 loadProjectSpaces(), createSpacesFromQuotation(), regenerateQuotationFromSpaces()
src/modules/project/components/quotation-editor.component.ts 报价编辑器 (需查看完整代码)
src/modules/project/pages/project-detail/stages/stage-delivery.component.ts 交付执行阶段 syncProductsWithQuotation()
src/app/pages/designer/project-detail/components/designer-team-assignment-modal/designer-team-assignment-modal.component.ts 设计师分配弹窗 loadRealProjectSpaces(), getDesignerSpaces(), getDesignerSpacesText()

5. 总结

核心同步机制:

  1. Product表 是空间数据的唯一真实来源
  2. Project.data.quotation 存储报价信息,包含空间明细
  3. 各阶段通过 ProductSpaceService.getProjectProductSpaces() 统一查询空间
  4. 自动同步机制确保Product表与报价数据基本一致
  5. 设计师分配弹窗从Product表加载空间,支持空间分配功能

实现方式:

  • ✅ 订单分配:自动创建缺失的Product记录
  • ✅ 交付执行:自动同步报价中的空间到Product表
  • ✅ 设计师分配:从Product表加载空间,支持多设计师分配

建议改进:

  • 添加报价保存时的Product同步
  • 实现空间数据一致性验证
  • 完善设计师空间分配的持久化存储