CrmQuoteServiceImpl.java 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329
  1. package org.dromara.crm.service.impl;
  2. import cn.hutool.core.date.DateUtil;
  3. import cn.hutool.core.util.IdUtil;
  4. import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  5. import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  6. import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
  7. import lombok.RequiredArgsConstructor;
  8. import lombok.extern.slf4j.Slf4j;
  9. import org.dromara.common.core.exception.ServiceException;
  10. import org.dromara.common.core.utils.MapstructUtils;
  11. import org.dromara.common.core.utils.StringUtils;
  12. import org.dromara.common.mybatis.core.page.PageQuery;
  13. import org.dromara.common.mybatis.core.page.TableDataInfo;
  14. import org.dromara.common.satoken.utils.LoginHelper;
  15. import org.dromara.crm.domain.CrmQuote;
  16. import org.dromara.crm.domain.CrmContract;
  17. import org.dromara.crm.domain.bo.CrmContractBo;
  18. import org.dromara.crm.domain.bo.CrmContractProductBo;
  19. import org.dromara.crm.domain.bo.CrmQuoteBo;
  20. import org.dromara.crm.domain.bo.CrmQuoteProductBo;
  21. import org.dromara.crm.domain.vo.CrmQuoteProductVo;
  22. import org.dromara.crm.domain.vo.CrmQuoteVo;
  23. import org.dromara.crm.mapper.CrmContractMapper;
  24. import org.dromara.crm.mapper.CrmQuoteMapper;
  25. import org.dromara.crm.mapper.CrmCustomerMapper;
  26. import org.dromara.crm.domain.CrmCustomer;
  27. import org.dromara.crm.service.*;
  28. import org.dromara.crm.service.ICrmQuoteApprovalService;
  29. import org.dromara.crm.service.ICrmQuotePdfService;
  30. import org.dromara.crm.service.ICrmQuoteProductService;
  31. import org.dromara.crm.service.ICrmQuoteService;
  32. import org.dromara.system.service.ISysUserService;
  33. import org.dromara.system.domain.vo.SysUserVo;
  34. import org.springframework.stereotype.Service;
  35. import org.springframework.transaction.annotation.Transactional;
  36. import java.math.BigDecimal;
  37. import java.time.LocalDate;
  38. import java.time.LocalDateTime;
  39. import java.time.format.DateTimeFormatter;
  40. import java.util.Collection;
  41. import java.util.List;
  42. import java.util.Map;
  43. import java.util.Set;
  44. import java.util.stream.Collectors;
  45. /**
  46. * 报价单Service实现
  47. *
  48. * @author 系统管理员
  49. * @date 2025-01-03
  50. */
  51. @Slf4j
  52. @RequiredArgsConstructor
  53. @Service
  54. public class CrmQuoteServiceImpl implements ICrmQuoteService {
  55. private final CrmQuoteMapper baseMapper;
  56. private final ICrmQuoteProductService quoteProductService;
  57. private final ICrmQuoteApprovalService quoteApprovalService;
  58. private final ICrmQuotePdfService quotePdfService;
  59. private final ICrmQuoteShareLogService quoteShareLogService;
  60. private final ICrmContractService contractService;
  61. private final CrmContractMapper contractMapper;
  62. private final ICrmOpportunityService opportunityService;
  63. private final ICrmContactService contactService;
  64. private final ISysUserService userService;
  65. private final CrmCustomerMapper customerMapper;
  66. private final ICrmSupplierInfoService supplierInfoService;
  67. private final ICrmProductService productService;
  68. /**
  69. * 构建查询条件
  70. */
  71. private LambdaQueryWrapper<CrmQuote> buildQueryWrapper(CrmQuoteBo bo) {
  72. LambdaQueryWrapper<CrmQuote> lqw = Wrappers.lambdaQuery();
  73. lqw.like(StringUtils.isNotBlank(bo.getQuoteNo()), CrmQuote::getQuoteNo, bo.getQuoteNo());
  74. // 快速检索关键字(报价主题/客户名称 OR 模糊)
  75. if (StringUtils.isNotBlank(bo.getKeyword())) {
  76. String keyword = bo.getKeyword();
  77. lqw.and(wrapper -> wrapper
  78. .like(CrmQuote::getQuoteTitle, keyword)
  79. .or()
  80. .like(CrmQuote::getCustomerName, keyword));
  81. } else {
  82. // 高级筛选:报价主题和客户名称
  83. // 若两个字段都传入且值相同,按 OR 处理;否则各自模糊匹配(AND)
  84. if (StringUtils.isNotBlank(bo.getQuoteTitle()) && StringUtils.isNotBlank(bo.getCustomerName())
  85. && bo.getQuoteTitle().equals(bo.getCustomerName())) {
  86. String keyword = bo.getQuoteTitle();
  87. lqw.and(wrapper -> wrapper
  88. .like(CrmQuote::getQuoteTitle, keyword)
  89. .or()
  90. .like(CrmQuote::getCustomerName, keyword));
  91. } else {
  92. lqw.like(StringUtils.isNotBlank(bo.getQuoteTitle()), CrmQuote::getQuoteTitle, bo.getQuoteTitle());
  93. lqw.like(StringUtils.isNotBlank(bo.getCustomerName()), CrmQuote::getCustomerName, bo.getCustomerName());
  94. }
  95. }
  96. lqw.eq(bo.getCustomerId() != null, CrmQuote::getCustomerId, bo.getCustomerId());
  97. lqw.eq(bo.getOpportunityId() != null, CrmQuote::getOpportunityId, bo.getOpportunityId());
  98. lqw.eq(StringUtils.isNotBlank(bo.getStatus()), CrmQuote::getStatus, bo.getStatus());
  99. // 排除指定状态(如默认排除已作废的记录)
  100. lqw.ne(StringUtils.isNotBlank(bo.getExcludeStatus()), CrmQuote::getStatus, bo.getExcludeStatus());
  101. lqw.eq(StringUtils.isNotBlank(bo.getQuoteType()), CrmQuote::getQuoteType, bo.getQuoteType());
  102. lqw.eq(bo.getOwnerUserId() != null, CrmQuote::getOwnerUserId, bo.getOwnerUserId());
  103. lqw.eq(bo.getIsLatest() != null, CrmQuote::getIsLatest, bo.getIsLatest());
  104. lqw.ge(bo.getParams().get("minAmount") != null, CrmQuote::getFinalAmount, bo.getParams().get("minAmount"));
  105. lqw.le(bo.getParams().get("maxAmount") != null, CrmQuote::getFinalAmount, bo.getParams().get("maxAmount"));
  106. // 报价日期范围查询
  107. if (bo.getQuoteDateBegin() != null && !bo.getQuoteDateBegin().isEmpty()) {
  108. lqw.ge(CrmQuote::getQuoteDate, LocalDate.parse(bo.getQuoteDateBegin()));
  109. }
  110. if (bo.getQuoteDateEnd() != null && !bo.getQuoteDateEnd().isEmpty()) {
  111. lqw.le(CrmQuote::getQuoteDate, LocalDate.parse(bo.getQuoteDateEnd()));
  112. }
  113. lqw.between(bo.getParams().get("beginTime") != null && bo.getParams().get("endTime") != null,
  114. CrmQuote::getCreateTime, bo.getParams().get("beginTime"), bo.getParams().get("endTime"));
  115. // 查询某个用户已审批过的报价单
  116. if (bo.getApprovedByUserId() != null) {
  117. lqw.inSql(CrmQuote::getId,
  118. "SELECT DISTINCT quote_id FROM crm_quote_approval " +
  119. "WHERE approver_id = " + bo.getApprovedByUserId() +
  120. " AND approval_result IN ('approved', 'rejected') " +
  121. " AND del_flag = 0");
  122. }
  123. lqw.orderByDesc(CrmQuote::getCreateTime);
  124. return lqw;
  125. }
  126. @Override
  127. public TableDataInfo<CrmQuoteVo> queryPageList(CrmQuoteBo bo, PageQuery pageQuery) {
  128. LambdaQueryWrapper<CrmQuote> lqw = buildQueryWrapper(bo);
  129. Page<CrmQuoteVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
  130. // 填充canApprove字段(当前用户是否可以审批)
  131. Long currentUserId = LoginHelper.getUserId();
  132. for (CrmQuoteVo vo : result.getRecords()) {
  133. // 只有待审批状态的报价单才需要检查
  134. if ("submitted".equals(vo.getStatus()) && currentUserId != null) {
  135. vo.setCanApprove(quoteApprovalService.canUserApprove(vo.getId(), currentUserId));
  136. } else {
  137. vo.setCanApprove(false);
  138. }
  139. }
  140. fillContractLinkedCount(result.getRecords());
  141. return TableDataInfo.build(result);
  142. }
  143. @Override
  144. public List<CrmQuoteVo> queryList(CrmQuoteBo bo) {
  145. LambdaQueryWrapper<CrmQuote> lqw = buildQueryWrapper(bo);
  146. List<CrmQuoteVo> list = baseMapper.selectVoList(lqw);
  147. // 填充canApprove字段(当前用户是否可以审批)
  148. Long currentUserId = LoginHelper.getUserId();
  149. for (CrmQuoteVo vo : list) {
  150. // 只有待审批状态的报价单才需要检查
  151. if ("submitted".equals(vo.getStatus()) && currentUserId != null) {
  152. vo.setCanApprove(quoteApprovalService.canUserApprove(vo.getId(), currentUserId));
  153. } else {
  154. vo.setCanApprove(false);
  155. }
  156. }
  157. fillContractLinkedCount(list);
  158. return list;
  159. }
  160. @Override
  161. public CrmQuoteVo queryById(Long id) {
  162. CrmQuoteVo vo = baseMapper.selectVoById(id);
  163. if (vo != null) {
  164. // 查询产品列表
  165. vo.setProductList(quoteProductService.queryListByQuoteId(id));
  166. // 填充商机名称
  167. if (vo.getOpportunityId() != null) {
  168. try {
  169. var opportunityVo = opportunityService.queryById(vo.getOpportunityId());
  170. if (opportunityVo != null) {
  171. vo.setOpportunityName(opportunityVo.getOpportunityName());
  172. }
  173. } catch (Exception e) {
  174. log.warn("查询商机名称失败, opportunityId: {}", vo.getOpportunityId(), e);
  175. }
  176. }
  177. // 自动补全联系人手机和邮箱(如果为空但有联系人ID)
  178. if (vo.getContactId() != null &&
  179. (StringUtils.isBlank(vo.getContactMobile()) || StringUtils.isBlank(vo.getContactEmail()))) {
  180. try {
  181. var contact = contactService.queryById(vo.getContactId());
  182. if (contact != null) {
  183. if (StringUtils.isBlank(vo.getContactMobile())) {
  184. vo.setContactMobile(contact.getMobile());
  185. }
  186. if (StringUtils.isBlank(vo.getContactEmail())) {
  187. vo.setContactEmail(contact.getEmail());
  188. }
  189. }
  190. } catch (Exception e) {
  191. log.warn("查询联系人信息失败, contactId: {}", vo.getContactId(), e);
  192. }
  193. }
  194. // 填充负责人手机号
  195. if (vo.getOwnerUserId() != null && StringUtils.isBlank(vo.getOwnerUserMobile())) {
  196. try {
  197. SysUserVo user = userService.selectUserById(vo.getOwnerUserId());
  198. if (user != null && StringUtils.isNotBlank(user.getPhonenumber())) {
  199. vo.setOwnerUserMobile(user.getPhonenumber());
  200. }
  201. } catch (Exception e) {
  202. log.warn("查询负责人手机号失败, ownerUserId: {}", vo.getOwnerUserId(), e);
  203. }
  204. }
  205. // 填充报价主体名称
  206. if (vo.getSupplierId() != null && StringUtils.isBlank(vo.getSupplierName())) {
  207. try {
  208. var supplierInfo = supplierInfoService.queryById(vo.getSupplierId());
  209. if (supplierInfo != null) {
  210. vo.setSupplierName(supplierInfo.getSupplierName());
  211. }
  212. } catch (Exception e) {
  213. log.warn("查询报价主体信息失败, supplierId: {}", vo.getSupplierId(), e);
  214. }
  215. }
  216. }
  217. return vo;
  218. }
  219. @Override
  220. @Transactional(rollbackFor = Exception.class)
  221. public Boolean insertByBo(CrmQuoteBo bo) {
  222. CrmQuote entity = MapstructUtils.convert(bo, CrmQuote.class);
  223. // 查询客户信息
  224. CrmCustomer customer = null;
  225. if (entity.getCustomerId() != null) {
  226. customer = customerMapper.selectById(entity.getCustomerId());
  227. }
  228. // 查询供方信息
  229. String supplierShortName = "山峡";
  230. if (entity.getSupplierId() != null) {
  231. try {
  232. var supplierInfo = supplierInfoService.queryById(entity.getSupplierId());
  233. if (supplierInfo != null && StringUtils.isNotBlank(supplierInfo.getShortName())) {
  234. supplierShortName = supplierInfo.getShortName();
  235. } else if (supplierInfo != null && StringUtils.isNotBlank(supplierInfo.getSupplierName())) {
  236. supplierShortName = supplierInfo.getSupplierName();
  237. }
  238. } catch (Exception e) {
  239. log.warn("查询供方信息失败,使用默认简称,供方ID: {}", entity.getSupplierId(), e);
  240. }
  241. }
  242. // 设置默认值(需要在生成编号之前设置报价日期)
  243. if (entity.getVersion() == null) {
  244. entity.setVersion("V1.0");
  245. }
  246. if (entity.getParentId() == null) {
  247. entity.setParentId(0L);
  248. }
  249. if (entity.getIsLatest() == null) {
  250. entity.setIsLatest(1);
  251. }
  252. if (entity.getStatus() == null) {
  253. entity.setStatus("draft");
  254. }
  255. if (StringUtils.isBlank(entity.getQuoteType())) {
  256. entity.setQuoteType("sales");
  257. }
  258. if (entity.getValidityDays() == null) {
  259. entity.setValidityDays(30);
  260. }
  261. if (entity.getQuoteDate() == null) {
  262. entity.setQuoteDate(LocalDate.now());
  263. }
  264. // 生成报价单编号(根据供方编码和报价日期)
  265. // 如果报价单号为空或包含预览标记(***),则重新生成
  266. if (StringUtils.isBlank(entity.getQuoteNo()) ||
  267. (StringUtils.isNotBlank(entity.getQuoteNo()) && entity.getQuoteNo().contains("***"))) {
  268. entity.setQuoteNo(generateQuoteNo(entity.getSupplierId(), entity.getQuoteDate()));
  269. }
  270. // 自动生成报价主题
  271. String quoteNo = entity.getQuoteNo();
  272. String customerShortName = customer != null && StringUtils.isNotBlank(customer.getShortName())
  273. ? customer.getShortName()
  274. : "";
  275. if (StringUtils.isBlank(entity.getQuoteTitle())) {
  276. // 用户未填写报价主题,根据报价类型自动生成
  277. // 销售:供方简称-客户简称(报价单编号)
  278. // 采购:客户简称-供方简称(报价单编号)
  279. String quoteTitle;
  280. if ("purchase".equals(entity.getQuoteType())) {
  281. // 采购:客户简称-供方简称(报价单编号)
  282. quoteTitle = customerShortName + "-" + supplierShortName + "(" + quoteNo + ")";
  283. } else {
  284. // 销售:供方简称-客户简称(报价单编号)
  285. quoteTitle = supplierShortName + "-" + customerShortName + "(" + quoteNo + ")";
  286. }
  287. entity.setQuoteTitle(quoteTitle);
  288. }
  289. if (entity.getOwnerUserId() == null) {
  290. entity.setOwnerUserId(LoginHelper.getUserId());
  291. entity.setOwnerUserName(LoginHelper.getNickname());
  292. }
  293. // 填充联系人手机和邮箱
  294. if (entity.getContactId() != null) {
  295. try {
  296. var contact = contactService.queryById(entity.getContactId());
  297. if (contact != null) {
  298. entity.setContactMobile(contact.getMobile());
  299. entity.setContactEmail(contact.getEmail());
  300. }
  301. } catch (Exception e) {
  302. log.warn("查询联系人信息失败, contactId: {}", entity.getContactId(), e);
  303. }
  304. }
  305. // 填充报价主体名称
  306. if (entity.getSupplierId() != null && StringUtils.isBlank(entity.getSupplierName())) {
  307. try {
  308. var supplierInfo = supplierInfoService.queryById(entity.getSupplierId());
  309. if (supplierInfo != null) {
  310. entity.setSupplierName(supplierInfo.getSupplierName());
  311. }
  312. } catch (Exception e) {
  313. log.warn("查询报价主体信息失败, supplierId: {}", entity.getSupplierId(), e);
  314. }
  315. }
  316. // 计算到期日期
  317. if (entity.getValidityDays() != null && entity.getQuoteDate() != null) {
  318. entity.setExpireDate(entity.getQuoteDate().plusDays(entity.getValidityDays()));
  319. }
  320. // 计算金额
  321. resolveQuoteProductIds(bo.getProducts());
  322. calculateAmount(entity, bo.getProducts());
  323. // 保存报价单
  324. boolean flag = baseMapper.insert(entity) > 0;
  325. if (flag) {
  326. bo.setId(entity.getId());
  327. // 保存产品明细
  328. if (bo.getProducts() != null && !bo.getProducts().isEmpty()) {
  329. quoteProductService.batchInsert(entity.getId(), bo.getProducts());
  330. }
  331. }
  332. return flag;
  333. }
  334. @Override
  335. @Transactional(rollbackFor = Exception.class)
  336. public Boolean updateByBo(CrmQuoteBo bo) {
  337. CrmQuote entity = MapstructUtils.convert(bo, CrmQuote.class);
  338. // 只有草稿状态才能修改
  339. CrmQuote oldEntity = baseMapper.selectById(bo.getId());
  340. if (oldEntity == null) {
  341. throw new ServiceException("报价单不存在");
  342. }
  343. if (!"draft".equals(oldEntity.getStatus())) {
  344. throw new ServiceException("只有草稿状态的报价单才能修改");
  345. }
  346. if (StringUtils.isBlank(entity.getQuoteType())) {
  347. entity.setQuoteType(StringUtils.isNotBlank(oldEntity.getQuoteType()) ? oldEntity.getQuoteType() : "sales");
  348. }
  349. // 填充联系人手机和邮箱
  350. if (entity.getContactId() != null) {
  351. try {
  352. var contact = contactService.queryById(entity.getContactId());
  353. if (contact != null) {
  354. entity.setContactMobile(contact.getMobile());
  355. entity.setContactEmail(contact.getEmail());
  356. }
  357. } catch (Exception e) {
  358. log.warn("查询联系人信息失败, contactId: {}", entity.getContactId(), e);
  359. }
  360. }
  361. // 检查客户、供方或报价日期是否发生变化
  362. boolean customerChanged = !java.util.Objects.equals(oldEntity.getCustomerId(), entity.getCustomerId());
  363. boolean supplierChanged = !java.util.Objects.equals(oldEntity.getSupplierId(), entity.getSupplierId());
  364. boolean quoteDateChanged = !java.util.Objects.equals(oldEntity.getQuoteDate(), entity.getQuoteDate());
  365. // 查询客户信息
  366. CrmCustomer customer = null;
  367. if (entity.getCustomerId() != null) {
  368. customer = customerMapper.selectById(entity.getCustomerId());
  369. }
  370. // 查询供方信息
  371. String supplierShortName = "山峡";
  372. if (entity.getSupplierId() != null) {
  373. try {
  374. var supplierInfo = supplierInfoService.queryById(entity.getSupplierId());
  375. if (supplierInfo != null) {
  376. entity.setSupplierName(supplierInfo.getSupplierName());
  377. if (StringUtils.isNotBlank(supplierInfo.getShortName())) {
  378. supplierShortName = supplierInfo.getShortName();
  379. } else if (StringUtils.isNotBlank(supplierInfo.getSupplierName())) {
  380. supplierShortName = supplierInfo.getSupplierName();
  381. }
  382. }
  383. } catch (Exception e) {
  384. log.warn("查询报价主体信息失败, supplierId: {}", entity.getSupplierId(), e);
  385. }
  386. }
  387. // 如果报价日期发生变化,需要重新生成编号(根据新的报价日期生成)
  388. if (quoteDateChanged && entity.getQuoteDate() != null) {
  389. entity.setQuoteNo(generateQuoteNo(entity.getSupplierId(), entity.getQuoteDate()));
  390. // 报价单号变化了,需要更新报价主题
  391. String customerShortName = "";
  392. if (customer != null && StringUtils.isNotBlank(customer.getShortName())) {
  393. customerShortName = customer.getShortName();
  394. } else if (entity.getCustomerName() != null) {
  395. customerShortName = entity.getCustomerName();
  396. } else if (oldEntity.getCustomerName() != null) {
  397. customerShortName = oldEntity.getCustomerName();
  398. }
  399. String currentQuoteNo = entity.getQuoteNo();
  400. // 根据报价类型生成报价主题
  401. // 销售:供方简称-客户简称(报价单编号)
  402. // 采购:客户简称-供方简称(报价单编号)
  403. String quoteTitle;
  404. if ("purchase".equals(entity.getQuoteType())) {
  405. // 采购:客户简称-供方简称(报价单编号)
  406. quoteTitle = customerShortName + "-" + supplierShortName + "(" + currentQuoteNo + ")";
  407. } else {
  408. // 销售:供方简称-客户简称(报价单编号)
  409. quoteTitle = supplierShortName + "-" + customerShortName + "(" + currentQuoteNo + ")";
  410. }
  411. entity.setQuoteTitle(quoteTitle);
  412. }
  413. // 如果客户或供方发生变化,更新报价主题和报价单号(只更新编码和简称,不改变日期和序列号)
  414. else if (customerChanged || supplierChanged) {
  415. String quoteNo = oldEntity.getQuoteNo(); // 保持原报价单号不变
  416. // 如果供方发生变化,只更新报价单号中的供方编码部分,保持日期和序列号不变
  417. if (supplierChanged && StringUtils.isNotBlank(quoteNo)) {
  418. // 报价单号格式:{供方编码}DD-YYYYMMDD001
  419. // 提取日期和序列号部分(DD-YYYYMMDD001)
  420. String dateAndSeq = null;
  421. if (quoteNo.contains("DD-") && quoteNo.length() > 3) {
  422. int ddIndex = quoteNo.indexOf("DD-");
  423. if (ddIndex >= 0 && ddIndex + 3 < quoteNo.length()) {
  424. dateAndSeq = quoteNo.substring(ddIndex);
  425. }
  426. }
  427. // 获取新的供方编码
  428. String newSupplierCode = "SX";
  429. if (entity.getSupplierId() != null) {
  430. try {
  431. var supplierInfo = supplierInfoService.queryById(entity.getSupplierId());
  432. if (supplierInfo != null && StringUtils.isNotBlank(supplierInfo.getSupplierCode())) {
  433. newSupplierCode = supplierInfo.getSupplierCode();
  434. }
  435. } catch (Exception e) {
  436. log.warn("查询供方信息失败,使用默认编码,供方ID: {}", entity.getSupplierId(), e);
  437. }
  438. }
  439. // 如果成功提取了日期和序列号,则用新编码替换
  440. if (dateAndSeq != null) {
  441. quoteNo = newSupplierCode + dateAndSeq;
  442. entity.setQuoteNo(quoteNo);
  443. }
  444. // 如果提取失败,保持原报价单号不变
  445. }
  446. // 更新报价主题(只更新简称部分,报价单号使用当前数据的报价单号,确保与entity.getQuoteNo()保持一致)
  447. String customerShortName = customer != null && StringUtils.isNotBlank(customer.getShortName())
  448. ? customer.getShortName()
  449. : "";
  450. // 使用entity.getQuoteNo()确保报价主题中的报价单号与当前数据的报价单号保持一致
  451. // 如果供方变化且成功更新了报价单号,entity.getQuoteNo()就是更新后的值
  452. // 如果供方没变化或提取失败,entity.getQuoteNo()就是原值
  453. String currentQuoteNo = entity.getQuoteNo() != null ? entity.getQuoteNo() : oldEntity.getQuoteNo();
  454. // 根据报价类型生成报价主题
  455. // 销售:供方简称-客户简称(报价单编号)
  456. // 采购:客户简称-供方简称(报价单编号)
  457. String quoteTitle;
  458. if ("purchase".equals(entity.getQuoteType())) {
  459. // 采购:客户简称-供方简称(报价单编号)
  460. quoteTitle = customerShortName + "-" + supplierShortName + "(" + currentQuoteNo + ")";
  461. } else {
  462. // 销售:供方简称-客户简称(报价单编号)
  463. quoteTitle = supplierShortName + "-" + customerShortName + "(" + currentQuoteNo + ")";
  464. }
  465. entity.setQuoteTitle(quoteTitle);
  466. }
  467. // 计算到期日期
  468. if (entity.getValidityDays() != null && entity.getQuoteDate() != null) {
  469. entity.setExpireDate(entity.getQuoteDate().plusDays(entity.getValidityDays()));
  470. }
  471. // 计算金额
  472. resolveQuoteProductIds(bo.getProducts());
  473. calculateAmount(entity, bo.getProducts());
  474. boolean flag = baseMapper.updateById(entity) > 0;
  475. if (flag && bo.getProducts() != null) {
  476. // 删除旧产品明细
  477. quoteProductService.deleteByQuoteId(bo.getId());
  478. // 插入新产品明细
  479. quoteProductService.batchInsert(bo.getId(), bo.getProducts());
  480. }
  481. return flag;
  482. }
  483. @Override
  484. @Transactional(rollbackFor = Exception.class)
  485. public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
  486. if (isValid) {
  487. // 校验是否可以删除
  488. for (Long id : ids) {
  489. CrmQuote entity = baseMapper.selectById(id);
  490. if (entity == null) {
  491. continue;
  492. }
  493. if (!"draft".equals(entity.getStatus())) {
  494. throw new ServiceException("只能删除草稿状态的报价单");
  495. }
  496. if (entity.getContractId() != null) {
  497. throw new ServiceException("已转合同的报价单不能删除");
  498. }
  499. }
  500. }
  501. return baseMapper.deleteByIds(ids) > 0;
  502. }
  503. @Override
  504. @Transactional(rollbackFor = Exception.class)
  505. public Boolean submitQuote(Long id, List<Long> approverIds, String remark) {
  506. CrmQuote entity = baseMapper.selectById(id);
  507. if (entity == null) {
  508. throw new ServiceException("报价单不存在");
  509. }
  510. if (!"draft".equals(entity.getStatus())) {
  511. throw new ServiceException("只有草稿状态的报价单才能提交");
  512. }
  513. // 更新状态
  514. entity.setStatus("submitted");
  515. baseMapper.updateById(entity);
  516. // 创建审批记录
  517. if (approverIds != null && !approverIds.isEmpty()) {
  518. quoteApprovalService.createApprovalRecords(id, approverIds);
  519. }
  520. // 自动生成PDF
  521. try {
  522. String pdfUrl = quotePdfService.generateQuotePdf(id);
  523. entity.setPdfUrl(pdfUrl);
  524. entity.setPdfGenerated(1);
  525. baseMapper.updateById(entity);
  526. } catch (Exception e) {
  527. log.warn("报价单提交时自动生成PDF失败: quoteId={}", id, e);
  528. }
  529. return true;
  530. }
  531. @Override
  532. public Boolean saveTemplate(Long id, String templateContent) {
  533. // 检查报价单是否存在
  534. CrmQuote entity = baseMapper.selectById(id);
  535. if (entity == null) {
  536. throw new ServiceException("报价单不存在");
  537. }
  538. log.info("========== 开始保存报价单模板内容 ==========");
  539. log.info("quoteId={}", id);
  540. log.info("contentLength={}", templateContent != null ? templateContent.length() : 0);
  541. log.info("content前200字符={}", templateContent != null && templateContent.length() > 200
  542. ? templateContent.substring(0, 200)
  543. : templateContent);
  544. log.info("包含HTML标签={}", templateContent != null && templateContent.contains("<"));
  545. // 使用 LambdaUpdateWrapper 只更新 template_content 字段,避免乐观锁问题
  546. int result = baseMapper.update(null,
  547. Wrappers.<CrmQuote>lambdaUpdate()
  548. .eq(CrmQuote::getId, id)
  549. .set(CrmQuote::getTemplateContent, templateContent));
  550. log.info("保存报价单模板内容完成: quoteId={}, success={}, affectedRows={}",
  551. id, result > 0, result);
  552. // 验证保存结果
  553. CrmQuote savedEntity = baseMapper.selectById(id);
  554. if (savedEntity != null && savedEntity.getTemplateContent() != null) {
  555. log.info("验证保存结果: 数据库中的内容长度={}", savedEntity.getTemplateContent().length());
  556. log.info("验证保存结果: 数据库中的内容前200字符={}",
  557. savedEntity.getTemplateContent().length() > 200
  558. ? savedEntity.getTemplateContent().substring(0, 200)
  559. : savedEntity.getTemplateContent());
  560. } else {
  561. log.warn("验证保存结果: 数据库中未找到模板内容!");
  562. }
  563. return result > 0;
  564. }
  565. @Override
  566. public String getTemplate(Long id) {
  567. log.info("========== 开始获取报价单模板内容 ==========");
  568. log.info("quoteId={}", id);
  569. // 直接查询,确保获取最新数据
  570. CrmQuote entity = baseMapper.selectById(id);
  571. if (entity == null) {
  572. throw new ServiceException("报价单不存在");
  573. }
  574. String templateContent = entity.getTemplateContent();
  575. log.info("获取报价单模板内容完成:");
  576. log.info("- hasTemplate={}", templateContent != null);
  577. log.info("- contentLength={}", templateContent != null ? templateContent.length() : 0);
  578. log.info("- content前200字符={}", templateContent != null && templateContent.length() > 200
  579. ? templateContent.substring(0, 200)
  580. : templateContent);
  581. log.info("- 包含HTML标签={}", templateContent != null && templateContent.contains("<"));
  582. return templateContent;
  583. }
  584. @Override
  585. public String generatePdf(Long id) {
  586. CrmQuote entity = baseMapper.selectById(id);
  587. if (entity == null) {
  588. throw new ServiceException("报价单不存在");
  589. }
  590. // 调用PDF生成服务
  591. String pdfUrl = quotePdfService.generateQuotePdf(id);
  592. // 更新PDF信息
  593. entity.setPdfUrl(pdfUrl);
  594. entity.setPdfGenerated(1);
  595. baseMapper.updateById(entity);
  596. return pdfUrl;
  597. }
  598. @Override
  599. public String generateShare(Long id, Integer validityDays, String password) {
  600. CrmQuote entity = baseMapper.selectById(id);
  601. if (entity == null) {
  602. throw new ServiceException("报价单不存在");
  603. }
  604. // 生成分享码(8位随机码)
  605. String shareCode = IdUtil.fastSimpleUUID().substring(0, 8);
  606. // 计算分享链接过期时间
  607. LocalDateTime shareExpireTime = null;
  608. if (validityDays != null && validityDays > 0) {
  609. shareExpireTime = LocalDateTime.now().plusDays(validityDays);
  610. }
  611. // 生成分享链接
  612. // TODO: 这里应该从配置文件读取域名
  613. String shareLink = "http://localhost:8080/share/" + shareCode;
  614. // 更新分享信息
  615. entity.setShareCode(shareCode);
  616. entity.setShareLink(shareLink);
  617. entity.setSharePassword(password); // 设置密码(可为空)
  618. entity.setShareExpireTime(shareExpireTime);
  619. baseMapper.updateById(entity);
  620. log.info("生成分享链接成功: quoteId={}, shareCode={}, hasPassword={}, expireTime={}",
  621. id, shareCode, (password != null && !password.isEmpty()), shareExpireTime);
  622. return shareLink;
  623. }
  624. @Override
  625. public CrmQuoteVo viewByShareCode(String shareCode, String password, String ipAddress, String userAgent) {
  626. LambdaQueryWrapper<CrmQuote> lqw = Wrappers.lambdaQuery();
  627. lqw.eq(CrmQuote::getShareCode, shareCode);
  628. CrmQuote entity = baseMapper.selectOne(lqw);
  629. if (entity == null) {
  630. throw new ServiceException("分享链接无效");
  631. }
  632. // 检查分享链接是否过期
  633. if (entity.getShareExpireTime() != null && LocalDateTime.now().isAfter(entity.getShareExpireTime())) {
  634. throw new ServiceException("分享链接已过期");
  635. }
  636. // 验证密码(如果设置了密码)
  637. if (StringUtils.isNotBlank(entity.getSharePassword())) {
  638. if (StringUtils.isBlank(password) || !entity.getSharePassword().equals(password)) {
  639. throw new ServiceException("分享密码错误");
  640. }
  641. }
  642. // 检查报价单是否已过期
  643. if (entity.getExpireDate() != null && LocalDate.now().isAfter(entity.getExpireDate())) {
  644. // 更新报价单状态为已过期
  645. if (!"expired".equals(entity.getStatus())) {
  646. entity.setStatus("expired");
  647. baseMapper.updateById(entity);
  648. }
  649. }
  650. // 增加查看次数
  651. entity.setViewCount((entity.getViewCount() == null ? 0 : entity.getViewCount()) + 1);
  652. entity.setLastViewTime(LocalDateTime.now());
  653. // 更新状态:如果是已提交或已审批,改为已查看
  654. if ("submitted".equals(entity.getStatus()) || "approved".equals(entity.getStatus())) {
  655. entity.setStatus("viewed");
  656. }
  657. baseMapper.updateById(entity);
  658. // 记录访问日志
  659. try {
  660. quoteShareLogService.recordVisit(entity.getId(), shareCode, ipAddress, userAgent);
  661. } catch (Exception e) {
  662. log.warn("记录访问日志失败: quoteId={}, shareCode={}", entity.getId(), shareCode, e);
  663. }
  664. log.info("通过分享码查看报价单: shareCode={}, quoteId={}, viewCount={}, ip={}",
  665. shareCode, entity.getId(), entity.getViewCount(), ipAddress);
  666. CrmQuoteVo vo = MapstructUtils.convert(entity, CrmQuoteVo.class);
  667. vo.setProductList(quoteProductService.queryListByQuoteId(entity.getId()));
  668. return vo;
  669. }
  670. @Override
  671. public Boolean acceptQuote(Long id, String remark) {
  672. CrmQuote entity = baseMapper.selectById(id);
  673. if (entity == null) {
  674. throw new ServiceException("报价单不存在");
  675. }
  676. entity.setStatus("accepted");
  677. return baseMapper.updateById(entity) > 0;
  678. }
  679. @Override
  680. public Boolean rejectQuote(Long id, String rejectReason, String remark) {
  681. CrmQuote entity = baseMapper.selectById(id);
  682. if (entity == null) {
  683. throw new ServiceException("报价单不存在");
  684. }
  685. entity.setStatus("rejected");
  686. entity.setRejectReason(rejectReason);
  687. return baseMapper.updateById(entity) > 0;
  688. }
  689. @Override
  690. @Transactional(rollbackFor = Exception.class)
  691. public Long toContract(Long id, String contractNo, String contractTitle, LocalDate signDate, LocalDate startDate,
  692. LocalDate endDate) {
  693. // 1. 查询报价单信息
  694. CrmQuote quote = baseMapper.selectById(id);
  695. if (quote == null) {
  696. throw new ServiceException("报价单不存在");
  697. }
  698. if (!"accepted".equals(quote.getStatus())) {
  699. throw new ServiceException("只有已接受的报价单才能转合同");
  700. }
  701. if (quote.getContractId() != null) {
  702. throw new ServiceException("该报价单已转为合同,不能重复转换");
  703. }
  704. // 2. 查询报价单产品明细
  705. List<CrmQuoteProductVo> quoteProducts = quoteProductService.queryListByQuoteId(id);
  706. if (quoteProducts == null || quoteProducts.isEmpty()) {
  707. throw new ServiceException("报价单没有产品明细,无法转合同");
  708. }
  709. // 3. 构建合同Bo对象
  710. CrmContractBo contractBo = new CrmContractBo();
  711. contractBo.setContractNo(contractNo);
  712. contractBo.setContractName(contractTitle);
  713. contractBo.setCustomerId(quote.getCustomerId());
  714. contractBo.setOpportunityId(quote.getOpportunityId());
  715. contractBo.setQuoteId(quote.getId());
  716. contractBo.setContactId(quote.getContactId());
  717. contractBo.setContractType("sale"); // 默认销售合同
  718. contractBo.setAmount(quote.getFinalAmount());
  719. contractBo.setSignDate(signDate != null ? signDate : LocalDate.now());
  720. contractBo.setStartDate(startDate != null ? startDate : LocalDate.now());
  721. contractBo.setEndDate(endDate); // 可为空
  722. contractBo.setOwnerUserId(quote.getOwnerUserId());
  723. contractBo.setPaymentTerms(quote.getPaymentTerms());
  724. contractBo.setDeliveryTerms(quote.getDeliveryTerms());
  725. contractBo.setRemark("由报价单" + quote.getQuoteNo() + "转换生成");
  726. // 4. 复制产品明细(只设置Bo中存在的字段)
  727. List<CrmContractProductBo> contractProducts = new java.util.ArrayList<>();
  728. for (CrmQuoteProductVo quoteProduct : quoteProducts) {
  729. CrmContractProductBo contractProduct = new CrmContractProductBo();
  730. contractProduct.setProductId(quoteProduct.getProductId());
  731. contractProduct.setQuantity(quoteProduct.getQuantity());
  732. contractProduct.setPrice(quoteProduct.getPrice());
  733. contractProduct.setDiscount(quoteProduct.getDiscount());
  734. contractProduct.setDescription(quoteProduct.getDescription());
  735. contractProduct.setSort(quoteProduct.getSort());
  736. contractProduct.setDeliveryStatus("pending");
  737. contractProduct.setDeliveredQuantity(0);
  738. contractProducts.add(contractProduct);
  739. }
  740. contractBo.setProducts(contractProducts);
  741. // 5. 调用合同服务创建合同
  742. Boolean success = contractService.insertByBo(contractBo);
  743. if (!success) {
  744. throw new ServiceException("创建合同失败");
  745. }
  746. Long contractId = contractBo.getId();
  747. // 6. 更新报价单状态
  748. quote.setStatus("contract");
  749. quote.setContractId(contractId);
  750. baseMapper.updateById(quote);
  751. log.info("报价单转合同成功: quoteId={}, quoteNo={}, contractId={}, contractNo={}",
  752. id, quote.getQuoteNo(), contractId, contractNo);
  753. return contractId;
  754. }
  755. @Override
  756. @Transactional(rollbackFor = Exception.class)
  757. public Long createNewVersion(Long id, String version, String remark) {
  758. CrmQuote oldEntity = baseMapper.selectById(id);
  759. if (oldEntity == null) {
  760. throw new ServiceException("报价单不存在");
  761. }
  762. // 创建新版本
  763. CrmQuote newEntity = new CrmQuote();
  764. newEntity.setQuoteNo(oldEntity.getQuoteNo());
  765. newEntity.setQuoteTitle(oldEntity.getQuoteTitle());
  766. newEntity.setCustomerId(oldEntity.getCustomerId());
  767. newEntity.setCustomerName(oldEntity.getCustomerName());
  768. newEntity.setOpportunityId(oldEntity.getOpportunityId());
  769. newEntity.setContactId(oldEntity.getContactId());
  770. newEntity.setContactName(oldEntity.getContactName());
  771. newEntity.setContactMobile(oldEntity.getContactMobile());
  772. newEntity.setContactEmail(oldEntity.getContactEmail());
  773. newEntity.setVersion(version);
  774. newEntity.setParentId(id);
  775. newEntity.setIsLatest(1);
  776. newEntity.setStatus("draft");
  777. newEntity.setValidityDays(oldEntity.getValidityDays());
  778. newEntity.setQuoteDate(LocalDate.now());
  779. newEntity.setExpireDate(LocalDate.now().plusDays(oldEntity.getValidityDays()));
  780. newEntity.setOwnerUserId(oldEntity.getOwnerUserId());
  781. newEntity.setOwnerUserName(oldEntity.getOwnerUserName());
  782. newEntity.setQuoteType(StringUtils.isNotBlank(oldEntity.getQuoteType()) ? oldEntity.getQuoteType() : "sales");
  783. newEntity.setPaymentTerms(oldEntity.getPaymentTerms());
  784. newEntity.setDeliveryTerms(oldEntity.getDeliveryTerms());
  785. newEntity.setRemark(remark);
  786. // 旧版本设为非最新
  787. oldEntity.setIsLatest(0);
  788. baseMapper.updateById(oldEntity);
  789. // 保存新版本
  790. baseMapper.insert(newEntity);
  791. // 复制产品明细
  792. List<CrmQuoteProductVo> oldProducts = quoteProductService.queryListByQuoteId(id);
  793. if (oldProducts != null && !oldProducts.isEmpty()) {
  794. List<CrmQuoteProductBo> newProducts = oldProducts.stream()
  795. .map(vo -> MapstructUtils.convert(vo, CrmQuoteProductBo.class))
  796. .toList();
  797. quoteProductService.batchInsert(newEntity.getId(), newProducts);
  798. }
  799. return newEntity.getId();
  800. }
  801. @Override
  802. public List<CrmQuoteVo> queryVersions(String quoteNo) {
  803. LambdaQueryWrapper<CrmQuote> lqw = Wrappers.lambdaQuery();
  804. lqw.eq(CrmQuote::getQuoteNo, quoteNo);
  805. lqw.orderByDesc(CrmQuote::getCreateTime);
  806. return baseMapper.selectVoList(lqw);
  807. }
  808. /**
  809. * 生成报价单编号
  810. * 格式:{供方编码}DD-YYYYMMDD001(供方编码DD- + 报价日期年月日 + 3位序号)
  811. *
  812. * @param supplierId 供方主体ID,如果为null则使用默认编码"SX"
  813. * @param quoteDate 报价日期,如果为null则使用当前日期
  814. */
  815. private String generateQuoteNo(Long supplierId, LocalDate quoteDate) {
  816. // 获取供方编码,如果没有则使用默认值"SX"
  817. String supplierCode = "SX";
  818. if (supplierId != null) {
  819. try {
  820. var supplierInfo = supplierInfoService.queryById(supplierId);
  821. if (supplierInfo != null && StringUtils.isNotBlank(supplierInfo.getSupplierCode())) {
  822. supplierCode = supplierInfo.getSupplierCode();
  823. }
  824. } catch (Exception e) {
  825. log.warn("查询供方信息失败,使用默认编码,供方ID: {}", supplierId, e);
  826. }
  827. }
  828. // 使用报价日期生成日期部分,如果报价日期为空则使用当前日期
  829. LocalDate date = quoteDate != null ? quoteDate : LocalDate.now();
  830. String dateStr = DateTimeFormatter.ofPattern("yyyyMMdd").format(date);
  831. String prefix = supplierCode + "DD-" + dateStr;
  832. // 查询该报价日期下的最大编号(只查询未删除的记录)
  833. // 同时需要匹配报价日期,确保序号按报价日期统计
  834. LambdaQueryWrapper<CrmQuote> wrapper = Wrappers.lambdaQuery();
  835. wrapper.likeRight(CrmQuote::getQuoteNo, prefix);
  836. wrapper.eq(CrmQuote::getQuoteDate, date); // 按报价日期查询
  837. wrapper.orderByDesc(CrmQuote::getQuoteNo);
  838. wrapper.last("LIMIT 1");
  839. CrmQuote lastQuote = baseMapper.selectOne(wrapper);
  840. int sequence = 1;
  841. if (lastQuote != null && StringUtils.isNotBlank(lastQuote.getQuoteNo())) {
  842. String lastNo = lastQuote.getQuoteNo();
  843. // {供方编码}DD-20251017001,前缀长度是{供方编码长度}+5(DD-yyyyMMdd),序号长度是3位
  844. if (lastNo.length() >= prefix.length() + 3) {
  845. String seqStr = lastNo.substring(prefix.length());
  846. try {
  847. sequence = Integer.parseInt(seqStr) + 1;
  848. } catch (NumberFormatException e) {
  849. sequence = 1;
  850. }
  851. }
  852. }
  853. // 最多尝试10次,避免无限循环
  854. for (int retry = 0; retry < 10; retry++) {
  855. String quoteNo = prefix + String.format("%03d", sequence + retry);
  856. // 检查编号是否已存在(只检查未删除的记录)
  857. Long count = baseMapper.selectCount(
  858. Wrappers.lambdaQuery(CrmQuote.class)
  859. .eq(CrmQuote::getQuoteNo, quoteNo));
  860. if (count == 0) {
  861. return quoteNo;
  862. }
  863. // 如果编号已存在,继续尝试下一个序号
  864. log.warn("报价单编号 {} 已存在,尝试生成下一个编号(第{}次重试)", quoteNo, retry + 1);
  865. }
  866. // 如果重试10次都失败,抛出异常
  867. throw new ServiceException("生成报价单编号失败,请稍后重试");
  868. }
  869. /**
  870. * 填充报价单被合同关联次数
  871. */
  872. private void fillContractLinkedCount(List<CrmQuoteVo> quoteList) {
  873. if (quoteList == null || quoteList.isEmpty()) {
  874. return;
  875. }
  876. Set<Long> quoteIds = quoteList.stream()
  877. .map(CrmQuoteVo::getId)
  878. .filter(java.util.Objects::nonNull)
  879. .collect(Collectors.toSet());
  880. if (quoteIds.isEmpty()) {
  881. return;
  882. }
  883. List<CrmContract> contracts = contractMapper.selectList(
  884. Wrappers.<CrmContract>lambdaQuery()
  885. .in(CrmContract::getQuoteId, quoteIds)
  886. .select(CrmContract::getQuoteId));
  887. Map<Long, Long> contractCountMap = contracts.stream()
  888. .map(CrmContract::getQuoteId)
  889. .filter(java.util.Objects::nonNull)
  890. .collect(Collectors.groupingBy(id -> id, Collectors.counting()));
  891. for (CrmQuoteVo vo : quoteList) {
  892. Long count = contractCountMap.getOrDefault(vo.getId(), 0L);
  893. vo.setContractLinkedCount(count.intValue());
  894. }
  895. }
  896. /**
  897. * 计算金额
  898. */
  899. private void calculateAmount(CrmQuote entity, List<CrmQuoteProductBo> products) {
  900. if (products == null || products.isEmpty()) {
  901. entity.setTotalAmount(BigDecimal.ZERO);
  902. entity.setDiscountAmount(BigDecimal.ZERO);
  903. entity.setFinalAmount(BigDecimal.ZERO);
  904. return;
  905. }
  906. // 计算总金额(未折扣前)
  907. BigDecimal totalAmount = BigDecimal.ZERO;
  908. for (CrmQuoteProductBo product : products) {
  909. BigDecimal subtotal = product.getPrice()
  910. .multiply(product.getQuantity())
  911. .multiply(BigDecimal.ONE.subtract(
  912. product.getDiscount() == null ? BigDecimal.ZERO
  913. : product.getDiscount().divide(BigDecimal.valueOf(100))));
  914. totalAmount = totalAmount.add(subtotal);
  915. }
  916. entity.setTotalAmount(totalAmount);
  917. // 计算整单折扣
  918. BigDecimal discountRate = entity.getDiscountRate() == null ? BigDecimal.ZERO : entity.getDiscountRate();
  919. BigDecimal discountAmount = totalAmount.multiply(discountRate.divide(BigDecimal.valueOf(100)));
  920. entity.setDiscountAmount(discountAmount);
  921. // 计算最终金额
  922. entity.setFinalAmount(totalAmount.subtract(discountAmount));
  923. }
  924. /**
  925. * 解析报价产品ID(按名称/规格/分类/单位/税率匹配,未命中自动新增)
  926. */
  927. private void resolveQuoteProductIds(List<CrmQuoteProductBo> products) {
  928. if (products == null || products.isEmpty()) {
  929. return;
  930. }
  931. for (CrmQuoteProductBo product : products) {
  932. Long productId = productService.resolveProductIdForQuoteItem(product);
  933. product.setProductId(productId);
  934. }
  935. }
  936. @Override
  937. public java.util.Map<String, Object> statisticsOverview(Long ownerUserId, String beginTime, String endTime) {
  938. java.util.Map<String, Object> result = new java.util.HashMap<>();
  939. LambdaQueryWrapper<CrmQuote> lqw = Wrappers.lambdaQuery();
  940. if (ownerUserId != null) {
  941. lqw.eq(CrmQuote::getOwnerUserId, ownerUserId);
  942. }
  943. if (StringUtils.isNotBlank(beginTime)) {
  944. lqw.ge(CrmQuote::getCreateTime, beginTime);
  945. }
  946. if (StringUtils.isNotBlank(endTime)) {
  947. lqw.le(CrmQuote::getCreateTime, endTime);
  948. }
  949. // 总数量
  950. Long totalCount = baseMapper.selectCount(lqw);
  951. result.put("totalCount", totalCount);
  952. // 各状态数量
  953. LambdaQueryWrapper<CrmQuote> draftLqw = lqw.clone().eq(CrmQuote::getStatus, "draft");
  954. result.put("draftCount", baseMapper.selectCount(draftLqw));
  955. LambdaQueryWrapper<CrmQuote> submittedLqw = lqw.clone().eq(CrmQuote::getStatus, "submitted");
  956. result.put("submittedCount", baseMapper.selectCount(submittedLqw));
  957. LambdaQueryWrapper<CrmQuote> acceptedLqw = lqw.clone().eq(CrmQuote::getStatus, "accepted");
  958. Long acceptedCount = baseMapper.selectCount(acceptedLqw);
  959. result.put("acceptedCount", acceptedCount);
  960. LambdaQueryWrapper<CrmQuote> rejectedLqw = lqw.clone().eq(CrmQuote::getStatus, "rejected");
  961. result.put("rejectedCount", baseMapper.selectCount(rejectedLqw));
  962. LambdaQueryWrapper<CrmQuote> expiredLqw = lqw.clone().eq(CrmQuote::getStatus, "expired");
  963. result.put("expiredCount", baseMapper.selectCount(expiredLqw));
  964. // 总金额和已接受金额
  965. List<CrmQuote> allQuotes = baseMapper.selectList(lqw);
  966. BigDecimal totalAmount = allQuotes.stream()
  967. .map(CrmQuote::getFinalAmount)
  968. .reduce(BigDecimal.ZERO, BigDecimal::add);
  969. result.put("totalAmount", totalAmount);
  970. List<CrmQuote> acceptedQuotes = baseMapper.selectList(acceptedLqw);
  971. BigDecimal acceptedAmount = acceptedQuotes.stream()
  972. .map(CrmQuote::getFinalAmount)
  973. .reduce(BigDecimal.ZERO, BigDecimal::add);
  974. result.put("acceptedAmount", acceptedAmount);
  975. // 接受率
  976. double acceptRate = totalCount > 0 ? (acceptedCount * 100.0 / totalCount) : 0.0;
  977. result.put("acceptRate", String.format("%.2f", acceptRate));
  978. // 平均金额
  979. BigDecimal avgAmount = totalCount > 0 ? totalAmount.divide(BigDecimal.valueOf(totalCount), 2,
  980. java.math.RoundingMode.HALF_UP) : BigDecimal.ZERO;
  981. result.put("avgAmount", avgAmount);
  982. return result;
  983. }
  984. @Override
  985. public List<java.util.Map<String, Object>> statisticsStatus() {
  986. List<java.util.Map<String, Object>> result = new java.util.ArrayList<>();
  987. String[] statusArray = { "draft", "submitted", "accepted", "rejected", "expired", "contract" };
  988. String[] statusNameArray = { "草稿", "已提交", "已接受", "已拒绝", "已过期", "已转合同" };
  989. Long total = baseMapper.selectCount(Wrappers.lambdaQuery());
  990. for (int i = 0; i < statusArray.length; i++) {
  991. java.util.Map<String, Object> item = new java.util.HashMap<>();
  992. String status = statusArray[i];
  993. String statusName = statusNameArray[i];
  994. LambdaQueryWrapper<CrmQuote> lqw = Wrappers.lambdaQuery();
  995. lqw.eq(CrmQuote::getStatus, status);
  996. Long count = baseMapper.selectCount(lqw);
  997. List<CrmQuote> quotes = baseMapper.selectList(lqw);
  998. BigDecimal amount = quotes.stream()
  999. .map(CrmQuote::getFinalAmount)
  1000. .reduce(BigDecimal.ZERO, BigDecimal::add);
  1001. double percent = total > 0 ? (count * 100.0 / total) : 0.0;
  1002. item.put("status", status);
  1003. item.put("statusName", statusName);
  1004. item.put("count", count);
  1005. item.put("amount", amount);
  1006. item.put("percent", String.format("%.2f", percent));
  1007. result.add(item);
  1008. }
  1009. return result;
  1010. }
  1011. @Override
  1012. public List<java.util.Map<String, Object>> statisticsTrend(String type, String beginTime, String endTime) {
  1013. List<java.util.Map<String, Object>> result = new java.util.ArrayList<>();
  1014. // 1. 构建查询条件
  1015. LambdaQueryWrapper<CrmQuote> lqw = Wrappers.lambdaQuery();
  1016. if (StringUtils.isNotBlank(beginTime)) {
  1017. lqw.ge(CrmQuote::getCreateTime, beginTime);
  1018. }
  1019. if (StringUtils.isNotBlank(endTime)) {
  1020. lqw.le(CrmQuote::getCreateTime, endTime);
  1021. }
  1022. // 2. 查询所有数据
  1023. List<CrmQuote> allQuotes = baseMapper.selectList(lqw);
  1024. if (allQuotes.isEmpty()) {
  1025. return result;
  1026. }
  1027. // 3. 按时间类型分组统计
  1028. java.util.Map<String, List<CrmQuote>> groupedQuotes = new java.util.LinkedHashMap<>();
  1029. for (CrmQuote quote : allQuotes) {
  1030. // createTime继承自BaseEntity,是Date类型,需要转换为LocalDateTime
  1031. java.util.Date createTimeDate = quote.getCreateTime();
  1032. LocalDateTime createTime;
  1033. if (createTimeDate != null) {
  1034. createTime = DateUtil.toLocalDateTime(createTimeDate);
  1035. } else if (quote.getQuoteDate() != null) {
  1036. // 如果createTime为空,使用quoteDate
  1037. createTime = quote.getQuoteDate().atStartOfDay();
  1038. } else {
  1039. createTime = LocalDateTime.now();
  1040. }
  1041. String period = getPeriodKey(createTime, type);
  1042. groupedQuotes.computeIfAbsent(period, k -> new java.util.ArrayList<>()).add(quote);
  1043. }
  1044. // 4. 计算每个周期的统计数据
  1045. for (java.util.Map.Entry<String, List<CrmQuote>> entry : groupedQuotes.entrySet()) {
  1046. String period = entry.getKey();
  1047. List<CrmQuote> periodQuotes = entry.getValue();
  1048. java.util.Map<String, Object> item = new java.util.HashMap<>();
  1049. item.put("period", period);
  1050. // 总数量
  1051. item.put("totalCount", periodQuotes.size());
  1052. // 已接受数量
  1053. long acceptedCount = periodQuotes.stream()
  1054. .filter(q -> "accepted".equals(q.getStatus()) || "contract".equals(q.getStatus()))
  1055. .count();
  1056. item.put("acceptedCount", acceptedCount);
  1057. // 总金额
  1058. BigDecimal totalAmount = periodQuotes.stream()
  1059. .map(CrmQuote::getFinalAmount)
  1060. .reduce(BigDecimal.ZERO, BigDecimal::add);
  1061. item.put("totalAmount", totalAmount);
  1062. // 已接受金额
  1063. BigDecimal acceptedAmount = periodQuotes.stream()
  1064. .filter(q -> "accepted".equals(q.getStatus()) || "contract".equals(q.getStatus()))
  1065. .map(CrmQuote::getFinalAmount)
  1066. .reduce(BigDecimal.ZERO, BigDecimal::add);
  1067. item.put("acceptedAmount", acceptedAmount);
  1068. // 接受率
  1069. double acceptRate = periodQuotes.size() > 0
  1070. ? (acceptedCount * 100.0 / periodQuotes.size())
  1071. : 0.0;
  1072. item.put("acceptRate", String.format("%.2f", acceptRate));
  1073. result.add(item);
  1074. }
  1075. log.info("报价单趋势分析: type={}, periods={}, totalRecords={}",
  1076. type, result.size(), allQuotes.size());
  1077. return result;
  1078. }
  1079. /**
  1080. * 根据时间类型获取周期标识
  1081. */
  1082. private String getPeriodKey(LocalDateTime dateTime, String type) {
  1083. if (dateTime == null) {
  1084. return "未知";
  1085. }
  1086. DateTimeFormatter formatter;
  1087. switch (type.toLowerCase()) {
  1088. case "year":
  1089. formatter = DateTimeFormatter.ofPattern("yyyy年");
  1090. break;
  1091. case "quarter":
  1092. int quarter = (dateTime.getMonthValue() - 1) / 3 + 1;
  1093. return dateTime.getYear() + "年Q" + quarter;
  1094. case "month":
  1095. default:
  1096. formatter = DateTimeFormatter.ofPattern("yyyy年MM月");
  1097. break;
  1098. }
  1099. return dateTime.format(formatter);
  1100. }
  1101. @Override
  1102. @Transactional(rollbackFor = Exception.class)
  1103. public Boolean withdrawApproval(Long id, String remark) {
  1104. CrmQuote entity = baseMapper.selectById(id);
  1105. if (entity == null) {
  1106. throw new ServiceException("报价单不存在");
  1107. }
  1108. // 只能撤回已提交未审批的报价单
  1109. if (!"submitted".equals(entity.getStatus())) {
  1110. throw new ServiceException("只能撤回已提交的报价单");
  1111. }
  1112. // 检查是否为创建人
  1113. if (!entity.getCreateBy().equals(LoginHelper.getUserId())) {
  1114. throw new ServiceException("只有创建人才能撤回审批");
  1115. }
  1116. // 更新状态为草稿
  1117. entity.setStatus("draft");
  1118. boolean flag = baseMapper.updateById(entity) > 0;
  1119. if (flag) {
  1120. // 删除所有pending状态的审批记录
  1121. quoteApprovalService.deletePendingApprovals(id);
  1122. log.info("撤回审批成功: quoteId={}, quoteNo={}, remark={}",
  1123. id, entity.getQuoteNo(), remark);
  1124. }
  1125. return flag;
  1126. }
  1127. @Override
  1128. @Transactional(rollbackFor = Exception.class)
  1129. public Boolean invalidQuote(Long id, String remark) {
  1130. CrmQuote entity = baseMapper.selectById(id);
  1131. if (entity == null) {
  1132. throw new ServiceException("报价单不存在");
  1133. }
  1134. // 任何状态都可以作废,但已作废的不能重复作废
  1135. if ("invalid".equals(entity.getStatus())) {
  1136. throw new ServiceException("报价单已作废,不能重复作废");
  1137. }
  1138. // 更新状态为已作废
  1139. entity.setStatus("invalid");
  1140. if (StringUtils.isNotBlank(remark)) {
  1141. String currentRemark = entity.getRemark();
  1142. if (StringUtils.isNotBlank(currentRemark)) {
  1143. entity.setRemark(currentRemark + "\n【作废原因】" + remark);
  1144. } else {
  1145. entity.setRemark("【作废原因】" + remark);
  1146. }
  1147. }
  1148. boolean flag = baseMapper.updateById(entity) > 0;
  1149. if (flag) {
  1150. log.info("作废报价单成功: quoteId={}, quoteNo={}, remark={}",
  1151. id, entity.getQuoteNo(), remark);
  1152. }
  1153. return flag;
  1154. }
  1155. }