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.FeedstockBatch; import com.kaidi.oa.domain.FermentBatch; import com.kaidi.oa.domain.FermentLog; import com.kaidi.oa.domain.FertRecipe; import com.kaidi.oa.domain.FertWorkSubOrder; import com.kaidi.oa.domain.RecipeIngredient; import com.kaidi.oa.repository.FeedstockBatchRepository; import com.kaidi.oa.repository.FermentBatchRepository; import com.kaidi.oa.repository.FermentLogRepository; import com.kaidi.oa.repository.FertRecipeRepository; import com.kaidi.oa.repository.FertWorkSubOrderRepository; import com.kaidi.oa.repository.RecipeIngredientRepository; 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.time.format.DateTimeParseException; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; /** * 生物质肥料制造中心·发酵任务单 / 工艺管理(需求 §2 生产计划与发酵工艺管理 + §5 质量追溯)。 * * 这是本机构最核心的"真实工作流而非空壳"模块,覆盖完整发酵工艺状态机: * create 起发酵任务单(按生效配方),状态 待投料; * charge 投料:按配方明细×批量算理论用量,按 FIFO 消耗合格原料批次({@link FeedstockBatch})可用量, * 记录投料溯源(成品←原料逆向追溯起点),状态 → 投料完成; * logs/log 过程监控录入:逐日/班记温度/含水率/pH/氧含量/翻抛次数,自动对照工艺目标判超限预警, * 首次录入把状态 → 发酵中;超限置批次 hasAlert; * to-maturity 申请腐熟检测:发酵中 → 待腐熟检测; * maturity 腐熟度判定闸:录发芽指数,≥目标阈值判合格 → 已出料(填实际出料日期),否则 → 异常; * trace 质量追溯:回溯本批配方 + 投料原料批次 + 全部过程监控记录(满足有机/绿色认证追溯)。 * * 读侧聚合含原料成本/工艺参数,写口推进状态机,已登记进 SENSITIVE_READ_PREFIXES(/api/oa/ferment-batches)。 * 数量口径 double(与 BomItem/WorkOrder 一致),原料可用量扣减用 BigDecimal/Money。 */ @RestController @RequestMapping("/api/oa/ferment-batches") public class FermentBatchController { /** 标准工序列表(需求§4:各工序均生成工单)。 */ private static final String[] STD_PROCESSES = { "预处理", "发酵", "粉碎", "筛分", "造粒", "干燥", "冷却", "包膜", "包装" }; private final FermentBatchRepository batchRepo; private final FermentLogRepository logRepo; private final FertRecipeRepository recipeRepo; private final RecipeIngredientRepository ingredientRepo; private final FeedstockBatchRepository feedstockRepo; private final FertWorkSubOrderRepository subOrderRepo; public FermentBatchController(FermentBatchRepository batchRepo, FermentLogRepository logRepo, FertRecipeRepository recipeRepo, RecipeIngredientRepository ingredientRepo, FeedstockBatchRepository feedstockRepo, FertWorkSubOrderRepository subOrderRepo) { this.batchRepo = batchRepo; this.logRepo = logRepo; this.recipeRepo = recipeRepo; this.ingredientRepo = ingredientRepo; this.feedstockRepo = feedstockRepo; this.subOrderRepo = subOrderRepo; } // ---------- 发酵任务单 CRUD ---------- /** 列表行视图:批次 + 发酵天数 + 最新监控温度(看板展示)。 */ public record BatchView(FermentBatch batch, long dayCount, Double latestTemp, int logCount) { } @GetMapping public ApiResp> list(@RequestParam(required = false) String status, @RequestParam(required = false) Boolean alertOnly) { List batches; if (Boolean.TRUE.equals(alertOnly)) { batches = batchRepo.findByHasAlertTrue(); } else if (status != null && !status.isBlank()) { batches = batchRepo.findByStatus(status); } else { batches = batchRepo.findAll(); } LocalDate today = LocalDate.now(); List out = new ArrayList<>(); for (FermentBatch b : batches) { List logs = logRepo.findByBatchIdOrderByLogDateAscIdAsc(b.getId()); Double latestTemp = logs.isEmpty() ? null : logs.get(logs.size() - 1).getTemperature(); out.add(new BatchView(b, dayCount(b.getStartDate(), today), latestTemp, logs.size())); } return ApiResp.ok(out); } @GetMapping("/{id}") public ApiResp get(@PathVariable Long id) { return ApiResp.ok(batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id))); } public record BatchRequest( String batchNo, Long recipeId, String fermentMode, String trough, Double batchSize, String startDate, Integer fermentCycleDays, Double targetTemp, Double targetMoisture, Double turnFreqDays, Double targetGermIndex, String owner, String remark) { } @PostMapping public ApiResp create(@RequestBody BatchRequest req) { if (req.recipeId() == null) { throw new ApiException(400, "请选择配方(recipeId)"); } FertRecipe recipe = recipeRepo.findById(req.recipeId()) .orElseThrow(() -> new NotFoundException("recipe not found: " + req.recipeId())); if (!"生效".equals(recipe.getStatus())) { throw new ApiException(409, "只能用生效中的配方起发酵任务(当前配方状态:" + recipe.getStatus() + ")"); } FermentBatch b = new FermentBatch(); b.setBatchNo(req.batchNo() == null || req.batchNo().isBlank() ? "FJ-" + (batchRepo.count() + 1) : req.batchNo()); b.setRecipeId(recipe.getId()); b.setRecipeCode(recipe.getCode()); b.setProductName(recipe.getProductName()); b.setFermentMode(req.fermentMode() == null || req.fermentMode().isBlank() ? "条垛" : req.fermentMode()); b.setTrough(req.trough()); b.setBatchSize(req.batchSize() == null ? 0 : req.batchSize()); String start = req.startDate() == null || req.startDate().isBlank() ? LocalDate.now().toString() : req.startDate(); b.setStartDate(start); int cycle = req.fermentCycleDays() == null || req.fermentCycleDays() <= 0 ? 30 : req.fermentCycleDays(); LocalDate startDate = parseDateOrNull(start); b.setExpectEndDate(startDate == null ? null : startDate.plusDays(cycle).toString()); b.setTargetTemp(req.targetTemp() == null ? 60.0 : req.targetTemp()); b.setTargetMoisture(req.targetMoisture() == null ? 55.0 : req.targetMoisture()); b.setTurnFreqDays(req.turnFreqDays()); b.setTargetGermIndex(req.targetGermIndex() == null ? 80.0 : req.targetGermIndex()); b.setOwner(req.owner()); b.setRemark(req.remark()); b.setStatus("待投料"); b.setHasAlert(Boolean.FALSE); b.setCreatedAt(Instant.now()); return ApiResp.ok(batchRepo.save(b)); } @PatchMapping("/{id}") public ApiResp update(@PathVariable Long id, @RequestBody BatchRequest req) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); if (req.fermentMode() != null && !req.fermentMode().isBlank()) b.setFermentMode(req.fermentMode()); if (req.trough() != null) b.setTrough(req.trough()); if (req.batchSize() != null && "待投料".equals(b.getStatus())) b.setBatchSize(req.batchSize()); if (req.targetTemp() != null) b.setTargetTemp(req.targetTemp()); if (req.targetMoisture() != null) b.setTargetMoisture(req.targetMoisture()); if (req.turnFreqDays() != null) b.setTurnFreqDays(req.turnFreqDays()); if (req.targetGermIndex() != null) b.setTargetGermIndex(req.targetGermIndex()); if (req.owner() != null) b.setOwner(req.owner()); if (req.remark() != null) b.setRemark(req.remark()); return ApiResp.ok(batchRepo.save(b)); } @DeleteMapping("/{id}") @Transactional public ApiResp delete(@PathVariable Long id) { if (!batchRepo.existsById(id)) { throw new NotFoundException("ferment batch not found: " + id); } logRepo.deleteByBatchId(id); batchRepo.deleteById(id); return ApiResp.ok(null); } // ---------- 投料(按配方理论用量 + FIFO 消耗原料批次)---------- public record ChargeLineResult(String materialName, double theoreticalQty, double issuedQty, List consumedBatches, boolean enough) { } public record ChargeResult(Long batchId, String status, List lines, int shortItems) { } /** * 投料:按本批配方明细 × 批量算各原料理论用量,按 FIFO(到货日期升序,粪污易腐败先进先出) * 从合格在库原料批次({@link FeedstockBatch} status=合格/降级)逐批扣减可用量 receivedQty, * 记录消耗的原料批次号(成品←原料逆向追溯)。库存不足的项标记缺料但仍尽量投。投料后状态 → 投料完成。 */ @PostMapping("/{id}/charge") @Transactional public ApiResp charge(@PathVariable Long id) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); if (!"待投料".equals(b.getStatus())) { throw new ApiException(409, "只有「待投料」批次可投料(当前:" + b.getStatus() + ")"); } if (b.getRecipeId() == null) { throw new ApiException(400, "批次未关联配方,无法投料"); } List ings = ingredientRepo.findByRecipeIdOrderByIdAsc(b.getRecipeId()); if (ings.isEmpty()) { throw new ApiException(400, "配方无明细,无法生成配料指令"); } List results = new ArrayList<>(); int shortItems = 0; for (RecipeIngredient ing : ings) { double theoretical = b.getBatchSize() * ing.getRatioPct() / 100.0; theoretical = Math.round(theoretical * 10000.0) / 10000.0; // 菌剂接种量小,不强占原料库存(按理论量记录,不做扣减)。 if (ing.isInoculant()) { results.add(new ChargeLineResult(ing.getMaterialName(), theoretical, theoretical, new ArrayList<>(List.of("(菌剂接种,不占原料库)")), true)); continue; } BigDecimal need = Money.of(theoretical); BigDecimal issued = BigDecimal.ZERO; List consumed = new ArrayList<>(); // 合格 + 降级 批次都可投(降级原料可降级使用)。 List stock = new ArrayList<>(); stock.addAll(feedstockRepo.findByMaterialNameAndStatusOrderByArrivalDateAsc(ing.getMaterialName(), "合格")); stock.addAll(feedstockRepo.findByMaterialNameAndStatusOrderByArrivalDateAsc(ing.getMaterialName(), "降级")); stock.sort((x, y) -> safeDate(x.getArrivalDate()).compareTo(safeDate(y.getArrivalDate()))); for (FeedstockBatch fb : stock) { if (Money.lte0(Money.sub(need, issued))) { break; } BigDecimal avail = Money.nz(fb.getReceivedQty()); if (Money.lte0(avail)) { continue; } BigDecimal remain = Money.sub(need, issued); BigDecimal take = Money.gt(avail, remain) ? remain : avail; fb.setReceivedQty(Money.sub(avail, take)); feedstockRepo.save(fb); issued = Money.add(issued, take); consumed.add(fb.getBatchNo() + "(用" + take.stripTrailingZeros().toPlainString() + "吨)"); } boolean enough = !Money.gt(need, issued); if (!enough) { shortItems++; } if (consumed.isEmpty()) { consumed.add("(无合格原料库存)"); } results.add(new ChargeLineResult(ing.getMaterialName(), theoretical, issued.doubleValue(), consumed, enough)); } b.setStatus("投料完成"); batchRepo.save(b); return ApiResp.ok(new ChargeResult(id, b.getStatus(), results, shortItems)); } // ---------- 过程监控记录(超限自动预警)---------- @GetMapping("/{id}/logs") public ApiResp> logs(@PathVariable Long id) { return ApiResp.ok(logRepo.findByBatchIdOrderByLogDateAscIdAsc(id)); } public record LogRequest(String logDate, String shift, Double temperature, Double moisturePct, Double ph, Double oxygenPct, Integer turnTimes, Double ambientTemp, Boolean fromIot, String recorder) { } /** * 录入过程监控:自动对照批次工艺目标判超限——温度高于 目标+5℃ 或 含水率偏离 目标±10个百分点 → 预警。 * 首次录入把状态从「投料完成/待投料」推进到「发酵中」;任意超限把批次 hasAlert 置位。 */ @PostMapping("/{id}/logs") @Transactional public ApiResp addLog(@PathVariable Long id, @RequestBody LogRequest req) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); if ("已出料".equals(b.getStatus())) { throw new ApiException(409, "批次已出料,不能再录监控记录"); } FermentLog log = new FermentLog(); log.setBatchId(id); log.setBatchNo(b.getBatchNo()); log.setLogDate(req.logDate() == null || req.logDate().isBlank() ? LocalDate.now().toString() : req.logDate()); log.setShift(req.shift()); log.setTemperature(req.temperature()); log.setMoisturePct(req.moisturePct()); log.setPh(req.ph()); log.setOxygenPct(req.oxygenPct()); log.setTurnTimes(req.turnTimes()); log.setAmbientTemp(req.ambientTemp()); log.setFromIot(Boolean.TRUE.equals(req.fromIot())); log.setRecorder(req.recorder()); StringBuilder alert = new StringBuilder(); double targetTemp = b.getTargetTemp() == null ? 60 : b.getTargetTemp(); double targetMoisture = b.getTargetMoisture() == null ? 55 : b.getTargetMoisture(); if (req.temperature() != null && req.temperature() > targetTemp + 5) { alert.append("堆体温度 ").append(req.temperature()).append("℃ 超目标(").append(targetTemp).append("℃);"); } if (req.temperature() != null && req.temperature() < targetTemp - 15) { alert.append("堆体温度 ").append(req.temperature()).append("℃ 偏低,发酵可能停滞;"); } if (req.moisturePct() != null && Math.abs(req.moisturePct() - targetMoisture) > 10) { alert.append("含水率 ").append(req.moisturePct()).append("% 偏离目标(").append(targetMoisture).append("%);"); } boolean alerted = alert.length() > 0; log.setAlertFlag(alerted); log.setAlertText(alerted ? alert.toString() : null); log.setCreatedAt(Instant.now()); FermentLog saved = logRepo.save(log); boolean dirty = false; if ("投料完成".equals(b.getStatus()) || "待投料".equals(b.getStatus())) { b.setStatus("发酵中"); dirty = true; } if (alerted && !Boolean.TRUE.equals(b.getHasAlert())) { b.setHasAlert(Boolean.TRUE); dirty = true; } if (dirty) { batchRepo.save(b); } return ApiResp.ok(saved); } /** 清除批次预警标记(人工处置完超限后清红点)。 */ @PostMapping("/{id}/clear-alert") public ApiResp clearAlert(@PathVariable Long id) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); b.setHasAlert(Boolean.FALSE); return ApiResp.ok(batchRepo.save(b)); } // ---------- 腐熟度判定闸 ---------- /** 申请腐熟检测:发酵中 → 待腐熟检测。 */ @PostMapping("/{id}/to-maturity") public ApiResp toMaturity(@PathVariable Long id) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); if (!"发酵中".equals(b.getStatus())) { throw new ApiException(409, "只有「发酵中」批次可申请腐熟检测(当前:" + b.getStatus() + ")"); } b.setStatus("待腐熟检测"); return ApiResp.ok(batchRepo.save(b)); } public record MaturityRequest(Double germIndex, String maturityNote, String actualEndDate) { } /** * 腐熟度判定闸:录入种子发芽指数 germIndex,≥批次目标阈值(targetGermIndex,缺省80) 判合格 → 已出料, * 否则 → 异常(需继续发酵/再检)。合格出料前必须经此闸,对应需求"合格后方可转入下一工序"。 */ @PostMapping("/{id}/maturity") @Transactional public ApiResp maturity(@PathVariable Long id, @RequestBody MaturityRequest req) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); if (!"待腐熟检测".equals(b.getStatus()) && !"异常".equals(b.getStatus())) { throw new ApiException(409, "只有「待腐熟检测」或「异常」批次可做腐熟度判定(当前:" + b.getStatus() + ")"); } if (req.germIndex() == null) { throw new ApiException(400, "请录入种子发芽指数(germIndex)"); } b.setGermIndex(req.germIndex()); b.setMaturityNote(req.maturityNote()); double target = b.getTargetGermIndex() == null ? 80 : b.getTargetGermIndex(); if (req.germIndex() >= target) { b.setStatus("已出料"); b.setActualEndDate(req.actualEndDate() == null || req.actualEndDate().isBlank() ? LocalDate.now().toString() : req.actualEndDate()); } else { b.setStatus("异常"); } return ApiResp.ok(batchRepo.save(b)); } // ---------- 质量追溯(成品 → 配方 + 原料批次 + 过程记录)---------- public record TraceResult(FermentBatch batch, FertRecipe recipe, List ingredients, List logs, long dayCount, int alertCount) { } /** * 质量追溯聚合:给定发酵批次,逆向查询其配方、配方明细(投料原料构成)、全部过程监控记录与预警次数, * 满足有机认证/绿色认证追溯要求(需求 §5 质量追溯:成品批次号可逆向查询工序参数、原料、检验)。 */ @GetMapping("/{id}/trace") public ApiResp trace(@PathVariable Long id) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); FertRecipe recipe = b.getRecipeId() == null ? null : recipeRepo.findById(b.getRecipeId()).orElse(null); List ings = b.getRecipeId() == null ? new ArrayList<>() : ingredientRepo.findByRecipeIdOrderByIdAsc(b.getRecipeId()); List logs = logRepo.findByBatchIdOrderByLogDateAscIdAsc(id); int alertCount = (int) logs.stream().filter(l -> Boolean.TRUE.equals(l.getAlertFlag())).count(); return ApiResp.ok(new TraceResult(b, recipe, ings, logs, dayCount(b.getStartDate(), LocalDate.now()), alertCount)); } // ---------- 批量自动下达全套工序子工单(Gap2:联动端点)---------- /** * 按发酵批次自动批量生成标准工序子工单(预处理/发酵/粉碎/筛分/造粒/干燥/冷却/包膜/包装)。 * 幂等:若已存在该 parentBatchRef 的子工单则跳过已有工序,仅补建缺失工序。 * 需求:各工序均生成工单,状态统一「下达」,seq 按标准顺序。 */ @PostMapping("/{id}/gen-sub-orders") @Transactional public ApiResp> genSubOrders(@PathVariable Long id) { FermentBatch b = batchRepo.findById(id) .orElseThrow(() -> new NotFoundException("ferment batch not found: " + id)); String ref = b.getBatchNo(); // 查已有工序,幂等补建 List existing = subOrderRepo.findByParentBatchRefOrderBySeqAsc(ref); java.util.Set doneProcesses = new java.util.HashSet<>(); for (FertWorkSubOrder o : existing) { doneProcesses.add(o.getProcess()); } List created = new ArrayList<>(); String startDate = b.getStartDate() != null ? b.getStartDate() : LocalDate.now().toString(); for (int i = 0; i < STD_PROCESSES.length; i++) { String proc = STD_PROCESSES[i]; if (doneProcesses.contains(proc)) { continue; } FertWorkSubOrder w = new FertWorkSubOrder(); long seq2 = subOrderRepo.count() + created.size() + 1; w.setSubOrderNo("WSO-" + String.format("%06d", seq2)); w.setParentBatchRef(ref); w.setProductName(b.getProductName()); w.setProcess(proc); w.setSeq(i + 1); w.setPlanInputQty(Money.of((double) b.getBatchSize())); w.setPlanOutputQty(Money.of((double) b.getBatchSize() * 0.9)); w.setPlanStartDate(startDate); w.setPlanEndDate(b.getExpectEndDate()); w.setWipLocation("待分配"); w.setStatus("下达"); w.setCreatedAt(Instant.now()); subOrderRepo.save(w); created.add(w); } if (created.isEmpty()) { throw new ApiException(409, "该批次所有工序子工单均已生成,无需重复下达(共 " + existing.size() + " 条)"); } return ApiResp.ok(created); } // ---------- 移动端工位机报工 API(Gap2:移动专用简化接口)---------- public record MobileReportRequest(Long subOrderId, String worker, Double actualOutputQty, Double laborHours, String remark) { } /** * 移动端/工位机简化报工接口:工人扫码工序子工单后上报产量和工时(Gap2 移动API需求)。 * 简化:不要求前序状态必须是「加工」,会自动推进状态到「报工」(操作工流程不走桌面端多步流转)。 */ @PostMapping("/mobile-report") @Transactional public ApiResp mobileReport(@RequestBody MobileReportRequest req) { if (req.subOrderId() == null) { throw new ApiException(400, "工序子工单ID(subOrderId)不能为空"); } FertWorkSubOrder w = subOrderRepo.findById(req.subOrderId()) .orElseThrow(() -> new NotFoundException("工序子工单不存在:" + req.subOrderId())); if ("转序".equals(w.getStatus()) || "质检".equals(w.getStatus())) { throw new ApiException(409, "该工序已完成质检/转序,无法重复报工(当前状态:" + w.getStatus() + ")"); } if (req.actualOutputQty() == null || req.actualOutputQty() <= 0) { throw new ApiException(400, "实际产出量(actualOutputQty)必须大于0"); } w.setActualOutputQty(Money.of(req.actualOutputQty())); w.setLaborHours(req.laborHours()); if (req.laborHours() != null) { w.setLaborCost(Money.of(req.laborHours() * 30.0)); // 缺省时薪30元 } w.setWorker(req.worker()); w.setWipQty(Money.of(req.actualOutputQty())); if (req.remark() != null) { w.setReportRemark(req.remark()); } // 移动报工自动推进到「报工」状态 if ("下达".equals(w.getStatus()) || "领料".equals(w.getStatus()) || "加工".equals(w.getStatus())) { if (w.getActualStartDate() == null) { w.setActualStartDate(LocalDate.now().toString()); } w.setStatus("报工"); } return ApiResp.ok(subOrderRepo.save(w)); } // ---------- helpers ---------- private static long dayCount(String startDate, LocalDate today) { LocalDate start = parseDateOrNull(startDate); if (start == null) { return 0; } long d = ChronoUnit.DAYS.between(start, today); return Math.max(0, d); } private static LocalDate parseDateOrNull(String s) { if (s == null || s.isBlank()) { return null; } try { return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length()))); } catch (DateTimeParseException | IndexOutOfBoundsException e) { return null; } } /** 排序用:解析失败的日期排到最后("9999-..."),不让 FIFO 受脏数据干扰。 */ private static String safeDate(String s) { LocalDate d = parseDateOrNull(s); return d == null ? "9999-12-31" : d.toString(); } }