package cn.iocoder.yudao.module.mes.service.pro.task.schedule; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.crm.api.customer.CrmCustomerApi; import cn.iocoder.yudao.module.mes.controller.admin.pro.task.vo.*; import cn.iocoder.yudao.module.mes.dal.dataobject.dv.machinery.MesDvMachineryDO; import cn.iocoder.yudao.module.mes.dal.dataobject.md.item.MesMdItemDO; import cn.iocoder.yudao.module.mes.dal.dataobject.md.workstation.MesMdWorkstationDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.process.MesProProcessDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.route.MesProRouteDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.task.MesProTaskDO; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.workorder.*; import cn.iocoder.yudao.module.mes.dal.mysql.dv.machinery.MesDvMachineryMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.task.MesProTaskMapper; import cn.iocoder.yudao.module.mes.dal.mysql.pro.workorder.MesProWorkOrderMapper; import cn.iocoder.yudao.module.mes.service.cal.holiday.MesCalHolidayService; import cn.iocoder.yudao.module.mes.service.md.item.MesMdItemService; import cn.iocoder.yudao.module.mes.service.md.workstation.MesMdWorkstationService; import cn.iocoder.yudao.module.mes.service.pro.process.MesProProcessService; import cn.iocoder.yudao.module.mes.service.pro.route.MesProRouteService; import cn.iocoder.yudao.module.mes.service.pro.task.MesProTaskService; import cn.iocoder.yudao.module.mes.service.pro.workorder.MesProWorkOrderProcessService; import org.junit.jupiter.api.Test; import org.springframework.aop.framework.ProxyFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.*; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; import org.springframework.transaction.interceptor.TransactionInterceptor; import org.springframework.dao.DataAccessException; import java.lang.reflect.*; import java.math.BigDecimal; import java.time.*; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; class MesProBatchScheduleServiceTest { @Test void yieldAdjustedPreviewExecutesThroughRealQuantityGuard() { var f = new Fixture(); f.processes.forEach(process -> process.setYieldRate(new BigDecimal("0.98"))); var request = f.confirmable(); assertEquals(2, f.proxy.execute(request).size()); assertEquals(new BigDecimal("103.00"), f.readTasks().getFirst().getQuantity()); assertEquals(new BigDecimal("100.00"), f.readTasks().getLast().getQuantity()); assertEquals(new BigDecimal("100.00"), f.scheduledQuantity()); assertFalse(f.service.preview(f.request()).isCanExecute()); } @Test void previewDoesNotWriteAndProcessesAreSequential() { var f = new Fixture(); var result = f.service.preview(f.request()); assertTrue(result.isCanExecute()); assertEquals(2, result.getTasks().size()); assertFalse(result.getTasks().get(1).getStartTime().isBefore(result.getTasks().getFirst().getEndTime())); assertEquals(0, f.taskCount()); assertEquals(0, f.creates.get()); } @Test void partialOrderOnlyFillsEachProcessRemainingQuantity() { var f = new Fixture(); f.jdbc.update("INSERT INTO schedule_task VALUES(99,1,1,1,40,4,?,?)", LocalDateTime.now().minusDays(1), LocalDateTime.now().minusHours(1)); var preview = f.service.preview(f.request()); assertTrue(preview.isCanExecute()); assertEquals(new BigDecimal("60.00"), preview.getTasks().getFirst().getQuantity()); assertEquals(new BigDecimal("100"), preview.getTasks().getLast().getQuantity()); } @Test void completedSchedulingBlocksDuplicateAndCanceledDoesNotConsumeQuantity() { var f = new Fixture(); var request = f.confirmable(); f.proxy.execute(request); assertThrows(ServiceException.class, () -> f.proxy.execute(request)); assertEquals(2, f.taskCount()); assertEquals(new BigDecimal("100.00"), f.scheduledQuantity()); } @Test void anyInvalidOrderBlocksWholeBatch() { var f = new Fixture(); f.orders.add(new MesProWorkOrderDO().setId(2L).setCode("WO-2").setStatus(0).setType(1).setQuantity(BigDecimal.TEN)); f.jdbc.update("INSERT INTO schedule_order VALUES(2,0)"); var request = f.request(); request.setWorkOrderIds(List.of(1L, 2L)); var preview = f.service.preview(request); assertFalse(preview.isCanExecute()); assertNotNull(preview.getOrders().getLast().getError()); request.setPreviewTime(preview.getPreviewTime()); request.setPreviewHash(preview.getPreviewHash()); assertThrows(ServiceException.class, () -> f.proxy.execute(request)); assertEquals(0, f.taskCount()); } @Test void missingCapacityAndDisabledOrderDoNotSilentlyCreateDraftTasks() { var f = new Fixture(); f.machines.getFirst().setProductionEfficiency(BigDecimal.ZERO); assertFalse(f.service.preview(f.request()).isCanExecute()); assertEquals(0, f.taskCount()); f.machines.getFirst().setProductionEfficiency(BigDecimal.valueOf(100)); f.orders.getFirst().setStatus(2); assertFalse(f.service.preview(f.request()).isCanExecute()); } @Test void changedPreviewAndExpiredPreviewCannotCommit() { var f = new Fixture(); var request = f.confirmable(); f.machines.getFirst().setProductionEfficiency(BigDecimal.valueOf(200)); assertThrows(ServiceException.class, () -> f.proxy.execute(request)); assertEquals(0, f.taskCount()); final var expired = f.confirmable(); expired.setPreviewTime(LocalDateTime.now(ZoneId.of("Asia/Shanghai")).minusMinutes(16)); assertThrows(ServiceException.class, () -> f.proxy.execute(expired)); } @Test void duplicateIdsAndInvalidClockAreRejected() { var f = new Fixture(); var request = f.request(); request.setWorkOrderIds(List.of(1L, 1L)); assertThrows(ServiceException.class, () -> f.service.preview(request)); request.setWorkOrderIds(List.of(1L)); request.setStartTime(LocalDateTime.now().minusDays(1)); assertThrows(ServiceException.class, () -> f.service.preview(request)); } @Test void failureDuringSecondTaskRollsBackTasksAndOrderCounter() { var f = new Fixture(); var request = f.confirmable(); f.failAt = 2; assertThrows(IllegalStateException.class, () -> f.proxy.execute(request)); assertEquals(0, f.taskCount()); assertEquals(BigDecimal.ZERO.setScale(2), f.scheduledQuantity()); } @Test void concurrentSubmissionsCannotCreateDuplicateTasks() throws Exception { var f = new Fixture(); var request = f.confirmable(); var start = new CountDownLatch(1); try (var pool = Executors.newFixedThreadPool(2)) { Callable action = () -> { start.await(); try { f.proxy.execute(request); return true; } catch (ServiceException | DataAccessException expected) { return false; } }; var first = pool.submit(action); var second = pool.submit(action); start.countDown(); int successes = (first.get(20, TimeUnit.SECONDS) ? 1 : 0) + (second.get(20, TimeUnit.SECONDS) ? 1 : 0); assertEquals(1, successes); assertEquals(2, f.taskCount()); } } @Test void latenessRequiresExplicitAcknowledgment() { var f = new Fixture(); f.orders.getFirst().setRequestDate(LocalDateTime.now().minusDays(1)); var request = f.confirmable(); request.setAllowLate(false); assertThrows(ServiceException.class, () -> f.proxy.execute(request)); assertEquals(0, f.taskCount()); request.setAllowLate(true); assertEquals(2, f.proxy.execute(request).size()); } @Test void preparationAndWaitingAreReserved() { var f = new Fixture(); f.processes.getFirst().setPrepareTime(30).setWaitTime(60); var preview = f.service.preview(f.request()); assertTrue(preview.isCanExecute()); assertEquals(2, preview.getTasks().getFirst().getDuration()); assertFalse(preview.getTasks().getLast().getStartTime().isBefore(preview.getTasks().getFirst().getEndTime().plusHours(1))); } @Test void canceledTasksDoNotConsumeQuantity() { var f = new Fixture(); f.jdbc.update("INSERT INTO schedule_task VALUES(99,1,1,1,100,5,?,?)", LocalDateTime.now().minusDays(1), LocalDateTime.now().minusHours(1)); var preview = f.service.preview(f.request()); assertTrue(preview.isCanExecute()); assertEquals(new BigDecimal("100"), preview.getTasks().getFirst().getQuantity()); } @Test void selectedChildIsScheduledBeforeParent() { var f = new Fixture(); f.orders.add(new MesProWorkOrderDO().setId(2L).setCode("CHILD").setParentId(1L).setStatus(1).setType(1) .setRouteId(100L).setProductId(1000L).setQuantity(BigDecimal.TEN)); var request = f.request(); request.setWorkOrderIds(List.of(1L, 2L)); var preview = f.service.preview(request); assertTrue(preview.isCanExecute()); assertEquals(2L, preview.getTasks().getFirst().getWorkOrderId()); assertFalse(preview.getTasks().get(2).getStartTime().isBefore(preview.getTasks().get(1).getEndTime())); } @Test void unselectedUnfinishedChildAndMultipleOutputsAreBlocked() { var f = new Fixture(); f.orders.add(new MesProWorkOrderDO().setId(2L).setCode("CHILD").setParentId(1L).setStatus(1).setType(1)); assertFalse(f.service.preview(f.request()).isCanExecute()); f.orders.removeLast(); f.processes.getFirst().setOutputItemId("1,2"); assertFalse(f.service.preview(f.request()).isCanExecute()); } @Test void customerPermissionsAreCheckedBeforeShowingOrderData() { var f = new Fixture(); f.orders.getFirst().setClientId(99L); f.set("customerApi", stub(CrmCustomerApi.class, (p,m,a) -> List.of(10L))); var preview = f.service.preview(f.request()); assertFalse(preview.isCanExecute()); assertNull(preview.getOrders().getFirst().getWorkOrderCode()); } private static T stub(Class type, InvocationHandler handler) { return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, (proxy, method, args) -> { if (method.getDeclaringClass() == Object.class) { return switch (method.getName()) { case "toString" -> type.getSimpleName() + "Stub"; case "hashCode" -> System.identityHashCode(proxy); case "equals" -> proxy == args[0]; default -> throw new AssertionError(method.getName()); }; } return handler.invoke(proxy, method, args); })); } static class Fixture { final JdbcTemplate jdbc; final MesProBatchScheduleService service = new MesProBatchScheduleService(); final MesProBatchScheduleService proxy; final AtomicInteger creates = new AtomicInteger(); int failAt; final List orders = new ArrayList<>(List.of(new MesProWorkOrderDO().setId(1L).setCode("WO-1") .setName("测试生产工单").setStatus(1).setType(1).setRouteId(100L).setProductId(1000L).setQuantity(new BigDecimal("100")))); final List processes = new ArrayList<>(List.of( new MesProWorkOrderProcessDO().setProcessId(1L).setSort(1), new MesProWorkOrderProcessDO().setProcessId(2L).setSort(2))); final List machines = new ArrayList<>(List.of( new MesDvMachineryDO().setId(10L).setName("设备A").setStatus(1).setProductionEfficiency(BigDecimal.valueOf(100)), new MesDvMachineryDO().setId(20L).setName("设备B").setStatus(1).setProductionEfficiency(BigDecimal.valueOf(100)))); Fixture() { var source = new DriverManagerDataSource("jdbc:h2:mem:schedule_"+UUID.randomUUID()+";DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", ""); jdbc = new JdbcTemplate(source); jdbc.execute("CREATE TABLE schedule_mutex(id BIGINT PRIMARY KEY)"); jdbc.update("INSERT INTO schedule_mutex VALUES(1)"); jdbc.execute("CREATE TABLE schedule_order(id BIGINT PRIMARY KEY, scheduled DECIMAL(14,2))"); jdbc.update("INSERT INTO schedule_order VALUES(1,0)"); jdbc.execute("CREATE TABLE schedule_task(id BIGINT PRIMARY KEY, wo BIGINT, process BIGINT, station BIGINT, qty DECIMAL(14,2), status INT, starts TIMESTAMP, ends TIMESTAMP)"); var guard = new MesProScheduleGuardService() { @Override public void lock() { jdbc.queryForList("SELECT id FROM schedule_mutex ORDER BY id FOR UPDATE"); } @Override public Map> machineIdsByStation() { return Map.of(1L, Set.of(10L), 2L, Set.of(20L)); } }; set("guard", guard); set("workOrderMapper", stub(MesProWorkOrderMapper.class, (p, m, a) -> switch (m.getName()) { case "selectByIds" -> orders.stream().filter(order -> ((Collection)a[0]).contains(order.getId())).toList(); case "lockScheduleOrders" -> { jdbc.queryForList("SELECT id FROM schedule_order ORDER BY id FOR UPDATE"); yield orders; } case "selectList" -> orders.stream().filter(order -> Objects.equals(order.getParentId(), a[1])).toList(); case "updateById" -> { var o = (MesProWorkOrderDO)a[0]; yield jdbc.update("UPDATE schedule_order SET scheduled=? WHERE id=?", o.getQuantityScheduled(), o.getId()); } default -> throw new AssertionError(m.getName()); })); set("taskMapper", stub(MesProTaskMapper.class, (p, m, a) -> switch (m.getName()) { case "selectSchedulingTasks" -> readTasks().stream().filter(t -> t.getStatus()!=4 && t.getStatus()!=5).toList(); case "selectListByWorkOrderIds" -> readTasks().stream().filter(t -> ((Collection)a[0]).contains(t.getWorkOrderId())).toList(); default -> throw new AssertionError(m.getName()); })); set("workstationService", stub(MesMdWorkstationService.class, (p,m,a) -> List.of( new MesMdWorkstationDO().setId(1L).setName("工作站A").setProcessId(1L).setStatus(0), new MesMdWorkstationDO().setId(2L).setName("工作站B").setProcessId(2L).setStatus(0)))); set("machineryMapper", stub(MesDvMachineryMapper.class, (p,m,a) -> machines)); set("customerApi", stub(CrmCustomerApi.class, (p,m,a) -> null)); set("holidayService", stub(MesCalHolidayService.class, (p,m,a) -> List.of())); set("workOrderProcessService", stub(MesProWorkOrderProcessService.class, (p,m,a) -> processes)); var quantities = new MesProScheduleQuantityService(); ReflectionTestUtils.setField(quantities, "workOrderProcessService", stub(MesProWorkOrderProcessService.class, (p,m,a) -> processes)); ReflectionTestUtils.setField(guard, "quantityService", quantities); ReflectionTestUtils.setField(guard, "taskMapper", stub(MesProTaskMapper.class, (p,m,a) -> switch (m.getName()) { case "selectListByWorkOrderId" -> readTasks().stream().filter(t -> Objects.equals(t.getWorkOrderId(), a[0])).toList(); case "selectSchedulingTasks" -> readTasks().stream().filter(t -> t.getStatus()!=4 && t.getStatus()!=5).toList(); default -> throw new AssertionError(m.getName()); })); set("routeService", stub(MesProRouteService.class, (p,m,a) -> new MesProRouteDO().setId(100L).setName("测试路线").setStatus(0))); set("processService", stub(MesProProcessService.class, (p,m,a) -> m.getName().equals("getProcess") ? new MesProProcessDO().setId((Long)a[0]).setName("工序"+a[0]).setStatus(0) : null)); set("itemService", stub(MesMdItemService.class, (p,m,a) -> new MesMdItemDO().setId(1000L).setName("测试产品").setStatus(0))); set("taskService", stub(MesProTaskService.class, (p,m,a) -> { assertEquals("createTask", m.getName()); int number = creates.incrementAndGet(); if (number == failAt) throw new IllegalStateException("模拟参数快照写入失败"); var task = (MesProTaskSaveReqVO)a[0]; guard.validate(orders.stream().filter(order -> Objects.equals(order.getId(), task.getWorkOrderId())).findFirst().orElseThrow(), new MesProTaskDO().setWorkOrderId(task.getWorkOrderId()).setRouteId(task.getRouteId()).setProcessId(task.getProcessId()) .setWorkstationId(task.getWorkstationId()).setQuantity(task.getQuantity()).setStartTime(task.getStartTime()).setEndTime(task.getEndTime()), null); jdbc.update("INSERT INTO schedule_task VALUES(?,?,?,?,?,?,?,?)", number, task.getWorkOrderId(), task.getProcessId(), task.getWorkstationId(), task.getQuantity(), 0, task.getStartTime(), task.getEndTime()); return (long)number; })); var factory = new ProxyFactory(service); factory.setProxyTargetClass(true); factory.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); proxy = (MesProBatchScheduleService)factory.getProxy(); } void set(String field, Object value) { ReflectionTestUtils.setField(service, field, value); } int taskCount() { return jdbc.queryForObject("SELECT COUNT(*) FROM schedule_task", Integer.class); } BigDecimal scheduledQuantity() { return jdbc.queryForObject("SELECT scheduled FROM schedule_order WHERE id=1", BigDecimal.class); } List readTasks() { return jdbc.query("SELECT * FROM schedule_task ORDER BY id", (row,n) -> new MesProTaskDO() .setId(row.getLong("id")).setWorkOrderId(row.getLong("wo")).setProcessId(row.getLong("process")) .setWorkstationId(row.getLong("station")).setQuantity(row.getBigDecimal("qty")).setStatus(row.getInt("status")) .setStartTime(row.getTimestamp("starts").toLocalDateTime()).setEndTime(row.getTimestamp("ends").toLocalDateTime())); } MesProBatchScheduleReqVO request() { var r = new MesProBatchScheduleReqVO(); r.setWorkOrderIds(List.of(1L)); r.setStartTime(LocalDate.now(ZoneId.of("Asia/Shanghai")).plusDays(1).atTime(9,0)); return r; } MesProBatchScheduleReqVO confirmable() { var r = request(); var p = service.preview(r); assertTrue(p.isCanExecute(), p.getOrders().toString()); r.setPreviewHash(p.getPreviewHash()); r.setPreviewTime(p.getPreviewTime()); r.setAllowLate(true); return r; } } }