feat: add bulk result persistence and macro scene selection

parent 2c825897
package com.aps.common.util;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
/** Shared JDBC batch insert helper for large result sets. */
@Service
public class JdbcBatchInsertService {
public static final int DEFAULT_BATCH_SIZE = 500;
private final DataSource dataSource;
public JdbcBatchInsertService(DataSource dataSource) {
this.dataSource = dataSource;
}
/** Executes multiple batch inserts on one connection and one transaction. */
public int executeInTransaction(TransactionWork work) {
try (Connection connection = dataSource.getConnection()) {
boolean previousAutoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try {
int count = work.execute(connection);
connection.commit();
connection.setAutoCommit(previousAutoCommit);
return count;
} catch (Exception e) {
connection.rollback();
connection.setAutoCommit(previousAutoCommit);
throw e;
}
} catch (Exception e) {
throw new IllegalStateException("JDBC transaction failed", e);
}
}
public <T> int batchInsert(String sql, List<T> rows, BatchBinder<T> binder) {
return batchInsert(sql, rows, DEFAULT_BATCH_SIZE, binder);
}
public <T> int batchInsert(String sql, List<T> rows, int batchSize, BatchBinder<T> binder) {
if (rows == null || rows.isEmpty()) {
return 0;
}
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be greater than zero");
}
try (Connection connection = dataSource.getConnection()) {
boolean previousAutoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try {
int count = batchInsert(connection, sql, rows, batchSize, binder);
connection.commit();
connection.setAutoCommit(previousAutoCommit);
return count;
} catch (Exception e) {
connection.rollback();
connection.setAutoCommit(previousAutoCommit);
throw e;
}
} catch (SQLException e) {
throw new IllegalStateException("JDBC batch insert failed", e);
}
}
public <T> int batchInsert(Connection connection, String sql, List<T> rows,
int batchSize, BatchBinder<T> binder) throws SQLException {
if (rows == null || rows.isEmpty()) {
return 0;
}
if (batchSize <= 0) {
throw new IllegalArgumentException("batchSize must be greater than zero");
}
int pending = 0;
int inserted = 0;
try (PreparedStatement statement = connection.prepareStatement(sql)) {
for (T row : rows) {
binder.bind(statement, row);
statement.addBatch();
pending++;
if (pending == batchSize) {
inserted += countBatch(statement.executeBatch());
pending = 0;
}
}
if (pending > 0) {
inserted += countBatch(statement.executeBatch());
}
}
return inserted;
}
private int countBatch(int[] results) {
int count = 0;
for (int result : results) {
if (result >= 0) {
count += result;
} else {
count++;
}
}
return count;
}
@FunctionalInterface
public interface BatchBinder<T> {
void bind(PreparedStatement statement, T row) throws SQLException;
}
@FunctionalInterface
public interface TransactionWork {
int execute(Connection connection) throws Exception;
}
}
......@@ -13,6 +13,7 @@ import com.aps.macroplanner.data.TestDataBuilder;
import com.aps.macroplanner.output.ResultWriter;
import com.aps.macroplanner.output.dto.*;
import com.aps.service.MacroPlannerResultService;
import com.aps.service.MpPispipResultPersistenceService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.google.ortools.Loader;
import io.swagger.v3.oas.annotations.Operation;
......@@ -60,6 +61,9 @@ import java.util.stream.Collectors;
@Slf4j
public class MacroPlannerResultController {
@Autowired
private MpPispipResultPersistenceService mpPispipResultPersistenceService;
@Autowired
private MacroPlannerResultService macroPlannerResultService;
......@@ -401,7 +405,11 @@ public class MacroPlannerResultController {
// 4. 保存结果到 JSON 文件
ResultWriter writer = new ResultWriter(optimizer.getModel(), optimizer.getData(), solveStart);
boolean saved = writer.saveResultToFile(sid);
OptimizationResult optimizationResult = writer.buildResult();
boolean saved = writer.saveResultToFile(sid, optimizationResult);
if (saved) {
mpPispipResultPersistenceService.save(sid, optimizationResult);
}
result.put("status", saved ? "SUCCESS" : "SAVE_FAILED");
result.put("solveElapsedMs", solveEnd - solveStart);
......
......@@ -7,7 +7,6 @@ import com.aps.macroplanner.scene.MacroSceneService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
......@@ -37,13 +36,20 @@ public class MacroSceneController {
return R.ok(macroSceneService.listScenes());
}
@PostMapping("/select")
@Operation(summary = "选择当前主计划场景")
public R<Boolean> select(@RequestBody java.util.Map<String, String> request) {
macroSceneService.selectScene(request.get("sceneId"), request.get("userId"));
return R.ok(true);
}
@GetMapping("/{sceneId}")
@Operation(summary = "查询主计划场景详情")
public R<MacroSceneConfig> get(@PathVariable String sceneId) {
return R.ok(macroSceneService.getScene(sceneId));
}
@DeleteMapping("/{sceneId}")
@PostMapping("/delete/{sceneId}")
@Operation(summary = "删除主计划场景", description = "删除场景配置及该场景下复制的数据")
public R<Boolean> delete(@PathVariable String sceneId) {
macroSceneService.deleteScene(sceneId);
......
......@@ -32,4 +32,6 @@ public class MacroSceneConfig {
private String updateUser;
@Schema(description = "更新时间")
private LocalDateTime updateTime;
@Schema(description = "当前选择该场景的用户 ID,多个用户用逗号分隔")
private String selectedUserIds;
}
......@@ -102,7 +102,10 @@ public class ResultWriter {
* 将结果保存到 JSON 文件
*/
public boolean saveResultToFile(String sceneId) {
OptimizationResult result = buildResult();
return saveResultToFile(sceneId, buildResult());
}
public boolean saveResultToFile(String sceneId, OptimizationResult result) {
if (result == null) {
logger.warn("对象不能为空");
return false;
......@@ -239,7 +242,7 @@ public class ResultWriter {
// ==================== 构建 OptimizationResult ====================
private OptimizationResult buildResult() {
public OptimizationResult buildResult() {
OptimizationResult result = new OptimizationResult();
// 元数据
......
package com.aps.macroplanner.scene;
import com.aps.entity.MacroSceneConfig;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@Service
public class MacroSceneSelectionService {
private final JdbcTemplate jdbc;
public MacroSceneSelectionService(DataSource dataSource) {
this.jdbc = new JdbcTemplate(dataSource);
}
@Transactional(rollbackFor = Exception.class)
public String select(String sceneId, String userId) {
return switchSelection(sceneId, userId, false);
}
@Transactional(rollbackFor = Exception.class)
public String completeAndSelect(String sceneId, String userId) {
return switchSelection(sceneId, userId, true);
}
private String switchSelection(String sceneId, String userId, boolean complete) {
validateUserId(userId);
if (sceneId == null || sceneId.trim().isEmpty()) {
throw new IllegalArgumentException("sceneId cannot be blank");
}
String scene = sceneId.trim();
String user = userId.trim();
// Serialize cross-row membership changes across all application instances.
jdbc.execute("LOCK TABLE MP_SCENE_CONFIG IN EXCLUSIVE MODE WAIT 5");
List<MacroSceneConfig> scenes = jdbc.query(
"SELECT SCENE_ID, SCENE_STATUS, SELECTED_USER_IDS FROM MP_SCENE_CONFIG",
new BeanPropertyRowMapper<>(MacroSceneConfig.class));
MacroSceneConfig target = scenes.stream().filter(s -> scene.equals(s.getSceneId()))
.findFirst().orElseThrow(() -> new IllegalArgumentException("scene does not exist: " + scene));
if (!(complete ? "COPYING" : "READY").equals(target.getSceneStatus())) {
throw new IllegalArgumentException("scene is not ready for selection: " + scene);
}
String selected = null;
for (MacroSceneConfig row : scenes) {
String updated = updatedUsers(row.getSelectedUserIds(), user, scene.equals(row.getSceneId()));
if (!Objects.equals(updated, row.getSelectedUserIds())) {
jdbc.update("UPDATE MP_SCENE_CONFIG SET SELECTED_USER_IDS = ? WHERE SCENE_ID = ?",
updated, row.getSceneId());
}
if (scene.equals(row.getSceneId())) selected = updated;
}
if (complete) {
jdbc.update("UPDATE MP_SCENE_CONFIG SET SCENE_STATUS = 'READY', UPDATE_TIME = CURRENT_TIMESTAMP "
+ "WHERE SCENE_ID = ?", scene);
}
return selected;
}
static void validateUserId(String userId) {
if (userId == null || userId.trim().isEmpty() || userId.contains(",")
|| userId.trim().getBytes(StandardCharsets.UTF_8).length > 2000) {
throw new IllegalArgumentException("userId must be a nonblank single ID, at most 2000 bytes");
}
}
static String updatedUsers(String value, String user, boolean selected) {
Set<String> users = new LinkedHashSet<>();
if (value != null) {
for (String id : value.split(",")) {
String normalized = id.trim();
if (!normalized.isEmpty()) users.add(normalized);
}
}
if (selected) users.add(user); else users.remove(user);
String updated = String.join(",", users);
if (updated.getBytes(StandardCharsets.UTF_8).length > 2000) {
throw new IllegalArgumentException("selected user IDs exceed the scene field capacity");
}
return updated.isEmpty() ? null : updated;
}
}
......@@ -49,6 +49,7 @@ public class MacroSceneService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final DataSource dataSource;
private final MacroSceneSelectionService selectionService;
private final MacroSceneConfigMapper sceneConfigMapper;
private final MacroSceneSequenceMapper sequenceMapper;
private final ApsDemandOrderMapper apsDemandOrderMapper;
......@@ -76,6 +77,10 @@ public class MacroSceneService {
return sceneConfigMapper.selectById(requireText(sceneId, "sceneId"));
}
public void selectScene(String sceneId, String userId) {
selectionService.select(sceneId, userId);
}
public synchronized MacroSceneConfig createScene(MacroSceneCreateRequest request) {
long createStartNanos = System.nanoTime();
if (request == null) {
......@@ -83,6 +88,7 @@ public class MacroSceneService {
}
String sceneName = requireText(request.getSceneName(), "sceneName");
String userId = requireText(request.getUserId(), "userId");
MacroSceneSelectionService.validateUserId(userId);
String sourceSceneId = normalize(request.getSourceSceneId());
if (sourceSceneId != null && sceneConfigMapper.selectById(sourceSceneId) == null) {
throw new IllegalArgumentException("source macro scene does not exist: " + sourceSceneId);
......@@ -110,9 +116,10 @@ public class MacroSceneService {
try {
cloneSceneData(sourceSceneId, sceneId);
String selectedUsers = selectionService.completeAndSelect(sceneId, userId);
config.setSceneStatus("READY");
config.setUpdateTime(LocalDateTime.now());
sceneConfigMapper.updateById(config);
config.setSelectedUserIds(selectedUsers);
long durationMs = elapsedMillis(createStartNanos);
log.info("主计划场景创建完成,场景ID:{},场景名称:{},耗时:{}毫秒",
sceneId, sceneName, durationMs);
......
package com.aps.service;
import com.aps.common.util.JdbcBatchInsertService;
import com.aps.macroplanner.output.dto.OptimizationResult;
import com.aps.macroplanner.output.dto.PispipResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.Types;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class MpPispipResultPersistenceService {
private static final int BATCH_SIZE = 500;
private final DataSource dataSource;
private final JdbcBatchInsertService batch;
public int save(String sceneId, OptimizationResult result) throws Exception {
if (result == null || result.getPispips() == null || result.getPispips().isEmpty()) return 0;
String parent = "INSERT INTO MP_PISPIP_RESULT (PISPIP_ID,SCENE_ID,PRODUCT_ID,PRODUCT_CODE,SP_ID,SP_NAME,PERIOD_INDEX,PERIOD_START_DATE,OPENING_INVENTORY,ENDING_INVENTORY,TARGET_INVENTORY_LEVEL,TARGET_INVENTORY_DAYS,BELOW_TARGET,MIN_INVENTORY_LEVEL,MIN_INVENTORY_DAYS,BELOW_MIN,MAX_INVENTORY_LEVEL,MAX_INVENTORY_DAYS,ABOVE_MAX,PRODUCTION_ARRIVED,IN_TRANSIT_ARRIVAL,TOTAL_INFLOW,SALES_DEMAND_QTY,SALES_FULFILLED_QTY,DEPENDENT_DEMAND_QTY,TOTAL_OUTFLOW,DEMAND_FULFILLMENT,DEMAND_SLACK,PRODUCTION_IN_PROGRESS) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
String prod = "INSERT INTO MP_PISPIP_PRODUCTION (PRODUCTION_ID,PISPIP_ID,SCENE_ID,OPERATION_ID,OPERATION_NAME,UNIT_ID,UNIT_NAME,QUANTITY,LEAD_TIME_DAYS) VALUES (?,?,?,?,?,?,?,?,?)";
String bom = "INSERT INTO MP_PISPIP_BOM (BOM_ID,PRODUCTION_ID,PISPIP_ID,SCENE_ID,INPUT_PRODUCT_ID,INPUT_SP_ID,INPUT_SP_NAME,FACTOR,CONSUMED_QTY) VALUES (?,?,?,?,?,?,?,?,?)";
Map<PispipResult,String> ids = new IdentityHashMap<>();
List<PispipResult.ProductionDetail> details = new ArrayList<>();
List<PispipResult.BomConsumption> boms = new ArrayList<>();
Map<Object,String> detailIds = new IdentityHashMap<>();
int saved = batch.executeInTransaction(c -> {
batch.batchInsert(c, parent, result.getPispips(), BATCH_SIZE, (s,p) -> bindParent(s,p,sceneId,ids));
for (PispipResult p : result.getPispips()) for (PispipResult.ProductionDetail d : p.getProductionDetails()) { details.add(d); detailIds.put(d, UUID.randomUUID().toString()); boms.addAll(d.bomConsumptions); }
batch.batchInsert(c, prod, details, BATCH_SIZE, (s,d) -> { String pid = owner(ids,d); s.setString(1,detailIds.get(d)); s.setString(2,pid); s.setString(3,sceneId); s.setString(4,d.operationId); s.setString(5,d.operationName); s.setString(6,d.unitId); s.setString(7,d.unitName); s.setDouble(8,d.quantity); s.setInt(9,d.leadTimeDays); });
batch.batchInsert(c, bom, boms, BATCH_SIZE, (s,b) -> { String did = detailIds.get(findDetail(details,b)); String pid = owner(ids,findDetail(details,b)); s.setString(1,UUID.randomUUID().toString()); s.setString(2,did); s.setString(3,pid); s.setString(4,sceneId); s.setString(5,b.inputProductId); s.setString(6,b.inputSpId); s.setString(7,b.inputSpName); s.setDouble(8,b.factor); s.setDouble(9,b.consumedQty); });
return ids.size();
});
log.info("Saved PISPIP result: sceneId={}, parents={}, productions={}, boms={}",sceneId,ids.size(),details.size(),boms.size());
return saved;
}
private String owner(Map<PispipResult,String> ids,Object d){ for(Map.Entry<PispipResult,String> e:ids.entrySet()) if(e.getKey().getProductionDetails().contains(d)) return e.getValue(); return null; }
private PispipResult.ProductionDetail findDetail(List<PispipResult.ProductionDetail> ds,PispipResult.BomConsumption b){ for(PispipResult.ProductionDetail d:ds) if(d.bomConsumptions.contains(b)) return d; return null; }
private void bindParent(PreparedStatement s,PispipResult p,String scene,Map<PispipResult,String> ids) throws java.sql.SQLException { ids.put(p,UUID.randomUUID().toString()); int i=1; s.setString(i++,ids.get(p)); s.setString(i++,scene); s.setString(i++,p.getProductId()); s.setString(i++,p.getProductCode()); s.setString(i++,p.getSpId()); s.setString(i++,p.getSpName()); s.setInt(i++,p.getPeriodIndex()); s.setDate(i++,date(p.getPeriodStartDate())); for(Double v:new Double[]{p.getOpeningInventory(),p.getEndingInventory(),p.getTargetInventoryLevel(),p.getTargetInventoryDays(),p.getBelowTarget(),p.getMinInventoryLevel(),p.getMinInventoryDays(),p.getBelowMin(),p.getMaxInventoryLevel(),p.getMaxInventoryDays(),p.getAboveMax(),p.getProductionArrived(),p.getInTransitArrival(),p.getTotalInflow(),p.getSalesDemandQty(),p.getSalesFulfilledQty(),p.getDependentDemandQty(),p.getTotalOutflow(),p.getDemandFulfillment(),p.getDemandSlack(),p.getProductionInProgress()}){if(v==null)s.setNull(i++,Types.NUMERIC);else s.setDouble(i++,v);}}
private Date date(String v){return v==null||v.trim().isEmpty()?null:Date.valueOf(LocalDate.parse(v));}
}
package com.aps.macroplanner.scene;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MacroSceneSelectionServiceTest {
@Test
void appendsWithoutLosingOtherUsers() {
assertEquals("111,222,333", MacroSceneSelectionService.updatedUsers("111,222", "333", true));
}
@Test
void repeatedSelectionIsIdempotent() {
assertEquals("111,222", MacroSceneSelectionService.updatedUsers("111,222", "111", true));
}
@Test
void removesOnlyExactUserAndClearsLastUser() {
assertEquals("1111,222", MacroSceneSelectionService.updatedUsers("111,1111,222", "111", false));
assertNull(MacroSceneSelectionService.updatedUsers("111", "111", false));
}
@Test
void handlesOriginalNullAndDuplicateIds() {
assertEquals("111", MacroSceneSelectionService.updatedUsers(null, "111", true));
assertEquals("111,222", MacroSceneSelectionService.updatedUsers("111,111, 222", "111", true));
}
@Test
void rejectsInvalidUsersAndOverflow() {
assertThrows(IllegalArgumentException.class, () -> MacroSceneSelectionService.validateUserId("111,222"));
assertThrows(IllegalArgumentException.class, () -> MacroSceneSelectionService.validateUserId(" "));
String full = String.join("", java.util.Collections.nCopies(2000, "a"));
assertThrows(IllegalArgumentException.class, () -> MacroSceneSelectionService.updatedUsers(full, "111", true));
}
}
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