Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FermentBatchController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

537 lines
26 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}