From 74b0d9c5ab1dc316b29f8525dcffe45225d4c43c Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期五, 16 十月 2026 10:29:23 +0800
Subject: [PATCH] Merge branch 'dev_海西州_惠牧' into dev_business

---
 yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesProBatchScheduleServiceTest.java |  288 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 288 insertions(+), 0 deletions(-)

diff --git a/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesProBatchScheduleServiceTest.java b/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesProBatchScheduleServiceTest.java
new file mode 100644
index 0000000..653fc24
--- /dev/null
+++ b/yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/task/schedule/MesProBatchScheduleServiceTest.java
@@ -0,0 +1,288 @@
+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<Boolean> 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> T stub(Class<T> 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<MesProWorkOrderDO> 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<MesProWorkOrderProcessDO> processes = new ArrayList<>(List.of(
+                new MesProWorkOrderProcessDO().setProcessId(1L).setSort(1), new MesProWorkOrderProcessDO().setProcessId(2L).setSort(2)));
+        final List<MesDvMachineryDO> 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<Long, Set<Long>> 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("宸ヤ綔绔橝").setProcessId(1L).setStatus(0),
+                    new MesMdWorkstationDO().setId(2L).setName("宸ヤ綔绔橞").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<MesProTaskDO> 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; }
+    }
+}

--
Gitblit v1.9.3