project-auto-case.service.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. import { Injectable } from '@angular/core';
  2. import { FmodeParse } from 'fmode-ng/parse';
  3. import { CaseService } from '../../../services/case.service';
  4. const Parse = FmodeParse.with('nova');
  5. /**
  6. * 项目自动创建案例服务
  7. * 负责处理项目完成后自动同步到案例库
  8. */
  9. @Injectable({
  10. providedIn: 'root'
  11. })
  12. export class ProjectAutoCaseService {
  13. private companyId = localStorage.getItem('company')!;
  14. constructor(private caseService: CaseService) {}
  15. /**
  16. * 更新测试项目到售后归档阶段并创建案例
  17. */
  18. async updateTestProjectToAftercare(): Promise<{ success: boolean; message: string; caseId?: string }> {
  19. try {
  20. console.log('🔄 开始更新测试项目到售后归档...');
  21. // 1. 查找"10.28 测试项目"
  22. const ProjectQuery = new Parse.Query('Project');
  23. ProjectQuery.equalTo('company', { __type: 'Pointer', className: 'Company', objectId: this.companyId });
  24. ProjectQuery.equalTo('title', '10.28 测试项目');
  25. ProjectQuery.notEqualTo('isDeleted', true);
  26. const testProject = await ProjectQuery.first();
  27. if (!testProject) {
  28. return { success: false, message: '未找到"10.28 测试项目"' };
  29. }
  30. console.log('✅ 找到测试项目:', testProject.id);
  31. // 2. 查询项目的所有Product(空间)
  32. const ProductQuery = new Parse.Query('Product');
  33. ProductQuery.equalTo('project', testProject.toPointer());
  34. ProductQuery.notEqualTo('isDeleted', true);
  35. ProductQuery.include('profile');
  36. const products = await ProductQuery.find();
  37. console.log(`📦 找到 ${products.length} 个空间:`, products.map(p => p.get('productName')));
  38. // 3. 检查是否存在重复空间,如果有则清理
  39. const uniqueProducts = await this.cleanDuplicateProducts(testProject.id, products);
  40. console.log(`✅ 清理后剩余 ${uniqueProducts.length} 个唯一空间`);
  41. // 4. 为每个空间填充测试数据并更新到"售后归档"阶段
  42. for (const product of uniqueProducts) {
  43. await this.fillProductTestData(product);
  44. }
  45. // 5. 更新Project表的currentStage和status
  46. testProject.set('currentStage', '售后归档');
  47. testProject.set('status', '已完成');
  48. testProject.set('data', {
  49. ...testProject.get('data'),
  50. completedAt: new Date(),
  51. completedBy: 'admin',
  52. autoCompleted: true
  53. });
  54. await testProject.save();
  55. console.log('✅ 项目阶段已更新为"售后归档",状态为"已完成"');
  56. // 6. 自动创建案例
  57. const caseResult = await this.createCaseFromProject(testProject, uniqueProducts);
  58. if (caseResult.success) {
  59. return {
  60. success: true,
  61. message: `项目已更新到售后归档阶段!\n✅ 自动创建案例: ${caseResult.caseId}\n📦 包含 ${uniqueProducts.length} 个空间`,
  62. caseId: caseResult.caseId
  63. };
  64. } else {
  65. return {
  66. success: false,
  67. message: `项目更新成功,但案例创建失败: ${caseResult.error}`
  68. };
  69. }
  70. } catch (error) {
  71. console.error('❌ 更新测试项目失败:', error);
  72. return {
  73. success: false,
  74. message: `更新失败: ${error instanceof Error ? error.message : '未知错误'}`
  75. };
  76. }
  77. }
  78. /**
  79. * 扫描已完成但尚未生成案例的项目并补齐(幂等)
  80. */
  81. async backfillMissingCases(limit: number = 10): Promise<{ created: number; scanned: number }> {
  82. const ProjectQuery = new Parse.Query('Project');
  83. ProjectQuery.equalTo('company', { __type: 'Pointer', className: 'Company', objectId: this.companyId });
  84. ProjectQuery.notEqualTo('isDeleted', true);
  85. ProjectQuery.containedIn('currentStage', ['尾款结算', '售后归档', 'aftercare']);
  86. ProjectQuery.doesNotExist('data.caseId');
  87. ProjectQuery.include('contact', 'assignee', 'department'); // 🔥 必须 include 关联对象
  88. ProjectQuery.limit(limit);
  89. const projects = await ProjectQuery.find();
  90. let created = 0;
  91. for (const p of projects) {
  92. try {
  93. // 仅当满足售后归档同步条件时才创建案例
  94. const eligible = await this.isProjectEligibleForCase(p);
  95. if (!eligible) {
  96. continue;
  97. }
  98. const ProductQuery = new Parse.Query('Product');
  99. ProductQuery.equalTo('project', p.toPointer());
  100. ProductQuery.notEqualTo('isDeleted', true);
  101. ProductQuery.include('profile');
  102. const products = await ProductQuery.find();
  103. const res = await this.createCaseFromProject(p, products);
  104. if (res.success) created++;
  105. } catch (e) {
  106. console.warn('⚠️ 补齐案例失败', p.id, p.get('title'), e);
  107. }
  108. }
  109. return { created, scanned: projects.length };
  110. }
  111. /**
  112. * 对外公开:为指定项目创建案例(不变更项目阶段)
  113. * 在项目已进入"售后归档/尾款结算"等完成阶段时调用
  114. */
  115. async createCaseForProject(projectId: string): Promise<{ success: boolean; caseId?: string; error?: string }> {
  116. try {
  117. const ProjectQuery = new Parse.Query('Project');
  118. ProjectQuery.equalTo('company', { __type: 'Pointer', className: 'Company', objectId: this.companyId });
  119. ProjectQuery.include('contact', 'assignee', 'department'); // 🔥 必须 include 关联对象
  120. const project = await ProjectQuery.get(projectId);
  121. // 若已存在关联的案例,直接返回
  122. const data = project.get('data') || {};
  123. if (data.caseId) {
  124. return { success: true, caseId: data.caseId };
  125. }
  126. // 条件校验:售后归档同步条件(评价+支付凭证+复盘),尾款部分支付也允许
  127. const eligible = await this.isProjectEligibleForCase(project);
  128. if (!eligible) {
  129. return { success: false, error: '项目未满足售后归档同步条件(需评价、支付凭证、复盘)' };
  130. }
  131. // 加载项目的空间(Product)
  132. const ProductQuery = new Parse.Query('Product');
  133. ProductQuery.equalTo('project', project.toPointer());
  134. ProductQuery.notEqualTo('isDeleted', true);
  135. ProductQuery.include('profile');
  136. const products = await ProductQuery.find();
  137. return await this.createCaseFromProject(project, products);
  138. } catch (error) {
  139. return { success: false, error: error instanceof Error ? error.message : '未知错误' };
  140. }
  141. }
  142. /**
  143. * 清理重复的Product(空间)
  144. * 保留每个空间名称的第一个,删除其他重复的
  145. */
  146. private async cleanDuplicateProducts(projectId: string, products: any[]): Promise<any[]> {
  147. const uniqueMap = new Map<string, any>();
  148. const duplicates: any[] = [];
  149. // 按创建时间排序(保留最早创建的)
  150. const sortedProducts = products.sort((a, b) => {
  151. const timeA = a.get('createdAt')?.getTime() || 0;
  152. const timeB = b.get('createdAt')?.getTime() || 0;
  153. return timeA - timeB;
  154. });
  155. for (const product of sortedProducts) {
  156. const productName = product.get('productName') || '未命名';
  157. if (!uniqueMap.has(productName)) {
  158. uniqueMap.set(productName, product);
  159. } else {
  160. duplicates.push(product);
  161. }
  162. }
  163. // 软删除重复的Product
  164. if (duplicates.length > 0) {
  165. console.log(`🗑️ 发现 ${duplicates.length} 个重复空间,将进行软删除:`, duplicates.map(p => p.get('productName')));
  166. for (const duplicate of duplicates) {
  167. duplicate.set('isDeleted', true);
  168. duplicate.set('data', {
  169. ...duplicate.get('data'),
  170. deletedAt: new Date(),
  171. deletedReason: '重复空间,自动清理'
  172. });
  173. await duplicate.save();
  174. console.log(`✅ 已删除重复空间: ${duplicate.get('productName')} (${duplicate.id})`);
  175. }
  176. }
  177. return Array.from(uniqueMap.values());
  178. }
  179. /**
  180. * 为Product填充测试数据
  181. */
  182. private async fillProductTestData(product: any): Promise<void> {
  183. const productName = product.get('productName') || '未命名空间';
  184. // 更新阶段和状态
  185. product.set('stage', '售后归档');
  186. product.set('status', '已完成');
  187. product.set('reviewStatus', '已通过');
  188. // 填充测试数据
  189. const testData = {
  190. ...product.get('data'),
  191. // 空间信息
  192. space: {
  193. area: this.getRandomArea(productName),
  194. style: ['现代简约', '北欧风', '新中式', '轻奢风'][Math.floor(Math.random() * 4)],
  195. color: ['暖色调', '冷色调', '中性色'][Math.floor(Math.random() * 3)],
  196. lighting: '充足'
  197. },
  198. // 需求信息
  199. requirements: {
  200. customerNeeds: `${productName}需要${['温馨舒适', '简约大气', '实用美观'][Math.floor(Math.random() * 3)]}的设计风格`,
  201. specialRequests: ['环保材料', '智能家居', '收纳优化'][Math.floor(Math.random() * 3)],
  202. budget: this.getRandomBudget(productName)
  203. },
  204. // 设计进度
  205. designProgress: {
  206. modeling: { completed: true, completedAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000) },
  207. rendering: { completed: true, completedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000) },
  208. postProduction: { completed: true, completedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) },
  209. review: { completed: true, completedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), passed: true }
  210. },
  211. // 完成信息
  212. completedAt: new Date(),
  213. completedBy: product.get('profile')?.id || 'test-designer',
  214. // 图片资源(模拟)
  215. images: this.generateMockImages(productName),
  216. // 客户评价
  217. customerReview: {
  218. rating: 5,
  219. comment: `${productName}的设计非常满意,设计师很专业!`,
  220. createdAt: new Date()
  221. }
  222. };
  223. product.set('data', testData);
  224. // 报价信息
  225. if (!product.get('quotation')) {
  226. product.set('quotation', {
  227. total: testData.requirements.budget,
  228. designFee: Math.round(testData.requirements.budget * 0.2),
  229. materialFee: Math.round(testData.requirements.budget * 0.5),
  230. constructionFee: Math.round(testData.requirements.budget * 0.3),
  231. status: 'approved'
  232. });
  233. }
  234. await product.save();
  235. console.log(`✅ ${productName} 测试数据填充完成`);
  236. }
  237. /**
  238. * 根据空间名称生成随机面积
  239. */
  240. private getRandomArea(spaceName: string): number {
  241. const areaMap: Record<string, [number, number]> = {
  242. '主卧': [15, 25],
  243. '书房': [8, 15],
  244. '儿童房': [10, 18],
  245. '卫生间': [4, 8],
  246. '厨房': [6, 12],
  247. '客厅': [25, 40],
  248. '餐厅': [12, 20]
  249. };
  250. const [min, max] = areaMap[spaceName] || [10, 20];
  251. return Math.floor(Math.random() * (max - min + 1)) + min;
  252. }
  253. /**
  254. * 根据空间名称生成随机预算
  255. */
  256. private getRandomBudget(spaceName: string): number {
  257. const area = this.getRandomArea(spaceName);
  258. const pricePerSqm = 800 + Math.floor(Math.random() * 400); // 800-1200/平米
  259. return Math.round(area * pricePerSqm / 100) * 100; // 四舍五入到百位
  260. }
  261. /**
  262. * 生成模拟图片URL(使用真实的家装设计图片)
  263. */
  264. private generateMockImages(spaceName: string): string[] {
  265. // 使用Unsplash的高质量家装设计图片
  266. const defaultInteriorImages = [
  267. 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=800&h=600&fit=crop', // 现代客厅
  268. 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=600&fit=crop', // 简约卧室
  269. 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=800&h=600&fit=crop', // 厨房设计
  270. 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800&h=600&fit=crop', // 餐厅空间
  271. 'https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=800&h=600&fit=crop', // 书房设计
  272. 'https://images.unsplash.com/photo-1600573472550-8090b5e0745e?w=800&h=600&fit=crop', // 浴室设计
  273. 'https://images.unsplash.com/photo-1600585154526-990dced4db0d?w=800&h=600&fit=crop', // 儿童房
  274. 'https://images.unsplash.com/photo-1600566752355-35792bedcfea?w=800&h=600&fit=crop' // 阳台设计
  275. ];
  276. // 根据空间名称选择合适的图片
  277. const spaceImageMap: Record<string, number[]> = {
  278. '客厅': [0, 1],
  279. '主卧': [1, 2],
  280. '书房': [4, 0],
  281. '儿童房': [6, 1],
  282. '卫生间': [5, 3],
  283. '厨房': [2, 3],
  284. '餐厅': [3, 0]
  285. };
  286. const indices = spaceImageMap[spaceName] || [0, 1];
  287. return [
  288. defaultInteriorImages[indices[0]],
  289. defaultInteriorImages[indices[1]],
  290. defaultInteriorImages[(indices[0] + 2) % defaultInteriorImages.length]
  291. ];
  292. }
  293. /**
  294. * 从Project和Products创建Case(完整版本)
  295. */
  296. private async createCaseFromProject(project: any, products: any[]): Promise<{ success: boolean; caseId?: string; error?: string }> {
  297. try {
  298. // 获取项目信息
  299. const contact = project.get('contact');
  300. const assignee = project.get('assignee');
  301. const department = project.get('department');
  302. const projectData = project.get('data') || {};
  303. // 收集所有空间的图片和交付文件
  304. const allImages: string[] = [];
  305. let totalPrice = 0;
  306. let totalArea = 0;
  307. // 默认家装图片(如果没有上传图片则使用)
  308. const defaultInteriorImages = [
  309. 'https://images.unsplash.com/photo-1600210492486-724fe5c67fb0?w=800&h=600&fit=crop', // 现代客厅
  310. 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=600&fit=crop', // 简约卧室
  311. 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=800&h=600&fit=crop', // 厨房设计
  312. 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?w=800&h=600&fit=crop', // 餐厅空间
  313. 'https://images.unsplash.com/photo-1600566753086-00f18fb6b3ea?w=800&h=600&fit=crop', // 书房设计
  314. 'https://images.unsplash.com/photo-1600573472550-8090b5e0745e?w=800&h=600&fit=crop', // 浴室设计
  315. 'https://images.unsplash.com/photo-1600585154526-990dced4db0d?w=800&h=600&fit=crop', // 儿童房
  316. 'https://images.unsplash.com/photo-1600566752355-35792bedcfea?w=800&h=600&fit=crop' // 阳台设计
  317. ];
  318. // 从Product(空间)收集数据
  319. const productsDetail: any[] = [];
  320. for (const product of products) {
  321. const productData = product.get('data') || {};
  322. if (productData.images && Array.isArray(productData.images)) {
  323. allImages.push(...productData.images);
  324. }
  325. const quotation = product.get('quotation');
  326. if (quotation && quotation.total) {
  327. totalPrice += quotation.total;
  328. }
  329. const spaceArea = productData.space?.area || 0;
  330. if (spaceArea > 0) {
  331. totalArea += spaceArea;
  332. }
  333. // 产品详细信息
  334. productsDetail.push({
  335. productId: product.id,
  336. productName: product.get('productName') || '未命名空间',
  337. category: productData.space?.type || '其他',
  338. brand: '',
  339. quantity: 1,
  340. unitPrice: quotation?.total || 0,
  341. totalPrice: quotation?.total || 0,
  342. spaceArea: spaceArea,
  343. specifications: {
  344. style: productData.space?.style || '现代简约',
  345. color: productData.space?.color || '',
  346. lighting: productData.space?.lighting || '',
  347. requirements: productData.requirements || {}
  348. }
  349. });
  350. }
  351. // 从ProjectFile加载交付文件图片
  352. try {
  353. const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
  354. const fileQuery = new Parse.Query('ProjectFile');
  355. fileQuery.equalTo('project', project.toPointer());
  356. fileQuery.equalTo('stage', 'delivery');
  357. fileQuery.include('attach');
  358. fileQuery.limit(100);
  359. const deliveryFiles = await fileQuery.find();
  360. const imagesDetail: any = {
  361. beforeRenovation: [],
  362. afterRenovation: [],
  363. videos: [],
  364. panoramas: []
  365. };
  366. for (const file of deliveryFiles) {
  367. const attach = file.get('attach');
  368. if (!attach) continue;
  369. const fileUrl = attach.get('url') || '';
  370. const fileName = attach.get('name') || attach.get('originalName') || '';
  371. const fileType = attach.get('mime') || attach.get('type') || '';
  372. const fileData = file.get('data') || {};
  373. const fileInfo = {
  374. attachmentId: attach.id || '',
  375. url: fileUrl,
  376. description: fileName,
  377. uploadDate: file.get('createdAt')?.toISOString() || new Date().toISOString(),
  378. spaceArea: fileData.productName || fileData.spaceName || ''
  379. };
  380. // 根据文件类型分类
  381. if (fileType.startsWith('image/')) {
  382. allImages.push(fileUrl);
  383. // 根据fileType判断是before/after
  384. const fileTypeStr = file.get('fileType') || '';
  385. if (fileTypeStr.includes('before')) {
  386. imagesDetail.beforeRenovation.push(fileInfo);
  387. } else {
  388. imagesDetail.afterRenovation.push(fileInfo);
  389. }
  390. } else if (fileType.startsWith('video/')) {
  391. imagesDetail.videos.push({
  392. ...fileInfo,
  393. duration: 0
  394. });
  395. }
  396. }
  397. // 保存图片详细信息到data
  398. projectData.imagesDetail = imagesDetail;
  399. } catch (e) {
  400. console.warn('⚠️ 加载交付文件失败(不影响案例创建):', e);
  401. }
  402. // 从项目data中提取标签
  403. const styleTags = projectData.quotation?.spaces?.map((s: any) =>
  404. s.name || ''
  405. ).filter(Boolean) || [];
  406. const defaultTags = ['全屋设计', '专业团队'];
  407. const finalTags = [...new Set([...styleTags, ...defaultTags])].slice(0, 5);
  408. // 如果没有上传图片,使用默认家装图片
  409. const finalImages = allImages.length > 0 ? allImages : defaultInteriorImages;
  410. const finalCoverImage = finalImages[0] || defaultInteriorImages[0];
  411. console.log(`📸 案例图片: ${allImages.length > 0 ? '使用上传的图片' : '使用默认家装图片'}, 共 ${finalImages.length} 张`);
  412. // 构建案例数据
  413. const caseData = {
  414. name: project.get('title') || '未命名案例',
  415. projectId: project.id,
  416. designerId: assignee?.id || '',
  417. teamId: department?.id || '',
  418. coverImage: finalCoverImage,
  419. images: finalImages.slice(0, 20), // 最多20张图片
  420. totalPrice: totalPrice || projectData.quotation?.total || 0,
  421. completionDate: projectData.deliveryCompletedAt || projectData.completedAt || new Date(),
  422. tag: finalTags,
  423. info: {
  424. area: totalArea || projectData.quotation?.spaces?.reduce((sum: number, s: any) => sum + (s.area || 0), 0) || 100,
  425. projectType: (project.get('projectType') || '家装') as '工装' | '家装',
  426. roomType: (projectData.roomType || '三居室') as '一居室' | '二居室' | '三居室' | '四居+',
  427. spaceType: (projectData.spaceType || project.get('spaceType') || '平层') as '平层' | '复式' | '别墅' | '自建房',
  428. renderingLevel: (project.get('renderType') === '360全景' ? '高端' : projectData.priceLevel === '三级' ? '高端' : projectData.priceLevel === '二级' ? '中端' : '低端') as '高端' | '中端' | '低端'
  429. },
  430. isPublished: true,
  431. publishedAt: new Date(),
  432. isExcellent: false,
  433. index: Math.floor(Date.now() / 1000), // 使用时间戳作为排序,新案例靠前
  434. customerReview: projectData.customerReview || `客户${(contact && typeof contact.get === 'function') ? contact.get('name') : (contact?.name || '')}对整体设计非常满意,设计师团队专业高效!`,
  435. data: {
  436. // 装修规格信息
  437. renovationSpec: projectData.renovationSpec || undefined,
  438. // 产品详细信息
  439. productsDetail,
  440. // 预算信息
  441. budget: {
  442. total: totalPrice,
  443. designFee: Math.round(totalPrice * 0.2),
  444. constructionFee: Math.round(totalPrice * 0.5),
  445. softDecorFee: Math.round(totalPrice * 0.3),
  446. actualCost: totalPrice,
  447. savingRate: 0
  448. },
  449. // 时间线
  450. timeline: {
  451. startDate: project.get('createdAt')?.toISOString() || new Date().toISOString(),
  452. completionDate: (projectData.deliveryCompletedAt || projectData.completedAt || new Date()).toISOString(),
  453. duration: Math.ceil((new Date().getTime() - (project.get('createdAt')?.getTime() || Date.now())) / (1000 * 60 * 60 * 24)),
  454. milestones: [
  455. { stage: '订单分配', date: project.get('createdAt')?.toISOString() || '', status: '已完成' },
  456. { stage: '确认需求', date: projectData.requirementsConfirmedAt?.toISOString() || '', status: '已完成' },
  457. { stage: '交付执行', date: projectData.deliveryStartedAt?.toISOString() || '', status: '已完成' },
  458. { stage: '售后归档', date: projectData.deliveryCompletedAt?.toISOString() || new Date().toISOString(), status: '已完成' }
  459. ].filter(m => m.date)
  460. },
  461. // 设计亮点
  462. highlights: projectData.highlights || [
  463. '空间布局合理,动线流畅',
  464. '自然采光充足,通风良好',
  465. '收纳设计巧妙,实用美观',
  466. '色彩搭配和谐,氛围温馨'
  467. ],
  468. // 设计特色
  469. features: projectData.features || [],
  470. // 设计挑战
  471. challenges: projectData.challenges || [],
  472. // 材料信息
  473. materials: projectData.materials || {},
  474. // 客户信息
  475. clientInfo: {
  476. familyMembers: (contact && typeof contact.get === 'function') ? (contact.get('data')?.familyMembers || 3) : 3,
  477. ageRange: (contact && typeof contact.get === 'function') ? (contact.get('data')?.ageRange || '30-40岁') : '30-40岁',
  478. lifestyle: (contact && typeof contact.get === 'function') ? (contact.get('data')?.lifestyle || '现代都市') : '现代都市',
  479. specialNeeds: (contact && typeof contact.get === 'function') ? (contact.get('data')?.specialNeeds || []) : [],
  480. satisfactionScore: projectData.customerReview?.rating || 5
  481. },
  482. // 施工团队信息
  483. constructionTeam: projectData.constructionTeam || {
  484. contractor: (department && typeof department.get === 'function') ? department.get('name') : (department?.name || '设计团队'),
  485. projectManager: (assignee && typeof assignee.get === 'function') ? assignee.get('name') : (assignee?.name || ''),
  486. supervisor: '',
  487. workers: products.length,
  488. subcontractors: []
  489. },
  490. // 质量验收
  491. qualityInspection: projectData.qualityInspection || undefined,
  492. // 图片详细信息
  493. imagesDetail: projectData.imagesDetail || {
  494. beforeRenovation: [],
  495. afterRenovation: finalImages.map((url, idx) => ({
  496. attachmentId: `img-${idx}`,
  497. url,
  498. description: allImages.length > 0 ? `效果图${idx + 1}` : `家装设计参考${idx + 1}`,
  499. uploadDate: new Date().toISOString(),
  500. spaceArea: products[idx % products.length]?.get('productName') || ''
  501. })),
  502. videos: [],
  503. panoramas: []
  504. }
  505. }
  506. };
  507. // 调用CaseService创建案例
  508. const newCase = await this.caseService.createCase(caseData);
  509. console.log('✅ 案例创建成功:', newCase.id, '项目:', project.get('title'));
  510. // 在Project的data中记录关联的案例ID
  511. project.set('data', {
  512. ...project.get('data'),
  513. caseId: newCase.id,
  514. caseCreatedAt: new Date()
  515. });
  516. await project.save();
  517. return { success: true, caseId: newCase.id };
  518. } catch (error) {
  519. console.error('❌ 创建案例失败:', error);
  520. return {
  521. success: false,
  522. error: error instanceof Error ? error.message : '未知错误'
  523. };
  524. }
  525. }
  526. /**
  527. * 判断项目是否满足同步到案例库的条件:
  528. * - 当前阶段为售后归档/aftercare/尾款结算 或 状态已归档
  529. * - 有客户评价(ProjectFeedback)
  530. * - 有支付凭证(ProjectPayment[type=final,status=paid] 或 ProjectFile(stage=aftercare,fileType=payment_voucher))
  531. * - 有项目复盘(project.data.retrospective)
  532. * - 尾款允许部分支付(只要存在任意尾款支付记录或凭证即可)
  533. */
  534. private async isProjectEligibleForCase(project: any): Promise<boolean> {
  535. try {
  536. const stage: string = project.get('currentStage') || '';
  537. const status: string = project.get('status') || '';
  538. const data = project.get('data') || {};
  539. const inAftercare = ['售后归档', 'aftercare', '尾款结算'].some(s => stage.includes(s)) || status === '已归档';
  540. if (!inAftercare) return false;
  541. // 评价:至少有一条 ProjectFeedback
  542. const fbQuery = new Parse.Query('ProjectFeedback');
  543. fbQuery.equalTo('project', project.toPointer());
  544. fbQuery.notEqualTo('isDeleted', true);
  545. const feedbackCount = await fbQuery.count();
  546. if (feedbackCount <= 0) return false;
  547. // 复盘:存在 data.retrospective 对象或标记
  548. const hasRetrospective = !!(data.retrospective && (data.retrospective.generated !== false));
  549. if (!hasRetrospective) return false;
  550. // 支付凭证:优先查 ProjectPayment(允许部分支付),再降级查 ProjectFile(payment_voucher)
  551. let hasVoucher = false;
  552. try {
  553. // 允许部分支付:存在任意记录即可;优先匹配已付款
  554. const paidQuery = new Parse.Query('ProjectPayment');
  555. paidQuery.equalTo('project', project.toPointer());
  556. paidQuery.equalTo('type', 'final');
  557. paidQuery.notEqualTo('isDeleted', true);
  558. paidQuery.equalTo('status', 'paid');
  559. const paidCount = await paidQuery.count();
  560. if (paidCount > 0) hasVoucher = true;
  561. if (!hasVoucher) {
  562. const anyQuery = new Parse.Query('ProjectPayment');
  563. anyQuery.equalTo('project', project.toPointer());
  564. anyQuery.equalTo('type', 'final');
  565. anyQuery.notEqualTo('isDeleted', true);
  566. const anyCount = await anyQuery.count();
  567. hasVoucher = anyCount > 0;
  568. }
  569. } catch (e) {
  570. // 忽略类不存在错误
  571. }
  572. if (!hasVoucher) {
  573. const fileQuery = new Parse.Query('ProjectFile');
  574. fileQuery.equalTo('project', project.toPointer());
  575. fileQuery.equalTo('stage', 'aftercare');
  576. fileQuery.equalTo('fileType', 'payment_voucher');
  577. const fileCount = await fileQuery.count();
  578. hasVoucher = fileCount > 0;
  579. }
  580. return hasVoucher;
  581. } catch (e) {
  582. console.warn('⚠️ 案例同步条件检测失败,默认不创建:', e);
  583. return false;
  584. }
  585. }
  586. }