SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
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<BatchView>> list(@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) Boolean alertOnly) {
|
||||
List<FermentBatch> 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<BatchView> out = new ArrayList<>();
|
||||
for (FermentBatch b : batches) {
|
||||
List<FermentLog> 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<FermentBatch> 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<FermentBatch> 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<FermentBatch> 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<Void> 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<String> consumedBatches, boolean enough) {
|
||||
}
|
||||
|
||||
public record ChargeResult(Long batchId, String status, List<ChargeLineResult> lines,
|
||||
int shortItems) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 投料:按本批配方明细 × 批量算各原料理论用量,按 FIFO(到货日期升序,粪污易腐败先进先出)
|
||||
* 从合格在库原料批次({@link FeedstockBatch} status=合格/降级)逐批扣减可用量 receivedQty,
|
||||
* 记录消耗的原料批次号(成品←原料逆向追溯)。库存不足的项标记缺料但仍尽量投。投料后状态 → 投料完成。
|
||||
*/
|
||||
@PostMapping("/{id}/charge")
|
||||
@Transactional
|
||||
public ApiResp<ChargeResult> 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<RecipeIngredient> ings = ingredientRepo.findByRecipeIdOrderByIdAsc(b.getRecipeId());
|
||||
if (ings.isEmpty()) {
|
||||
throw new ApiException(400, "配方无明细,无法生成配料指令");
|
||||
}
|
||||
List<ChargeLineResult> 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<String> consumed = new ArrayList<>();
|
||||
// 合格 + 降级 批次都可投(降级原料可降级使用)。
|
||||
List<FeedstockBatch> 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<List<FermentLog>> 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<FermentLog> 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<FermentBatch> 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<FermentBatch> 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<FermentBatch> 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<RecipeIngredient> ingredients,
|
||||
List<FermentLog> logs, long dayCount, int alertCount) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 质量追溯聚合:给定发酵批次,逆向查询其配方、配方明细(投料原料构成)、全部过程监控记录与预警次数,
|
||||
* 满足有机认证/绿色认证追溯要求(需求 §5 质量追溯:成品批次号可逆向查询工序参数、原料、检验)。
|
||||
*/
|
||||
@GetMapping("/{id}/trace")
|
||||
public ApiResp<TraceResult> 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<RecipeIngredient> ings = b.getRecipeId() == null ? new ArrayList<>()
|
||||
: ingredientRepo.findByRecipeIdOrderByIdAsc(b.getRecipeId());
|
||||
List<FermentLog> 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<List<FertWorkSubOrder>> genSubOrders(@PathVariable Long id) {
|
||||
FermentBatch b = batchRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("ferment batch not found: " + id));
|
||||
String ref = b.getBatchNo();
|
||||
// 查已有工序,幂等补建
|
||||
List<FertWorkSubOrder> existing = subOrderRepo.findByParentBatchRefOrderBySeqAsc(ref);
|
||||
java.util.Set<String> doneProcesses = new java.util.HashSet<>();
|
||||
for (FertWorkSubOrder o : existing) {
|
||||
doneProcesses.add(o.getProcess());
|
||||
}
|
||||
List<FertWorkSubOrder> 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<FertWorkSubOrder> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user