feat: support default macro scene mode and clean scene results

parent 7d6de509
......@@ -61,6 +61,9 @@ import java.util.stream.Collectors;
@Slf4j
public class MacroPlannerResultController {
@Autowired
private com.aps.macroplanner.scene.MacroSceneMode sceneMode;
@Autowired
private MpPispipResultPersistenceService mpPispipResultPersistenceService;
......@@ -81,10 +84,7 @@ public class MacroPlannerResultController {
* @return OptimizationResult, 或 null (需要调用方返回 R.failed)
*/
private OptimizationResult loadResult(String sceneId) {
if (sceneId == null || sceneId.trim().isEmpty()) {
return null;
}
return macroPlannerResultService.getResult(sceneId.trim());
return macroPlannerResultService.getResult(sceneMode.resolve(sceneId));
}
// ==================== 拆分接口 ====================
......@@ -112,7 +112,7 @@ public class MacroPlannerResultController {
description = "订单级需求满足明细: 每条SalesDemand的需求量/满足量/缺口/满足率/优先级。"
+ "支持按 productId / periodIndex 可选过滤。")
public R<Map<String, Object>> getSalesDemands(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId,
@RequestParam(value = "sceneId", required = false) @Parameter(description = "场景ID,默认场景不传") String sceneId,
@RequestParam(value = "productId", required = false) @Parameter(description = "按产品ID过滤(可选)") String productId,
@RequestParam(value = "periodIndex", required = false) @Parameter(description = "按周期索引过滤(可选)") Integer periodIndex) {
OptimizationResult result = loadResult(sceneId);
......@@ -178,7 +178,7 @@ public class MacroPlannerResultController {
+ "数据来源: PispipResult(库存点库存) + SalesDemandResult(销售需求)。"
+ "注: 如需订单级需求满足明细, 请使用 /salesDemands 接口。")
public R<Map<String, Object>> getSupplyChain(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId) {
@RequestParam(value = "sceneId", required = false) @Parameter(description = "场景ID,默认场景不传") String sceneId) {
OptimizationResult result = loadResult(sceneId);
if (result == null) {
return R.failed("未找到场景 " + sceneId + " 的排产结果文件");
......@@ -212,7 +212,7 @@ public class MacroPlannerResultController {
+ "与 supplyChain 中的 productSummaries (跨周期求和) 不同, "
+ "此接口返回每周期独立数据, 适合前端展开查看各周期详情。")
public R<Map<String, Object>> getProductSummary(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId) {
@RequestParam(value = "sceneId", required = false) @Parameter(description = "场景ID,默认场景不传") String sceneId) {
OptimizationResult result = loadResult(sceneId);
if (result == null) {
return R.failed("未找到场景 " + sceneId + " 的排产结果文件");
......@@ -271,7 +271,7 @@ public class MacroPlannerResultController {
description = "按设备(unitId)聚合各周期的产能使用量(小时)与生产任务明细。"
+ "数据来源: PeriodTaskResult(每工序-设备-周期的生产量和产能占比)。")
public R<Map<String, Object>> getUnitCapacity(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId) {
@RequestParam(value = "sceneId", required = false) @Parameter(description = "场景ID,默认场景不传") String sceneId) {
OptimizationResult result = loadResult(sceneId);
if (result == null) {
return R.failed("未找到场景 " + sceneId + " 的排产结果文件");
......@@ -300,7 +300,7 @@ public class MacroPlannerResultController {
+ "包含各层级的供应来源(含每周期产量)、消耗者、BOM子物料(含每周期消耗量)。"
+ "支持按 productId / spId / unitId / periodIndex 可选过滤。")
public R<SupplyChainNode> getProductNetwork(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId,
@RequestParam(value = "sceneId", required = false) @Parameter(description = "场景ID,默认场景不传") String sceneId,
@RequestParam(value = "productId", required = false) @Parameter(description = "按产品ID过滤成品根节点(可选)") String productId,
@RequestParam(value = "spId", required = false) @Parameter(description = "按库存点ID过滤(可选)") String spId,
@RequestParam(value = "period", required = false) @Parameter(description = "按周期过滤, 仅保留该周期内有供应的节点/来源(可选)") String period) {
......@@ -331,7 +331,7 @@ public class MacroPlannerResultController {
description = "KPI目标值与各子项明细(需求缺口/产能超载/批次偏差/库存偏差等) + "
+ "求解器状态/变量规模/约束规模/耗时/gap。")
public R<Map<String, Object>> getSummary(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId) {
@RequestParam(value = "sceneId", required = false) @Parameter(description = "场景ID,默认场景不传") String sceneId) {
OptimizationResult result = loadResult(sceneId);
if (result == null) {
return R.failed("未找到场景 " + sceneId + " 的排产结果文件");
......@@ -371,11 +371,8 @@ public class MacroPlannerResultController {
description = "执行数据库转换→数据验证→MIP建模→分层求解→JSON结果保存的完整流程。"
+ "耗时通常数秒到数分钟, 取决于数据规模。")
public R<Map<String, Object>> runOptimization(
@RequestParam("sceneId") @Parameter(description = "场景ID", required = true) String sceneId) {
if (sceneId == null || sceneId.trim().isEmpty()) {
return R.failed("sceneId不能为空");
}
String sid = sceneId.trim();
@RequestParam(value = "sceneId", required = false) @Parameter(description = "场景ID,默认场景不传") String sceneId) {
String sid = sceneMode.resolve(sceneId);
Map<String, Object> result = new LinkedHashMap<>();
result.put("sceneId", sid);
long t0 = System.currentTimeMillis();
......@@ -400,7 +397,7 @@ public class MacroPlannerResultController {
long solveStart = System.currentTimeMillis();
MacroPlannerOptimizer optimizer = new MacroPlannerOptimizer(data);
optimizer.buildModel();
optimizer.solve();
optimizer.solve(sid);
long solveEnd = System.currentTimeMillis();
// 4. 保存结果到 JSON 文件
......@@ -414,7 +411,8 @@ public class MacroPlannerResultController {
result.put("status", saved ? "SUCCESS" : "SAVE_FAILED");
result.put("solveElapsedMs", solveEnd - solveStart);
result.put("totalElapsedMs", System.currentTimeMillis() - t0);
result.put("resultFile", "mp/result/optimization_result_" + sid + ".json");
result.put("resultFile", "mp/result/optimization_result_"
+ com.aps.macroplanner.scene.MacroSceneMode.resultFileKey(sid) + ".json");
return R.ok(result);
} catch (Exception e) {
......
......@@ -23,6 +23,17 @@ import java.util.List;
public class MacroSceneController {
private final MacroSceneService macroSceneService;
private final com.aps.macroplanner.scene.MacroSceneMode sceneMode;
@GetMapping("/capabilities")
@Operation(summary = "查询主计划场景管理能力")
public R<java.util.Map<String, Boolean>> capabilities() {
java.util.Map<String, Boolean> result = new java.util.LinkedHashMap<>();
result.put("defaultOnly", sceneMode.isDefaultOnly());
result.put("canCreate", !sceneMode.isDefaultOnly());
result.put("canDelete", !sceneMode.isDefaultOnly());
return R.ok(result);
}
@PostMapping("/create")
@Operation(summary = "创建主计划场景", description = "从原始数据或已有主计划场景复制创建新场景")
......
......@@ -168,7 +168,7 @@ public class MacroPlannerDataConverter {
// 0. 读取时间配置, 计算 horizonEnd
// endCount = 期数 (与 periodDimension 结合决定实际天数)
LambdaQueryWrapper<ApsTimeConfig> wrapper= new LambdaQueryWrapper<ApsTimeConfig>();
if(!sceneId.isEmpty())
if(sceneId != null && !sceneId.isEmpty())
{
// wrapper.eq(ApsTimeConfig::getMpSceneId,sceneId);
}
......
......@@ -81,7 +81,7 @@ public class ResultWriter {
}
private File getResultDirectory() {
File resultDir = new File("mp/result");
File resultDir = Paths.get(System.getProperty("user.dir"), "mp/result").toFile();
if (!resultDir.exists()) {
boolean created = resultDir.mkdirs();
if (!created) {
......@@ -93,7 +93,8 @@ public class ResultWriter {
}
private File getOptimizationFile(String sceneId) {
File resultDir = getResultDirectory();
String fileName = "optimization_result_" + sceneId + getFileExtension();
String fileName = "optimization_result_"
+ com.aps.macroplanner.scene.MacroSceneMode.resultFileKey(sceneId) + getFileExtension();
return new File(resultDir, fileName);
}
// ==================== 主入口 ====================
......@@ -111,10 +112,6 @@ public class ResultWriter {
return false;
}
if (sceneId == null || sceneId.trim().isEmpty()) {
logger.warn("场景ID不能为空");
return false;
}
try {
......@@ -174,10 +171,6 @@ public class ResultWriter {
* 从文件中读取 Chromosome 对象,并拆分文件读取与反序列化耗时。
*/
public OptimizationResult getResultToFile(String sceneId) {
if (sceneId == null || sceneId.trim().isEmpty()) {
logger.warn("场景ID不能为空");
return null;
}
try {
long totalStart = System.nanoTime();
......
package com.aps.macroplanner.scene;
import com.aps.entity.MacroSceneConfig;
import org.springframework.stereotype.Component;
@Component
public class MacroSceneMode {
// Temporary test mode. Set to false and rebuild to restore scene management.
private static final boolean DEFAULT_ONLY = true;
private final boolean defaultOnly;
public MacroSceneMode() {
this(DEFAULT_ONLY);
}
MacroSceneMode(boolean defaultOnly) {
this.defaultOnly = defaultOnly;
}
public boolean isDefaultOnly() { return defaultOnly; }
public String resolve(String sceneId) {
if (defaultOnly) return null;
String id = sceneId == null || sceneId.trim().isEmpty() ? null : sceneId.trim();
if (id == null) {
throw new IllegalArgumentException("sceneId不能为空");
}
return id;
}
public void requireSceneManagement() {
if (defaultOnly) {
throw new IllegalArgumentException("当前仅支持默认场景,暂不支持创建或删除");
}
}
public MacroSceneConfig defaultScene() {
MacroSceneConfig scene = new MacroSceneConfig();
scene.setSceneId("default");
scene.setSceneName("默认场景");
scene.setSceneStatus("READY");
return scene;
}
public static String resultFileKey(String sceneId) {
return sceneId == null || sceneId.trim().isEmpty() ? "default" : sceneId.trim();
}
}
......@@ -12,6 +12,12 @@ import java.io.IOException;
@Component
public class MacroSceneRequestFilter extends OncePerRequestFilter {
private final MacroSceneMode sceneMode;
public MacroSceneRequestFilter(MacroSceneMode sceneMode) {
this.sceneMode = sceneMode;
}
public static final String HEADER_NAME = "X-MP-Scene-Id";
@Override
......@@ -36,6 +42,9 @@ public class MacroSceneRequestFilter extends OncePerRequestFilter {
sceneId = request.getParameter("sceneId");
}
try {
if (sceneMode.isDefaultOnly() && request.getServletPath().startsWith("/macroResult")) {
sceneId = null;
}
MacroSceneContext.setSceneId(sceneId);
filterChain.doFilter(request, response);
} finally {
......
......@@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
......@@ -49,6 +50,8 @@ public class MacroSceneService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final DataSource dataSource;
private final MacroSceneMode sceneMode;
private final JdbcTemplate jdbcTemplate;
private final MacroSceneSelectionService selectionService;
private final MacroSceneConfigMapper sceneConfigMapper;
private final MacroSceneSequenceMapper sequenceMapper;
......@@ -70,18 +73,29 @@ public class MacroSceneService {
private final ApsTimeConfigMapper apsTimeConfigMapper;
public List<MacroSceneConfig> listScenes() {
if (sceneMode.isDefaultOnly()) return java.util.Collections.singletonList(sceneMode.defaultScene());
return sceneConfigMapper.selectList(null);
}
public MacroSceneConfig getScene(String sceneId) {
if (sceneMode.isDefaultOnly()) {
sceneMode.resolve(sceneId);
return sceneMode.defaultScene();
}
return sceneConfigMapper.selectById(requireText(sceneId, "sceneId"));
}
public void selectScene(String sceneId, String userId) {
if (sceneMode.isDefaultOnly()) {
sceneMode.resolve(sceneId);
MacroSceneSelectionService.validateUserId(userId);
return;
}
selectionService.select(sceneId, userId);
}
public synchronized MacroSceneConfig createScene(MacroSceneCreateRequest request) {
sceneMode.requireSceneManagement();
long createStartNanos = System.nanoTime();
if (request == null) {
throw new IllegalArgumentException("request cannot be null");
......@@ -141,6 +155,7 @@ public class MacroSceneService {
@Transactional(rollbackFor = Exception.class)
public void deleteScene(String sceneId) {
sceneMode.requireSceneManagement();
String normalizedSceneId = requireText(sceneId, "sceneId");
if (sceneConfigMapper.selectById(normalizedSceneId) == null) {
return;
......@@ -150,6 +165,7 @@ public class MacroSceneService {
}
private void cleanupSceneData(String sceneId) {
cleanupPispipResults(sceneId);
MacroSceneContext.execute(sceneId, () -> {
apsDemandOrderMapper.delete(null);
routingDetailEquipMapper.delete(null);
......@@ -170,6 +186,14 @@ public class MacroSceneService {
});
}
private void cleanupPispipResults(String sceneId) {
// Foreign keys require children to be removed before the PISPIP parent rows.
jdbcTemplate.update("DELETE FROM MP_PISPIP_BOM WHERE SCENE_ID = ?", sceneId);
jdbcTemplate.update("DELETE FROM MP_PISPIP_PRODUCTION WHERE SCENE_ID = ?", sceneId);
jdbcTemplate.update("DELETE FROM MP_PISPIP_RESULT WHERE SCENE_ID = ?", sceneId);
log.info("已清理主计划排产结果,场景ID:{}", sceneId);
}
private void cloneSceneData(String sourceSceneId, String targetSceneId) {
Map<Integer, Integer> equipIds = copyEquipinfo(sourceSceneId, targetSceneId);
Map<Integer, Integer> resourceIds = copyPlanResources(sourceSceneId, targetSceneId, equipIds);
......
......@@ -47,10 +47,6 @@ public class MacroPlannerResultService {
* @return 反序列化后的优化结果, 或 null (文件不存在/读取失败)
*/
public OptimizationResult getResult(String sceneId) {
if (sceneId == null || sceneId.isEmpty()) {
log.warn("sceneId为空, 无法读取结果");
return null;
}
File file = getOptimizationFile(sceneId);
if (file == null || !file.exists() || file.length() == 0) {
......@@ -73,6 +69,7 @@ public class MacroPlannerResultService {
* 定位结果文件, 优先 .json, 其次 .json.gz。
*/
private File getOptimizationFile(String sceneId) {
sceneId = com.aps.macroplanner.scene.MacroSceneMode.resultFileKey(sceneId);
String workDir = System.getProperty("user.dir");
Path resultDir = Paths.get(workDir, OUTPUT_DIR);
......
package com.aps.macroplanner.scene;
import com.aps.entity.MacroSceneConfig;
import com.aps.mapper.MacroSceneConfigMapper;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class MacroSceneDefaultModeTest {
@Test
void defaultResultsRoundTrip(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) {
String oldDir = System.getProperty("user.dir");
try {
System.setProperty("user.dir", dir.toString());
com.aps.macroplanner.output.ResultWriter writer =
new com.aps.macroplanner.output.ResultWriter(null, null, 0);
com.aps.macroplanner.output.dto.OptimizationResult result =
new com.aps.macroplanner.output.dto.OptimizationResult();
assertTrue(writer.saveResultToFile(null, result));
assertTrue(java.nio.file.Files.exists(dir.resolve("mp/result/optimization_result_default.json")));
assertNotNull(writer.getResultToFile(null));
assertNotNull(new com.aps.service.MacroPlannerResultService().getResult(null));
} finally {
System.setProperty("user.dir", oldDir);
}
}
@Test
void resolvesAllIdsToBaselineInDefaultMode() {
MacroSceneMode mode = new MacroSceneMode(true);
assertNull(mode.resolve(null));
assertNull(mode.resolve(" "));
for (String id : new String[]{"null", "1", "default", "scene-001"}) {
assertNull(mode.resolve(id));
}
assertEquals("default", MacroSceneMode.resultFileKey(null));
assertEquals("scene-001", MacroSceneMode.resultFileKey("scene-001"));
}
@Test
void multiSceneModeRestoresNonemptyIdsAndManagement() {
MacroSceneMode mode = new MacroSceneMode(false);
assertEquals("scene-001", mode.resolve(" scene-001 "));
assertThrows(IllegalArgumentException.class, () -> mode.resolve(null));
assertDoesNotThrow(mode::requireSceneManagement);
}
@Test
void defaultSceneDoesNotAccessDatabaseAndManagementIsBlocked() {
MacroSceneService service = mock(MacroSceneService.class, CALLS_REAL_METHODS);
MacroSceneConfigMapper mapper = mock(MacroSceneConfigMapper.class);
ReflectionTestUtils.setField(service, "sceneMode", new MacroSceneMode(true));
ReflectionTestUtils.setField(service, "sceneConfigMapper", mapper);
MacroSceneConfig scene = service.listScenes().get(0);
assertEquals("default", scene.getSceneId());
assertNull(new MacroSceneMode(true).resolve(scene.getSceneId()));
assertEquals("默认场景", scene.getSceneName());
assertEquals("READY", scene.getSceneStatus());
service.selectScene(null, "1111");
assertDoesNotThrow(() -> service.selectScene("1", "1111"));
assertEquals("default", service.getScene("old-scene").getSceneId());
assertThrows(IllegalArgumentException.class, () -> service.createScene(null));
assertThrows(IllegalArgumentException.class, () -> service.deleteScene("scene-001"));
verifyNoInteractions(mapper);
}
@Test
void staleHeaderCannotChangeDefaultResultScope() throws Exception {
MacroSceneRequestFilter filter = new MacroSceneRequestFilter(new MacroSceneMode(true));
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/macroResult/run");
request.setServletPath("/macroResult/run");
request.addHeader("X-MP-Scene-Id", "old-scene");
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
assertNull(MacroSceneContext.getSceneId());
});
assertNull(MacroSceneContext.getSceneId());
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment