package com.kaidi.oa.web; import com.kaidi.oa.common.ApiException; import com.kaidi.oa.common.ApiResp; import com.kaidi.oa.common.Money; import com.kaidi.oa.common.NotFoundException; import com.kaidi.oa.domain.Bid; import com.kaidi.oa.domain.CrmBidQuoteDetail; import com.kaidi.oa.repository.BidRepository; import com.kaidi.oa.repository.CrmBidQuoteDetailRepository; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.time.Instant; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 投标报价明细(Module 3 缺口:bidAmount 单字段→拆线报价明细表)。 *

* 每笔投标(Bid)可维护多条报价明细(直接费/间接费/利润/税金), * 支持多报价策略(低价/合理价/高价),并聚合汇总端点。 * 同时新建「投标文件-CollabDoc 联动」聚合端点(bidId外键联查)。 *

* GET /crm-bid-quote-details/by-bid/{bidId} -> 某投标的报价明细列表 * GET /crm-bid-quote-details/summary/{bidId} -> 报价汇总 * POST /crm-bid-quote-details -> 新建报价明细行 * PUT /crm-bid-quote-details/{id} -> 更新报价明细行 * DELETE /crm-bid-quote-details/{id} -> 删除报价明细行 */ @RestController @RequestMapping("/api/oa/crm-bid-quote-details") public class CrmBidQuoteDetailController { private static final List VALID_CATEGORIES = List.of("直接费", "间接费", "利润", "税金", "其他"); private static final List VALID_STRATEGIES = List.of("低价策略", "合理价策略", "高价策略"); private final CrmBidQuoteDetailRepository quoteRepo; private final BidRepository bidRepo; public CrmBidQuoteDetailController(CrmBidQuoteDetailRepository quoteRepo, BidRepository bidRepo) { this.quoteRepo = quoteRepo; this.bidRepo = bidRepo; } @GetMapping("/by-bid/{bidId}") public ApiResp> listByBid(@PathVariable Long bidId) { bidRepo.findById(bidId).orElseThrow(() -> new NotFoundException("投标项目不存在:" + bidId)); return ApiResp.ok(quoteRepo.findByBidId(bidId)); } /** * GET /summary/{bidId} -> 按类别汇总报价金额 + 占比 + 合计。 */ @GetMapping("/summary/{bidId}") public ApiResp> summary(@PathVariable Long bidId) { Bid bid = bidRepo.findById(bidId).orElseThrow(() -> new NotFoundException("投标项目不存在:" + bidId)); List lines = quoteRepo.findByBidId(bidId); BigDecimal total = lines.stream() .map(d -> Money.nz(d.getAmount())) .reduce(BigDecimal.ZERO, Money::add); Map byCategory = new LinkedHashMap<>(); for (CrmBidQuoteDetail d : lines) { String cat = d.getCategory() == null ? "其他" : d.getCategory(); byCategory.merge(cat, Money.nz(d.getAmount()), Money::add); } // 计算各类别占比 List> catRows = new java.util.ArrayList<>(); for (Map.Entry e : byCategory.entrySet()) { Map row = new LinkedHashMap<>(); row.put("category", e.getKey()); row.put("amount", e.getValue().doubleValue()); double pct = total.signum() == 0 ? 0.0 : Math.round(e.getValue().doubleValue() * 1000.0 / total.doubleValue()) / 10.0; row.put("pct", pct); catRows.add(row); } Map result = new LinkedHashMap<>(); result.put("bidId", bidId); result.put("projectName", bid.getProjectName()); result.put("bidAmount", Money.nz(bid.getBidAmount()).doubleValue()); result.put("totalQuoted", total.doubleValue()); result.put("quoteLineCount", lines.size()); result.put("byCategory", catRows); return ApiResp.ok(result); } public record QuoteDetailRequest( Long bidId, String category, String itemName, Double amount, Double pct, String priceStrategy, String remark, String createdBy) { } @PostMapping @Transactional public ApiResp create(@RequestBody QuoteDetailRequest req) { if (req.bidId() == null) throw new ApiException(400, "bidId 不能为空"); if (req.category() == null || req.category().isBlank()) throw new ApiException(400, "费用类别 category 不能为空"); if (!VALID_CATEGORIES.contains(req.category())) { throw new ApiException(400, "费用类别无效,必须为:" + String.join("/", VALID_CATEGORIES)); } bidRepo.findById(req.bidId()).orElseThrow(() -> new NotFoundException("投标项目不存在:" + req.bidId())); CrmBidQuoteDetail d = new CrmBidQuoteDetail(); d.setBidId(req.bidId()); d.setCategory(req.category()); d.setItemName(req.itemName()); d.setAmount(req.amount() != null ? Money.of(req.amount()) : BigDecimal.ZERO); d.setPct(req.pct()); if (req.priceStrategy() != null && !VALID_STRATEGIES.contains(req.priceStrategy())) { throw new ApiException(400, "报价策略无效,必须为:" + String.join("/", VALID_STRATEGIES)); } d.setPriceStrategy(req.priceStrategy()); d.setRemark(req.remark()); d.setCreatedBy(req.createdBy()); d.setCreatedAt(Instant.now()); return ApiResp.ok(quoteRepo.save(d)); } @PutMapping("/{id}") @Transactional public ApiResp update(@PathVariable Long id, @RequestBody QuoteDetailRequest req) { CrmBidQuoteDetail d = quoteRepo.findById(id) .orElseThrow(() -> new NotFoundException("报价明细不存在:" + id)); if (req.category() != null) { if (!VALID_CATEGORIES.contains(req.category())) { throw new ApiException(400, "费用类别无效,必须为:" + String.join("/", VALID_CATEGORIES)); } d.setCategory(req.category()); } if (req.itemName() != null) d.setItemName(req.itemName()); if (req.amount() != null) d.setAmount(Money.of(req.amount())); if (req.pct() != null) d.setPct(req.pct()); if (req.priceStrategy() != null) { if (!VALID_STRATEGIES.contains(req.priceStrategy())) { throw new ApiException(400, "报价策略无效,必须为:" + String.join("/", VALID_STRATEGIES)); } d.setPriceStrategy(req.priceStrategy()); } if (req.remark() != null) d.setRemark(req.remark()); return ApiResp.ok(quoteRepo.save(d)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { if (!quoteRepo.existsById(id)) throw new NotFoundException("报价明细不存在:" + id); quoteRepo.deleteById(id); return ApiResp.ok(null); } }