|
@@ -1,15 +1,21 @@
|
|
|
-import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
|
|
+import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges, OnInit, OnDestroy } from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
+import { FmodeParse } from 'fmode-ng/parse';
|
|
|
+import { Subscription } from 'rxjs';
|
|
|
+
|
|
|
+const Parse = FmodeParse.with('nova');
|
|
|
|
|
|
/**
|
|
|
- * 报价编辑器组件
|
|
|
+ * 基于Product表的报价编辑器组件
|
|
|
*
|
|
|
* 功能:
|
|
|
- * 1. 展示报价明细,支持折叠/展开
|
|
|
- * 2. 编辑工序价格和数量
|
|
|
- * 3. 自动计算小计和总价
|
|
|
- * 4. 支持表格和卡片两种展示模式
|
|
|
+ * 1. 通过project.id自动加载和管理所有Product的报价
|
|
|
+ * 2. 支持多产品设计产品的报价管理
|
|
|
+ * 3. 智能报价生成和编辑
|
|
|
+ * 4. 支持家装/工装项目类型
|
|
|
+ * 5. 自动计算小计和总价
|
|
|
+ * 6. 支持表格和卡片两种展示模式
|
|
|
*/
|
|
|
@Component({
|
|
|
selector: 'app-quotation-editor',
|
|
@@ -18,13 +24,40 @@ import { FormsModule } from '@angular/forms';
|
|
|
templateUrl: './quotation-editor.component.html',
|
|
|
styleUrls: ['./quotation-editor.component.scss']
|
|
|
})
|
|
|
-export class QuotationEditorComponent implements OnChanges {
|
|
|
- @Input() quotation: any = { spaces: [], total: 0 };
|
|
|
+export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
|
|
|
+ // 输入属性
|
|
|
+ @Input() projectId: string = '';
|
|
|
@Input() canEdit: boolean = false;
|
|
|
- @Input() viewMode: 'table' | 'card' = 'card'; // 展示模式
|
|
|
+ @Input() viewMode: 'table' | 'card' = 'card';
|
|
|
+ @Input() currentUser: any = null;
|
|
|
|
|
|
+ // 输出事件
|
|
|
@Output() quotationChange = new EventEmitter<any>();
|
|
|
@Output() totalChange = new EventEmitter<number>();
|
|
|
+ @Output() loadingChange = new EventEmitter<boolean>();
|
|
|
+ @Output() productsChange = new EventEmitter<any[]>();
|
|
|
+
|
|
|
+ // 数据状态
|
|
|
+ loading: boolean = false;
|
|
|
+ project: any = null;
|
|
|
+ products: any[] = [];
|
|
|
+ projectInfo: any = {
|
|
|
+ title: '',
|
|
|
+ projectType: '', // 家装 | 工装
|
|
|
+ renderType: '', // 静态单张 | 360全景
|
|
|
+ deadline: '',
|
|
|
+ description: '',
|
|
|
+ priceLevel: '一级', // 一级(老客户) | 二级(中端组) | 三级(高端组)
|
|
|
+ };
|
|
|
+
|
|
|
+ // 报价数据结构
|
|
|
+ quotation: any = {
|
|
|
+ spaces: [], // 兼容旧格式,现在基于products
|
|
|
+ total: 0,
|
|
|
+ spaceBreakdown: [], // 产品占比明细
|
|
|
+ generatedAt: null,
|
|
|
+ validUntil: null
|
|
|
+ };
|
|
|
|
|
|
// 工序类型定义
|
|
|
processTypes = [
|
|
@@ -35,49 +68,400 @@ export class QuotationEditorComponent implements OnChanges {
|
|
|
];
|
|
|
|
|
|
// 折叠状态
|
|
|
- expandedSpaces: Set<string> = new Set();
|
|
|
+ expandedProducts: Set<string> = new Set();
|
|
|
+
|
|
|
+ // UI状态
|
|
|
+ showBreakdown: boolean = false;
|
|
|
+
|
|
|
+ // 报价配置
|
|
|
+ priceTable: any = {};
|
|
|
+ styleLevels: any = {};
|
|
|
+ spaceTypes: any = {};
|
|
|
+ businessTypes: any = {};
|
|
|
+ homeDefaultRooms: string[] = [];
|
|
|
+
|
|
|
+ // 订阅管理
|
|
|
+ private subscriptions: Subscription[] = [];
|
|
|
+
|
|
|
+ ngOnInit() {
|
|
|
+ this.loadQuotationConfig();
|
|
|
+ if (this.projectId) {
|
|
|
+ this.loadProjectData();
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
ngOnChanges(changes: SimpleChanges) {
|
|
|
+ if (changes['projectId'] && changes['projectId'].currentValue) {
|
|
|
+ this.loadProjectData();
|
|
|
+ }
|
|
|
+
|
|
|
if (changes['quotation'] && this.quotation?.spaces?.length > 0) {
|
|
|
- // 默认展开第一个空间
|
|
|
- if (this.expandedSpaces.size === 0) {
|
|
|
- this.expandedSpaces.add(this.quotation.spaces[0].name);
|
|
|
+ // 默认展开第一个产品
|
|
|
+ if (this.expandedProducts.size === 0) {
|
|
|
+ this.expandedProducts.add(this.quotation.spaces[0].name);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ ngOnDestroy() {
|
|
|
+ this.subscriptions.forEach(sub => sub.unsubscribe());
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
- * 切换空间展开/折叠状态
|
|
|
+ * 加载报价配置
|
|
|
*/
|
|
|
- toggleSpaceExpand(spaceName: string) {
|
|
|
- if (this.expandedSpaces.has(spaceName)) {
|
|
|
- this.expandedSpaces.delete(spaceName);
|
|
|
- } else {
|
|
|
- this.expandedSpaces.add(spaceName);
|
|
|
+ private loadQuotationConfig(): void {
|
|
|
+ // 设置默认值 - 暂时硬编码,后续可改为动态导入
|
|
|
+ this.homeDefaultRooms = ['客厅', '餐厅', '主卧', '次卧', '儿童房', '书房', '厨房', '卫生间', '阳台'];
|
|
|
+ this.priceTable = {};
|
|
|
+ this.styleLevels = {};
|
|
|
+ this.spaceTypes = {};
|
|
|
+ this.businessTypes = {};
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 加载项目数据
|
|
|
+ */
|
|
|
+ private async loadProjectData(): Promise<void> {
|
|
|
+ if (!this.projectId) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.loading = true;
|
|
|
+ this.loadingChange.emit(true);
|
|
|
+
|
|
|
+ // 加载项目信息
|
|
|
+ const projectQuery = new Parse.Query('Project');
|
|
|
+ projectQuery.include('customer', 'assignee', 'department');
|
|
|
+ this.project = await projectQuery.get(this.projectId);
|
|
|
+
|
|
|
+ if (this.project) {
|
|
|
+ // 加载项目信息
|
|
|
+ this.projectInfo.title = this.project.get('title') || '';
|
|
|
+ this.projectInfo.projectType = this.project.get('projectType') || '';
|
|
|
+ this.projectInfo.renderType = this.project.get('renderType') || '';
|
|
|
+ this.projectInfo.deadline = this.project.get('deadline') || '';
|
|
|
+ this.projectInfo.description = this.project.get('description') || '';
|
|
|
+
|
|
|
+ const data = this.project.get('data') || {};
|
|
|
+ if (data.priceLevel) {
|
|
|
+ this.projectInfo.priceLevel = data.priceLevel;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 加载项目的产品列表
|
|
|
+ await this.loadProjectProducts();
|
|
|
+
|
|
|
+ // 加载现有报价数据
|
|
|
+ if (data.quotation) {
|
|
|
+ this.quotation = data.quotation;
|
|
|
+ this.updateProductsFromQuotation();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载项目数据失败:', error);
|
|
|
+ } finally {
|
|
|
+ this.loading = false;
|
|
|
+ this.loadingChange.emit(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 加载项目产品列表
|
|
|
+ */
|
|
|
+ private async loadProjectProducts(): Promise<void> {
|
|
|
+ if (!this.project) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const productQuery = new Parse.Query('Product');
|
|
|
+ productQuery.equalTo('project', this.project.toPointer());
|
|
|
+ productQuery.include('profile');
|
|
|
+ productQuery.ascending('productName');
|
|
|
+
|
|
|
+ this.products = await productQuery.find();
|
|
|
+ this.productsChange.emit(this.products);
|
|
|
+
|
|
|
+ // 如果没有产品但有项目数据,创建默认产品
|
|
|
+ if (this.products.length === 0) {
|
|
|
+ await this.createDefaultProducts();
|
|
|
+ }
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载产品列表失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 创建默认产品
|
|
|
+ */
|
|
|
+ private async createDefaultProducts(): Promise<void> {
|
|
|
+ if (!this.project || !this.projectInfo.projectType) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const defaultRooms = this.getDefaultRoomsForProjectType();
|
|
|
+
|
|
|
+ for (const roomName of defaultRooms) {
|
|
|
+ const product = new Parse.Object('Product');
|
|
|
+ product.set('project', this.project.toPointer());
|
|
|
+ product.set('productName', roomName);
|
|
|
+ product.set('productType', this.inferProductType(roomName));
|
|
|
+
|
|
|
+ // 设置空间信息
|
|
|
+ product.set('space', {
|
|
|
+ spaceName: roomName,
|
|
|
+ area: 0,
|
|
|
+ dimensions: { length: 0, width: 0, height: 0 },
|
|
|
+ features: [],
|
|
|
+ constraints: [],
|
|
|
+ priority: 5,
|
|
|
+ complexity: 'medium'
|
|
|
+ });
|
|
|
+
|
|
|
+ // 设置报价信息
|
|
|
+ const basePrice = this.calculateBasePrice(roomName);
|
|
|
+ product.set('quotation', {
|
|
|
+ price: basePrice,
|
|
|
+ currency: 'CNY',
|
|
|
+ breakdown: this.calculatePriceBreakdown(basePrice),
|
|
|
+ status: 'draft',
|
|
|
+ validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30天后过期
|
|
|
+ });
|
|
|
+
|
|
|
+ // 设置需求信息
|
|
|
+ product.set('requirements', {
|
|
|
+ colorRequirement: {},
|
|
|
+ materialRequirement: {},
|
|
|
+ lightingRequirement: {},
|
|
|
+ specificRequirements: [],
|
|
|
+ constraints: {}
|
|
|
+ });
|
|
|
+
|
|
|
+ product.set('status', 'not_started');
|
|
|
+ await product.save();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重新加载产品列表
|
|
|
+ await this.loadProjectProducts();
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('创建默认产品失败:', error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 检查空间是否展开
|
|
|
+ * 根据项目类型获取默认房间
|
|
|
*/
|
|
|
- isSpaceExpanded(spaceName: string): boolean {
|
|
|
- return this.expandedSpaces.has(spaceName);
|
|
|
+ private getDefaultRoomsForProjectType(): string[] {
|
|
|
+ if (this.projectInfo.projectType === '家装') {
|
|
|
+ return this.homeDefaultRooms.length > 0 ? this.homeDefaultRooms : ['客厅', '主卧', '次卧', '厨房', '卫生间'];
|
|
|
+ } else if (this.projectInfo.projectType === '工装') {
|
|
|
+ return ['门厅', '主要空间', '辅助空间'];
|
|
|
+ }
|
|
|
+ return ['客厅'];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 展开所有空间
|
|
|
+ * 推断产品类型
|
|
|
+ */
|
|
|
+ private inferProductType(roomName: string): string {
|
|
|
+ const name = roomName.toLowerCase();
|
|
|
+ if (name.includes('客厅') || name.includes('起居')) return 'living_room';
|
|
|
+ if (name.includes('卧室') || name.includes('主卧') || name.includes('次卧')) return 'bedroom';
|
|
|
+ if (name.includes('厨房')) return 'kitchen';
|
|
|
+ if (name.includes('卫生间') || name.includes('浴室')) return 'bathroom';
|
|
|
+ if (name.includes('餐厅')) return 'dining_room';
|
|
|
+ if (name.includes('书房') || name.includes('工作室')) return 'study';
|
|
|
+ if (name.includes('阳台')) return 'balcony';
|
|
|
+ if (name.includes('玄关') || name.includes('走廊')) return 'corridor';
|
|
|
+ return 'other';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算基础价格
|
|
|
+ */
|
|
|
+ private calculateBasePrice(roomName: string): number {
|
|
|
+ // 这里应该根据报价规则计算,暂时返回默认值
|
|
|
+ const basePrices: Record<string, number> = {
|
|
|
+ '客厅': 35000,
|
|
|
+ '主卧': 28000,
|
|
|
+ '次卧': 22000,
|
|
|
+ '厨房': 25000,
|
|
|
+ '卫生间': 15000,
|
|
|
+ '餐厅': 20000,
|
|
|
+ '书房': 18000,
|
|
|
+ '阳台': 8000
|
|
|
+ };
|
|
|
+
|
|
|
+ for (const [key, price] of Object.entries(basePrices)) {
|
|
|
+ if (roomName.includes(key)) return price;
|
|
|
+ }
|
|
|
+
|
|
|
+ return 20000; // 默认价格
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算价格明细
|
|
|
+ */
|
|
|
+ private calculatePriceBreakdown(basePrice: number): any {
|
|
|
+ return {
|
|
|
+ design: basePrice * 0.3,
|
|
|
+ modeling: basePrice * 0.25,
|
|
|
+ rendering: basePrice * 0.25,
|
|
|
+ softDecor: basePrice * 0.15,
|
|
|
+ postProcess: basePrice * 0.05
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从报价数据更新产品
|
|
|
+ */
|
|
|
+ private updateProductsFromQuotation(): void {
|
|
|
+ if (!this.quotation.spaces || !this.products.length) return;
|
|
|
+
|
|
|
+ // 将报价数据映射到产品
|
|
|
+ this.quotation.spaces.forEach((space: any) => {
|
|
|
+ const product = this.products.find(p =>
|
|
|
+ p.get('productName') === space.name ||
|
|
|
+ p.get('productName').includes(space.name)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (product) {
|
|
|
+ // 更新产品的报价信息
|
|
|
+ const quotation = product.get('quotation') || {};
|
|
|
+ quotation.price = space.subtotal || 0;
|
|
|
+ quotation.processes = space.processes;
|
|
|
+ product.set('quotation', quotation);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============ 报价管理核心方法 ============
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成基于产品的报价
|
|
|
+ */
|
|
|
+ async generateQuotationFromProducts(): Promise<void> {
|
|
|
+ if (!this.products.length) return;
|
|
|
+
|
|
|
+ this.quotation.spaces = [];
|
|
|
+ this.quotation.generatedAt = new Date();
|
|
|
+ this.quotation.validUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
|
+
|
|
|
+ for (const product of this.products) {
|
|
|
+ const productName = product.get('productName');
|
|
|
+ const quotation = product.get('quotation') || {};
|
|
|
+ const basePrice = quotation.price || this.calculateBasePrice(productName);
|
|
|
+
|
|
|
+ // 生成工序明细
|
|
|
+ const processes = this.generateDefaultProcesses(basePrice);
|
|
|
+
|
|
|
+ const spaceData = {
|
|
|
+ name: productName,
|
|
|
+ productId: product.id,
|
|
|
+ processes: processes,
|
|
|
+ subtotal: this.calculateProductSubtotal(processes)
|
|
|
+ };
|
|
|
+
|
|
|
+ this.quotation.spaces.push(spaceData);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.calculateTotal();
|
|
|
+ this.updateProductBreakdown();
|
|
|
+ await this.saveQuotationToProject();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 生成默认工序
|
|
|
+ */
|
|
|
+ private generateDefaultProcesses(basePrice: number): any {
|
|
|
+ return {
|
|
|
+ modeling: {
|
|
|
+ enabled: true,
|
|
|
+ price: basePrice * 0.25,
|
|
|
+ unit: '项',
|
|
|
+ quantity: 1
|
|
|
+ },
|
|
|
+ softDecor: {
|
|
|
+ enabled: true,
|
|
|
+ price: basePrice * 0.15,
|
|
|
+ unit: '项',
|
|
|
+ quantity: 1
|
|
|
+ },
|
|
|
+ rendering: {
|
|
|
+ enabled: true,
|
|
|
+ price: basePrice * 0.25,
|
|
|
+ unit: '张',
|
|
|
+ quantity: 1
|
|
|
+ },
|
|
|
+ postProcess: {
|
|
|
+ enabled: true,
|
|
|
+ price: basePrice * 0.05,
|
|
|
+ unit: '项',
|
|
|
+ quantity: 1
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算产品小计
|
|
|
+ */
|
|
|
+ private calculateProductSubtotal(processes: any): number {
|
|
|
+ let subtotal = 0;
|
|
|
+ for (const process of Object.values(processes)) {
|
|
|
+ const proc = process as any;
|
|
|
+ if (proc.enabled) {
|
|
|
+ subtotal += proc.price * proc.quantity;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return subtotal;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新产品占比明细
|
|
|
+ */
|
|
|
+ private updateProductBreakdown(): void {
|
|
|
+ this.quotation.spaceBreakdown = this.quotation.spaces.map((space: any) => ({
|
|
|
+ spaceName: space.name,
|
|
|
+ spaceId: space.productId || '',
|
|
|
+ amount: space.subtotal,
|
|
|
+ percentage: this.quotation.total > 0 ? Math.round((space.subtotal / this.quotation.total) * 100) : 0
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保存报价到项目
|
|
|
+ */
|
|
|
+ private async saveQuotationToProject(): Promise<void> {
|
|
|
+ if (!this.project) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const data = this.project.get('data') || {};
|
|
|
+ data.quotation = this.quotation;
|
|
|
+ this.project.set('data', data);
|
|
|
+ await this.project.save();
|
|
|
+
|
|
|
+ this.quotationChange.emit(this.quotation);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('保存报价失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============ UI交互方法 ============
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 展开所有产品
|
|
|
*/
|
|
|
expandAll() {
|
|
|
this.quotation.spaces.forEach((space: any) => {
|
|
|
- this.expandedSpaces.add(space.name);
|
|
|
+ this.expandedProducts.add(space.name);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 折叠所有空间
|
|
|
+ * 折叠所有产品
|
|
|
*/
|
|
|
collapseAll() {
|
|
|
- this.expandedSpaces.clear();
|
|
|
+ this.expandedProducts.clear();
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -119,19 +503,14 @@ export class QuotationEditorComponent implements OnChanges {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 计算空间小计
|
|
|
+ * 计算空间小计(保持兼容性)
|
|
|
*/
|
|
|
calculateSpaceSubtotal(space: any): number {
|
|
|
- let subtotal = 0;
|
|
|
- for (const processKey of Object.keys(space.processes)) {
|
|
|
- const process = space.processes[processKey];
|
|
|
- if (process.enabled) {
|
|
|
- subtotal += process.price * process.quantity;
|
|
|
- }
|
|
|
- }
|
|
|
- return subtotal;
|
|
|
+ return this.calculateProductSubtotal(space.processes);
|
|
|
}
|
|
|
|
|
|
+ // ============ 辅助方法 ============
|
|
|
+
|
|
|
/**
|
|
|
* 辅助方法:检查工序是否启用
|
|
|
*/
|
|
@@ -176,19 +555,322 @@ export class QuotationEditorComponent implements OnChanges {
|
|
|
return process?.quantity || 0;
|
|
|
}
|
|
|
|
|
|
+
|
|
|
/**
|
|
|
- * 辅助方法:获取工序单位
|
|
|
+ * 辅助方法:计算工序小计
|
|
|
*/
|
|
|
- getProcessUnit(space: any, processKey: string): string {
|
|
|
+ calculateProcessSubtotal(space: any, processKey: string): number {
|
|
|
const process = space.processes?.[processKey];
|
|
|
- return process?.unit || '';
|
|
|
+ return (process?.price || 0) * (process?.quantity || 0);
|
|
|
}
|
|
|
|
|
|
+ // ============ 产品管理方法 ============
|
|
|
+
|
|
|
/**
|
|
|
- * 辅助方法:计算工序小计
|
|
|
+ * 添加新产品
|
|
|
*/
|
|
|
- calculateProcessSubtotal(space: any, processKey: string): number {
|
|
|
- const process = space.processes?.[processKey];
|
|
|
- return (process?.price || 0) * (process?.quantity || 0);
|
|
|
+ async addProduct(productName?: string): Promise<void> {
|
|
|
+ if (!this.project) return;
|
|
|
+
|
|
|
+ const name = productName || prompt('请输入产品名称:');
|
|
|
+ if (!name) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const product = new Parse.Object('Product');
|
|
|
+ product.set('project', this.project.toPointer());
|
|
|
+ product.set('productName', name);
|
|
|
+ product.set('productType', this.inferProductType(name));
|
|
|
+
|
|
|
+ // 设置空间信息
|
|
|
+ product.set('space', {
|
|
|
+ spaceName: name,
|
|
|
+ area: 0,
|
|
|
+ dimensions: { length: 0, width: 0, height: 0 },
|
|
|
+ features: [],
|
|
|
+ constraints: [],
|
|
|
+ priority: 5,
|
|
|
+ complexity: 'medium'
|
|
|
+ });
|
|
|
+
|
|
|
+ // 设置报价信息
|
|
|
+ const basePrice = this.calculateBasePrice(name);
|
|
|
+ product.set('quotation', {
|
|
|
+ price: basePrice,
|
|
|
+ currency: 'CNY',
|
|
|
+ breakdown: this.calculatePriceBreakdown(basePrice),
|
|
|
+ status: 'draft',
|
|
|
+ validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
|
|
+ });
|
|
|
+
|
|
|
+ product.set('status', 'not_started');
|
|
|
+ await product.save();
|
|
|
+
|
|
|
+ // 重新加载产品列表
|
|
|
+ await this.loadProjectProducts();
|
|
|
+ await this.generateQuotationFromProducts();
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('添加产品失败:', error);
|
|
|
+ alert('添加失败,请重试');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 编辑产品名称
|
|
|
+ */
|
|
|
+ async editProduct(productId: string): Promise<void> {
|
|
|
+ const product = this.products.find(p => p.id === productId);
|
|
|
+ if (!product) return;
|
|
|
+
|
|
|
+ const newName = prompt('修改产品名称:', product.get('productName'));
|
|
|
+ if (!newName || newName === product.get('productName')) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ product.set('productName', newName);
|
|
|
+ product.set('productType', this.inferProductType(newName));
|
|
|
+ await product.save();
|
|
|
+
|
|
|
+ // 更新报价中的名称
|
|
|
+ const spaceData = this.quotation.spaces.find((s: any) => s.productId === productId);
|
|
|
+ if (spaceData) {
|
|
|
+ spaceData.name = newName;
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.saveQuotationToProject();
|
|
|
+ await this.loadProjectProducts();
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('更新产品失败:', error);
|
|
|
+ alert('更新失败,请重试');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 删除产品
|
|
|
+ */
|
|
|
+ async deleteProduct(productId: string): Promise<void> {
|
|
|
+ if (!confirm('确定要删除这个产品吗?相关数据将被清除。')) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const product = this.products.find(p => p.id === productId);
|
|
|
+ if (product) {
|
|
|
+ await product.destroy();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 从本地列表中移除
|
|
|
+ this.products = this.products.filter(p => p.id !== productId);
|
|
|
+
|
|
|
+ // 从报价中移除
|
|
|
+ this.quotation.spaces = this.quotation.spaces.filter((s: any) => s.productId !== productId);
|
|
|
+
|
|
|
+ // 重新计算
|
|
|
+ this.calculateTotal();
|
|
|
+ this.updateProductBreakdown();
|
|
|
+ await this.saveQuotationToProject();
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('删除产品失败:', error);
|
|
|
+ alert('删除失败,请重试');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取产品设计师
|
|
|
+ */
|
|
|
+ getProductDesigner(product: any): string {
|
|
|
+ const profile = product.get('profile');
|
|
|
+ return profile ? profile.get('name') : '未分配';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取产品状态
|
|
|
+ */
|
|
|
+ getProductStatus(product: any): string {
|
|
|
+ return product.get('status') || 'not_started';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取产品状态颜色
|
|
|
+ */
|
|
|
+ getProductStatusColor(status: string): string {
|
|
|
+ const colorMap: Record<string, string> = {
|
|
|
+ 'not_started': 'medium',
|
|
|
+ 'in_progress': 'warning',
|
|
|
+ 'awaiting_review': 'info',
|
|
|
+ 'completed': 'success',
|
|
|
+ 'blocked': 'danger',
|
|
|
+ 'delayed': 'danger'
|
|
|
+ };
|
|
|
+ return colorMap[status] || 'medium';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取产品状态文本
|
|
|
+ */
|
|
|
+ getProductStatusText(status: string): string {
|
|
|
+ const textMap: Record<string, string> = {
|
|
|
+ 'not_started': '未开始',
|
|
|
+ 'in_progress': '进行中',
|
|
|
+ 'awaiting_review': '待审核',
|
|
|
+ 'completed': '已完成',
|
|
|
+ 'blocked': '已阻塞',
|
|
|
+ 'delayed': '已延期'
|
|
|
+ };
|
|
|
+ return textMap[status] || status;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 保存报价
|
|
|
+ */
|
|
|
+ async saveQuotation(): Promise<void> {
|
|
|
+ if (!this.canEdit) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ await this.saveQuotationToProject();
|
|
|
+ alert('保存成功');
|
|
|
+ } catch (error) {
|
|
|
+ console.error('保存失败:', error);
|
|
|
+ alert('保存失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取产品图标
|
|
|
+ */
|
|
|
+ getProductIcon(productType: string): string {
|
|
|
+ const iconMap: Record<string, string> = {
|
|
|
+ 'living_room': 'living-room',
|
|
|
+ 'bedroom': 'bedroom',
|
|
|
+ 'kitchen': 'kitchen',
|
|
|
+ 'bathroom': 'bathroom',
|
|
|
+ 'dining_room': 'dining-room',
|
|
|
+ 'study': 'study',
|
|
|
+ 'balcony': 'balcony',
|
|
|
+ 'corridor': 'corridor',
|
|
|
+ 'storage': 'storage',
|
|
|
+ 'entrance': 'entrance',
|
|
|
+ 'other': 'room'
|
|
|
+ };
|
|
|
+ return iconMap[productType] || 'room';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 格式化价格显示
|
|
|
+ */
|
|
|
+ formatPrice(price: number): string {
|
|
|
+ return `¥${price.toFixed(2)}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 格式化百分比
|
|
|
+ */
|
|
|
+ formatPercentage(value: number): string {
|
|
|
+ return `${value}%`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============ 辅助方法用于简化模板 ============
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据空间名称获取产品类型
|
|
|
+ */
|
|
|
+ getProductTypeForSpace(spaceName: string): string {
|
|
|
+ const name = spaceName.toLowerCase();
|
|
|
+ if (name.includes('客厅') || name.includes('起居')) return 'living_room';
|
|
|
+ if (name.includes('卧室') || name.includes('主卧') || name.includes('次卧')) return 'bedroom';
|
|
|
+ if (name.includes('厨房')) return 'kitchen';
|
|
|
+ if (name.includes('卫生间') || name.includes('浴室')) return 'bathroom';
|
|
|
+ if (name.includes('餐厅')) return 'dining_room';
|
|
|
+ if (name.includes('书房') || name.includes('工作室')) return 'study';
|
|
|
+ if (name.includes('阳台')) return 'balcony';
|
|
|
+ if (name.includes('玄关') || name.includes('走廊')) return 'corridor';
|
|
|
+ return 'other';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据空间名称获取产品图标
|
|
|
+ */
|
|
|
+ getProductIconForSpace(spaceName: string): string {
|
|
|
+ const productType = this.getProductTypeForSpace(spaceName);
|
|
|
+ return this.getProductIcon(productType);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据空间ID获取状态颜色
|
|
|
+ */
|
|
|
+ getStatusColorForSpace(spaceId: string): string {
|
|
|
+ const product = this.products.find(p => p.id === spaceId);
|
|
|
+ if (product) {
|
|
|
+ return this.getProductStatusColor(product.get('status'));
|
|
|
+ }
|
|
|
+ return 'medium';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据空间ID获取状态文本
|
|
|
+ */
|
|
|
+ getStatusTextForSpace(spaceId: string): string {
|
|
|
+ const product = this.products.find(p => p.id === spaceId);
|
|
|
+ if (product) {
|
|
|
+ return this.getProductStatusText(product.get('status'));
|
|
|
+ }
|
|
|
+ return '未开始';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据空间ID获取设计师名称
|
|
|
+ */
|
|
|
+ getDesignerNameForSpace(spaceId: string): string {
|
|
|
+ const product = this.products.find(p => p.id === spaceId);
|
|
|
+ if (product) {
|
|
|
+ return this.getProductDesigner(product);
|
|
|
+ }
|
|
|
+ return '未分配';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据空间ID获取产品对象
|
|
|
+ */
|
|
|
+ getProductForSpace(productId: string): any {
|
|
|
+ return this.products.find(p => p.id === productId) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取工序单位
|
|
|
+ */
|
|
|
+ getProcessUnit(_space: any, processKey: string): string {
|
|
|
+ const units: { [key: string]: string } = {
|
|
|
+ 'modeling': '项',
|
|
|
+ 'softDecor': '项',
|
|
|
+ 'rendering': '张',
|
|
|
+ 'postProcess': '项'
|
|
|
+ };
|
|
|
+ return units[processKey] || '项';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 切换产品展开状态(重载以保持兼容性)
|
|
|
+ */
|
|
|
+ toggleProductExpand(spaceName: string): void {
|
|
|
+ if (this.expandedProducts.has(spaceName)) {
|
|
|
+ this.expandedProducts.delete(spaceName);
|
|
|
+ } else {
|
|
|
+ this.expandedProducts.add(spaceName);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 检查产品是否展开(重载以保持兼容性)
|
|
|
+ */
|
|
|
+ isProductExpanded(spaceName: string): boolean {
|
|
|
+ return this.expandedProducts.has(spaceName);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取空间占比
|
|
|
+ */
|
|
|
+ getSpacePercentage(spaceId: string): number {
|
|
|
+ if (!this.quotation.spaceBreakdown) return 0;
|
|
|
+ const breakdown = this.quotation.spaceBreakdown.find((b: any) => b.spaceId === spaceId);
|
|
|
+ return breakdown?.percentage || 0;
|
|
|
}
|
|
|
}
|