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:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -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();
}
}