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.MfgCostEntry;
import com.kaidi.oa.domain.MfgOutsource;
import com.kaidi.oa.domain.MfgDelivery;
import com.kaidi.oa.repository.MfgCostEntryRepository;
import com.kaidi.oa.repository.MfgOutsourceRepository;
import com.kaidi.oa.repository.MfgDeliveryRepository;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 制造管理中心 / 环保设备制造中心 —— 项目成本五维度归集(需求 §10 补深 GAP#10 low)。
*
*
补深审计缺口 GAP#10(low):
*
* - 外协费用/安装调试费用在项目成本归集中未作为独立维度归集(仅材料+人工+售后备件三类);
* - 采购发票/销售发票自动生成财务凭证链路为聚合计算而非与财务模块双写联动(凭证落于 mfg 模块内,
* 未写入财务总账)——财务总账双写属外部系统集成(skippedExternal),本轮不做,
* 但凭证生成链路已在 MfgFinIntegrationController 实现。
*
*
* 本控制器提供:
*
* - POST /mfg-cost-entries 手工录入成本条目(外协/安装调试/其他制造/质保维修);
* - POST /mfg-cost-entries/from-outsource/{outsourceId} 从外协任务自动提取外协费用,
* 幂等(同 outsourceNo 已录入则 409);
* - POST /mfg-cost-entries/from-delivery/{deliveryId} 从发货单提取安装调试费用
* (deliveryId 关联的安装调试节点),幂等;
* - GET /mfg-cost-entries/project-summary/{projectCode} 五维度成本汇总(材料+人工+外协+安装调试+其他);
* - GET/PATCH/DELETE 条目管理;
* - POST /{id}/approve 审核确认(待审核 → 已审核)。
*
*
* 金额一律 {@link Money}/BigDecimal;外协费用从 MfgOutsource.amount 取,
* 安装调试费用默认按工程师人天数计(前端可手工调整)。
*/
@RestController
@RequestMapping("/api/oa/mfg-cost-entries")
public class MfgCostEntryController {
public static final List COST_TYPES = List.of(
"外协费用", "安装调试费用", "其他制造费用", "质保维修费用");
private final MfgCostEntryRepository entryRepo;
private final MfgOutsourceRepository outsourceRepo;
private final MfgDeliveryRepository deliveryRepo;
public MfgCostEntryController(MfgCostEntryRepository entryRepo,
MfgOutsourceRepository outsourceRepo,
MfgDeliveryRepository deliveryRepo) {
this.entryRepo = entryRepo;
this.outsourceRepo = outsourceRepo;
this.deliveryRepo = deliveryRepo;
}
// ---- CRUD ----
@GetMapping
public ApiResp> list(
@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String costType) {
if (projectCode != null && !projectCode.isBlank() && costType != null && !costType.isBlank()) {
return ApiResp.ok(entryRepo.findByProjectCodeAndCostType(projectCode, costType));
}
if (projectCode != null && !projectCode.isBlank()) {
return ApiResp.ok(entryRepo.findByProjectCode(projectCode));
}
if (costType != null && !costType.isBlank()) {
return ApiResp.ok(entryRepo.findByCostType(costType));
}
return ApiResp.ok(entryRepo.findAllByOrderByIdDesc());
}
@GetMapping("/{id}")
public ApiResp get(@PathVariable Long id) {
return ApiResp.ok(entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id)));
}
public record EntryRequest(
String projectCode, String costType, String description,
String outsourceNo, String deliveryNo,
Double amount, String costDate, String handler, String remark) {
}
@PostMapping
public ApiResp create(@RequestBody EntryRequest req) {
if (req.projectCode() == null || req.projectCode().isBlank()) {
throw new ApiException(400, "项目编码(projectCode)不能为空");
}
if (req.costType() == null || !COST_TYPES.contains(req.costType())) {
throw new ApiException(400, "费用类型(costType)无效,可选: " + COST_TYPES);
}
MfgCostEntry entry = new MfgCostEntry();
entry.setEntryNo("MCE-" + (entryRepo.count() + 1));
fillEntry(entry, req);
entry.setApprovalStatus("待审核");
entry.setCreatedAt(Instant.now());
return ApiResp.ok(entryRepo.save(entry));
}
@PatchMapping("/{id}")
public ApiResp update(@PathVariable Long id, @RequestBody EntryRequest req) {
MfgCostEntry entry = entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id));
if ("已审核".equals(entry.getApprovalStatus())) {
throw new ApiException(409, "已审核的成本条目不能修改");
}
if (req.costType() != null && !req.costType().isBlank() && !COST_TYPES.contains(req.costType())) {
throw new ApiException(400, "费用类型无效,可选: " + COST_TYPES);
}
fillEntry(entry, req);
return ApiResp.ok(entryRepo.save(entry));
}
@DeleteMapping("/{id}")
public ApiResp delete(@PathVariable Long id) {
MfgCostEntry entry = entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id));
if ("已审核".equals(entry.getApprovalStatus())) {
throw new ApiException(409, "已审核的成本条目不能删除");
}
entryRepo.deleteById(id);
return ApiResp.ok(null);
}
// ---- 从外协任务自动提取外协费用 ----
/**
* 从外协任务单自动提取外协费用条目(幂等:同 outsourceNo 已录入则 409)。
* 外协费用 = outsource.amount(外协合同价),关联项目 = outsource.projectCode。
*/
@PostMapping("/from-outsource/{outsourceId}")
@Transactional
public ApiResp fromOutsource(@PathVariable Long outsourceId) {
MfgOutsource os = outsourceRepo.findById(outsourceId)
.orElseThrow(() -> new NotFoundException("外协任务不存在: " + outsourceId));
// 幂等检查
String osNo = os.getOutsourceNo();
if (osNo != null) {
boolean dup = entryRepo.findByCostType("外协费用").stream()
.anyMatch(e -> osNo.equals(e.getOutsourceNo()));
if (dup) {
throw new ApiException(409, "该外协任务(" + osNo + ")已提取外协费用条目,不可重复提取");
}
}
// 只有「合格入库」状态才确认成本
if (!"合格入库".equals(os.getStatus()) && !"已返回".equals(os.getStatus())) {
throw new ApiException(400, "外协任务须处于「已返回」或「合格入库」状态才能提取费用,当前: "
+ os.getStatus());
}
MfgCostEntry entry = new MfgCostEntry();
entry.setEntryNo("MCE-OS-" + outsourceId);
entry.setProjectCode(os.getProjectCode());
entry.setCostType("外协费用");
entry.setDescription("外协加工(" + os.getProcessType() + ") - "
+ os.getPartName() + " @ " + os.getVendor());
entry.setOutsourceNo(osNo);
entry.setAmount(Money.nz(os.getAmount()));
entry.setCostDate(LocalDate.now().toString());
entry.setHandler(os.getOwner());
entry.setApprovalStatus("待审核");
entry.setCreatedAt(Instant.now());
return ApiResp.ok(entryRepo.save(entry));
}
// ---- 从发货单提取安装调试费用 ----
public record DeliveryInstallCostRequest(
Double installCost, String description, String handler) {
}
/**
* 从发货单提取安装调试费用(幂等:同 deliveryNo 已录入则 409)。
* 安装调试费用由调用方传入(工程师差旅+日补等实际发生额),关联 deliveryNo。
* 发货单须处于「安装调试」/「性能验收」/「已移交」阶段。
*/
@PostMapping("/from-delivery/{deliveryId}")
@Transactional
public ApiResp fromDelivery(@PathVariable Long deliveryId,
@RequestBody DeliveryInstallCostRequest req) {
MfgDelivery delivery = deliveryRepo.findById(deliveryId)
.orElseThrow(() -> new NotFoundException("发货单不存在: " + deliveryId));
String dlvNo = delivery.getDeliveryNo();
if (dlvNo != null) {
boolean dup = entryRepo.findByCostType("安装调试费用").stream()
.anyMatch(e -> dlvNo.equals(e.getDeliveryNo()));
if (dup) {
throw new ApiException(409, "该发货单(" + dlvNo + ")已录入安装调试费用,不可重复录入");
}
}
String status = delivery.getStatus();
if (!"安装调试".equals(status) && !"性能验收".equals(status) && !"已移交".equals(status)) {
throw new ApiException(400, "发货单须处于「安装调试」「性能验收」「已移交」状态,当前: " + status);
}
if (req.installCost() == null || req.installCost() <= 0) {
throw new ApiException(400, "安装调试费用(installCost)必须填写且大于0");
}
MfgCostEntry entry = new MfgCostEntry();
entry.setEntryNo("MCE-DLV-" + deliveryId);
entry.setProjectCode(delivery.getProjectCode());
entry.setCostType("安装调试费用");
entry.setDescription(req.description() != null ? req.description()
: "现场安装调试费 - " + delivery.getDeviceName()
+ " @ " + (delivery.getSiteAddress() != null ? delivery.getSiteAddress() : ""));
entry.setDeliveryNo(dlvNo);
entry.setAmount(Money.of(req.installCost()));
entry.setCostDate(LocalDate.now().toString());
entry.setHandler(req.handler() != null ? req.handler() : delivery.getEngineer());
entry.setApprovalStatus("待审核");
entry.setCreatedAt(Instant.now());
return ApiResp.ok(entryRepo.save(entry));
}
// ---- 审核 ----
public record ApproveRequest(String approver) {
}
/** 审核确认(待审核 → 已审核)。 */
@PostMapping("/{id}/approve")
public ApiResp approve(@PathVariable Long id,
@RequestBody(required = false) ApproveRequest req) {
MfgCostEntry entry = entryRepo.findById(id)
.orElseThrow(() -> new NotFoundException("成本条目不存在: " + id));
if (!"待审核".equals(entry.getApprovalStatus())) {
throw new ApiException(409, "只有「待审核」状态可审核,当前: " + entry.getApprovalStatus());
}
entry.setApprovalStatus("已审核");
entry.setApproveDate(LocalDate.now().toString());
if (req != null && req.approver() != null) {
entry.setApprover(req.approver());
}
return ApiResp.ok(entryRepo.save(entry));
}
// ---- 五维度项目成本汇总 ----
public record DimCost(String costType, BigDecimal totalAmount, long entryCount) {}
public record ProjectCostFiveDim(
String projectCode,
BigDecimal outsourceCost,
BigDecimal installCost,
BigDecimal otherMfgCost,
BigDecimal warrantyRepairCost,
BigDecimal extendedCostTotal,
List dimBreakdown) {
}
/**
* 五维度项目成本汇总(外协+安装调试+其他制造+质保维修),
* 供 MfgProjectCostController /summary 聚合接入:
* 外协费用 / 安装调试费用 / 其他制造费用 / 质保维修费用 各维度分别汇总,
* 前端在项目成本页额外展示「扩展成本」四维度,与原三维度(材料/人工/售后)并列。
*/
@GetMapping("/project-summary/{projectCode}")
public ApiResp projectSummary(@PathVariable String projectCode) {
List entries = entryRepo.findByProjectCode(projectCode);
Map byType = new LinkedHashMap<>();
Map countByType = new LinkedHashMap<>();
for (String t : COST_TYPES) {
byType.put(t, BigDecimal.ZERO);
countByType.put(t, 0L);
}
for (MfgCostEntry e : entries) {
if (e.getCostType() == null) continue;
byType.merge(e.getCostType(), Money.nz(e.getAmount()), Money::add);
countByType.merge(e.getCostType(), 1L, Long::sum);
}
List breakdown = new ArrayList<>();
for (String t : COST_TYPES) {
breakdown.add(new DimCost(t, byType.get(t), countByType.get(t)));
}
BigDecimal outsource = byType.get("外协费用");
BigDecimal install = byType.get("安装调试费用");
BigDecimal other = byType.get("其他制造费用");
BigDecimal warranty = byType.get("质保维修费用");
BigDecimal total = Money.add(Money.add(outsource, install), Money.add(other, warranty));
return ApiResp.ok(new ProjectCostFiveDim(
projectCode, outsource, install, other, warranty, total, breakdown));
}
// ---- helpers ----
private void fillEntry(MfgCostEntry entry, EntryRequest req) {
if (req.projectCode() != null && !req.projectCode().isBlank()) entry.setProjectCode(req.projectCode());
if (req.costType() != null && !req.costType().isBlank()) entry.setCostType(req.costType());
if (req.description() != null) entry.setDescription(req.description());
if (req.outsourceNo() != null) entry.setOutsourceNo(req.outsourceNo());
if (req.deliveryNo() != null) entry.setDeliveryNo(req.deliveryNo());
if (req.amount() != null) entry.setAmount(Money.of(req.amount()));
if (req.costDate() != null) entry.setCostDate(req.costDate());
if (req.handler() != null) entry.setHandler(req.handler());
if (req.remark() != null) entry.setRemark(req.remark());
}
}