Ver código fonte

新增商家订单数量接口并统计全部待取餐已出餐订单

qmj 3 dias atrás
pai
commit
5bfdeb42f9

+ 67 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -9,6 +9,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.app.order.dto.MerchantTodayOrderCounts;
 import com.ruoyi.app.order.dto.OrderCreateInput;
 import com.ruoyi.app.order.dto.OrderPushBodyDto;
 import com.ruoyi.app.order.dto.SelfDeliveryOperateInput;
@@ -41,6 +42,8 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
 
 import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -920,6 +923,65 @@ public class PosOrderShOprateController extends BaseController {
             throw new ServiceException("用户信息不存在");
         }
 
+        LambdaQueryWrapper<PosOrder> wrapper = merchantOrderQuery(loginUserId, currentUser, mdId, type);
+        applyMerchantOrderTab(wrapper, tab);
+        wrapper.orderByDesc(PosOrder::getCretim);
+
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> pageParam =
+                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
+        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> result = posOrderService.page(pageParam, wrapper);
+
+        invoicePrintAvailability.fill(result.getRecords());
+        return success(result);
+    }
+
+    /**
+     * 商家工作Tab数量:待受理、待出餐统计当天创建,已出餐不限制日期。
+     * 复用列表查询条件,避免分管权限、骑手接单或付款条件与列表不一致。
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/todayOrderCounts")
+    public AjaxResult todayOrderCounts(@RequestHeader String token,
+                                      @RequestParam(required = false) Long mdId,
+                                      @RequestParam(required = false) String type) {
+        Long loginUserId = AuthContext.requireUserId();
+        InfoUser currentUser = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()
+                .eq(InfoUser::getUserId, loginUserId));
+        if (currentUser == null || currentUser.getUserType() == null
+                || !List.of("1", "3", "4", "5").contains(currentUser.getUserType())) {
+            throw new ServiceException(MessageUtils.message("merchant.account.unavailable"));
+        }
+        if (type != null && !type.isEmpty() && !List.of("0", "1", "2").contains(type)) {
+            throw new ServiceException(MessageUtils.message("no.unifiedorder.param.invalid"));
+        }
+        // 摊位只能统计绑定门店;不将传入的其他门店静默替换成当前摊位。
+        if ("4".equals(currentUser.getUserType()) && (currentUser.getStoreId() == null
+                || (mdId != null && !mdId.equals(currentUser.getStoreId())))) {
+            throw new ServiceException(MessageUtils.message("merchant.store.access.denied"));
+        }
+        LambdaQueryWrapper<PosOrder> base = merchantOrderQuery(loginUserId, currentUser, mdId, type);
+        ZoneId zone = ZoneId.of("Asia/Taipei");
+        LocalDate today = LocalDate.now(zone);
+        // 使用半开区间,包含零点且排除次日零点;不对列套DATE函数,以便使用时间索引。
+        LambdaQueryWrapper<PosOrder> daily = base.clone();
+        daily.ge(PosOrder::getCretim, Date.from(today.atStartOfDay(zone).toInstant()))
+                .lt(PosOrder::getCretim, Date.from(today.plusDays(1).atStartOfDay(zone).toInstant()));
+        // 已出餐可能跨日等待取餐,使用不带日期的基础条件统计全部当前ready订单。
+        return success(new MerchantTodayOrderCounts(countMerchantTab(daily, "pending"),
+                countMerchantTab(daily, "preparing"), countMerchantTab(base, "ready")));
+    }
+
+    private long countMerchantTab(LambdaQueryWrapper<PosOrder> base, String tab) {
+        // 每个状态使用独立副本,不能把state=0/1/2累加到同一个查询上。
+        LambdaQueryWrapper<PosOrder> query = base.clone();
+        applyMerchantOrderTab(query, tab);
+        return posOrderService.count(query);
+    }
+
+    /** 列表与数量共用门店范围、订单类型以及骑手接单可见条件。 */
+    private LambdaQueryWrapper<PosOrder> merchantOrderQuery(Long loginUserId, InfoUser currentUser,
+                                                           Long mdId, String type) {
         LambdaQueryWrapper<PosOrder> wrapper = new LambdaQueryWrapper<>();
         String userType = currentUser.getUserType();
         if ("1".equals(userType) || "5".equals(userType)) {
@@ -949,6 +1011,11 @@ public class PosOrderShOprateController extends BaseController {
         wrapper.and(w -> w.ne(PosOrder::getType, 0L)
                 .or(delivery -> delivery.in(PosOrder::getDeliveryStatus, 1L, 2L, 3L)
                         .isNotNull(PosOrder::getQsId)));
+        return wrapper;
+    }
+
+    /** 保持既有Tab语义;数量按需追加日期条件,不另定义支付或售后状态。 */
+    private void applyMerchantOrderTab(LambdaQueryWrapper<PosOrder> wrapper, String tab) {
         switch (tab == null ? "" : tab) {
             case "pending":
                 wrapper.eq(PosOrder::getState, 0L)
@@ -975,16 +1042,5 @@ public class PosOrderShOprateController extends BaseController {
             default:
                break;
         }
-        wrapper.orderByDesc(PosOrder::getCretim);
-
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> pageParam =
-                new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page, size);
-        com.baomidou.mybatisplus.extension.plugins.pagination.Page<PosOrder> result = posOrderService.page(pageParam, wrapper);
-
-        invoicePrintAvailability.fill(result.getRecords());
-        return success(result);
     }
-
-
-
 }

+ 5 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MerchantTodayOrderCounts.java

@@ -0,0 +1,5 @@
+package com.ruoyi.app.order.dto;
+
+/** 商家工作Tab数量:pending/preparing按当日创建,ready统计全部日期当前已出餐。 */
+public record MerchantTodayOrderCounts(long pendingCount, long preparingCount, long readyCount) {
+}

+ 144 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantTodayOrderCountsTest.java

@@ -0,0 +1,144 @@
+package com.ruoyi.app.order;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class MerchantTodayOrderCountsTest {
+    private final PosOrderShOprateController controller = new PosOrderShOprateController();
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final MerchantStoreAccessService access = mock(MerchantStoreAccessService.class);
+    private final InfoUser user = new InfoUser();
+    private final List<LambdaQueryWrapper<PosOrder>> counts = new ArrayList<>();
+    private MockedStatic<AuthContext> auth;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeAll static void metadata() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+    }
+    @BeforeEach void setup() {
+        auth = mockStatic(AuthContext.class);
+        auth.when(AuthContext::requireUserId).thenReturn(801L);
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+        user.setUserId(801L); user.setUserType("1");
+        when(users.getOne(any())).thenReturn(user);
+        when(access.getAccessibleStoreIds(801L)).thenReturn(Set.of(11L,12L));
+        ReflectionTestUtils.setField(controller,"infoUserService",users);
+        ReflectionTestUtils.setField(controller,"posOrderService",orders);
+        ReflectionTestUtils.setField(controller,"merchantStoreAccessService",access);
+        ReflectionTestUtils.setField(controller,"invoicePrintAvailability",mock(com.ruoyi.app.order.invoice.OrderInvoicePrintAvailabilityService.class));
+        when(orders.count(any())).thenAnswer(i -> {
+            counts.add(i.getArgument(0));
+            return (long)counts.size();
+        });
+    }
+    @AfterEach void close() { auth.close(); messages.close(); }
+
+    private Object invoke(Long mdId,String type) {
+        return controller.todayOrderCounts("token",mdId,type);
+    }
+    private String sql(LambdaQueryWrapper<PosOrder> query) {
+        String result=query.getSqlSegment();
+        for(var e:query.getParamNameValuePairs().entrySet())
+            result=result.replace("#{ew.paramNameValuePairs."+e.getKey()+"}",String.valueOf(e.getValue()));
+        return result;
+    }
+    @Test void countsPendingAndPreparingTodayButReadyAcrossAllDates() {
+        LocalDate day=LocalDate.now(ZoneId.of("Asia/Taipei"));
+        var json=com.alibaba.fastjson2.JSON.parseObject(com.alibaba.fastjson2.JSON.toJSONString(invoke(null,null)));
+        assertEquals(200,json.getIntValue("code"));
+        assertEquals(1,json.getJSONObject("data").getLongValue("pendingCount"));
+        assertEquals(2,json.getJSONObject("data").getLongValue("preparingCount"));
+        assertEquals(3,json.getJSONObject("data").getLongValue("readyCount"));
+        for(var query:counts.subList(0,2)) {
+            String statement=sql(query);
+            assertTrue(statement.contains("cretim >="),statement);
+            assertTrue(statement.contains("cretim <"),statement);
+            assertFalse(statement.contains("ORDER BY"));
+            assertTrue(query.getParamNameValuePairs().containsValue(Date.from(day.atStartOfDay(ZoneId.of("Asia/Taipei")).toInstant())));
+            assertTrue(query.getParamNameValuePairs().containsValue(Date.from(day.plusDays(1).atStartOfDay(ZoneId.of("Asia/Taipei")).toInstant())));
+        }
+        // 已出餐不带时间限制,但保留原ready状态及售后条件,不纳入已完成订单。
+        assertFalse(sql(counts.get(2)).contains("cretim"));
+        assertFalse(sql(counts.get(2)).contains("ORDER BY"));
+        assertTrue(sql(counts.get(0)).contains("state = 0"));
+        assertTrue(sql(counts.get(0)).contains("pay_status = 1 OR (pay_type IN (1,4) AND type IN (1,2))"));
+        assertTrue(sql(counts.get(1)).contains("state = 1"));
+        assertTrue(sql(counts.get(2)).contains("state = 2 AND after_sale_status = 0"));
+        verify(users,times(1)).getOne(any());
+    }
+    @ParameterizedTest @ValueSource(strings={"1","3","4","5"})
+    void matchesExistingListScopeAndStateConditions(String role) {
+        user.setUserType(role); user.setStoreId(11L);
+        invoke(11L,"0");
+        List<LambdaQueryWrapper<PosOrder>> lists=new ArrayList<>();
+        when(orders.page(any(Page.class),any(Wrapper.class))).thenAnswer(i->{lists.add(i.getArgument(1));return new Page<PosOrder>();});
+        for(String tab:List.of("pending","preparing","ready")) controller.orderList("token",1,10,tab,11L,"0");
+        for(int i=0;i<3;i++) {
+            String list=sql(lists.get(i)).replace(" ORDER BY cretim DESC","");
+            String count=sql(counts.get(i)).replaceAll(" AND cretim >= .*? AND cretim < .*?(?= AND state)","");
+            assertEquals(list,count);
+        }
+    }
+    @Test void rejectsUnauthorizedStoreBeforeCounting() {
+        doThrow(new ServiceException("denied")).when(access).requireStoreAccess(801L,99L);
+        assertThrows(ServiceException.class,()->invoke(99L,null));
+        verify(orders,never()).count(any());
+    }
+    @Test void emptyAuthorizationCannotCountAllOrders() {
+        user.setUserType("5");
+        when(access.getAccessibleStoreIds(801L)).thenReturn(Set.of());
+        doAnswer(i->{counts.add(i.getArgument(0));return 0L;}).when(orders).count(any());
+        var json=com.alibaba.fastjson2.JSON.parseObject(com.alibaba.fastjson2.JSON.toJSONString(invoke(null,null)));
+        assertEquals(0,json.getJSONObject("data").getLongValue("pendingCount"));
+        for(var query:counts) assertTrue(sql(query).contains("1 = 0"));
+    }
+    @ParameterizedTest @ValueSource(strings={"0","2","unknown"})
+    void rejectsNonMerchant(String role) {
+        user.setUserType(role);
+        assertThrows(ServiceException.class,()->invoke(null,null));
+        verify(orders,never()).count(any());
+    }
+    @Test void rejectsMissingUserAndWrongStallStore() {
+        when(users.getOne(any())).thenReturn(null);
+        assertThrows(ServiceException.class,()->invoke(null,null));
+        when(users.getOne(any())).thenReturn(user);
+        user.setUserType(null);
+        assertThrows(ServiceException.class,()->invoke(null,null));
+        user.setUserType("4"); user.setStoreId(11L);
+        assertThrows(ServiceException.class,()->invoke(12L,null));
+        verify(orders,never()).count(any());
+    }
+
+    @ParameterizedTest @ValueSource(strings={"abc","-1","3"})
+    void rejectsInvalidOrderType(String type) {
+        assertThrows(ServiceException.class,()->invoke(null,type));
+        verify(orders,never()).count(any());
+    }
+}

+ 38 - 0
specs/041-merchant-today-order-counts/frontend-app-integration.md

@@ -0,0 +1,38 @@
+# 商家当日订单数量
+
+`GET /system/orderShOprate/todayOrderCounts`
+
+请求头:`token`(商家当前登录token)。
+
+可选查询参数与 `orderList` 对应:
+
+| 参数 | 说明 |
+| --- | --- |
+| mdId | 门店ID;不传汇总当前账号可查看的门店 |
+| type | 0外送、1自取、2堂食;不传统计全部类型 |
+
+响应示例:
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "pendingCount": 3,
+    "preparingCount": 5,
+    "readyCount": 2
+  }
+}
+```
+
+- `pendingCount`:当日待受理,对应列表Tab `pending` 中当天创建的订单。
+- `preparingCount`:当日待出餐,对应列表Tab `preparing` 中当天创建的订单。
+- `readyCount`:全部当前已出餐,对应列表Tab `ready`,**不限日期,不含已完成订单**。
+
+待受理、待出餐按台湾时区**当天创建**的订单统计,含当天00:00,不含次日00:00;不是按预约配送时间或出餐时间。已出餐不加时间条件。空结果返回0。
+
+权限、支付、骑手接单和售后筛选与原列表相同。外送须已有骑手接单;多店子单分别计数。原列表不限制日期,因此待受理、待出餐数量不是列表所有历史记录的总数,已出餐则与完整ready列表范围一致。
+
+进入页面、切换门店/类型、操作订单成功或收到订单变更通知后刷新;请求时传入与列表相同的筛选项。计数与之后请求列表之间状态可能继续变化,应以最新接口结果为准。
+
+本轮后端新增接口,前端原列表接口和请求参数无需变更,无SQL迁移。

+ 6 - 0
specs/041-merchant-today-order-counts/plan.md

@@ -0,0 +1,6 @@
+# 实施计划
+
+1. 提取 orderList 已有基础查询和Tab条件为Controller内部共用方法,保持列表原行为;待受理、待出餐数量增加当日时间范围,已出餐使用不限日期的基础条件,使用COUNT查询,不加载订单明细。
+2. 一次解析账号/门店范围,在三个查询副本上分别附加状态,返回明确DTO。新接口校验账号及可选type,沿用现有国际化错误。
+3. 定向测试比较列表与计数SQL,覆盖权限、空授权、日期上下界、付款/骑手/售后条件及JSON;执行既有列表回归与JDK21模块构建。
+4. 提供简短App接入文档,记录未执行的实库与App联调。不提交暂停的蓝牙附件。

+ 11 - 0
specs/041-merchant-today-order-counts/spec.md

@@ -0,0 +1,11 @@
+# 商家当日订单数量
+
+新增 GET `/system/orderShOprate/todayOrderCounts`,请求头 token,选填 mdId 和 type(0外送、1自取、2堂食)。返回 pendingCount、preparingCount、readyCount,均为数量,无数据为0。
+
+pendingCount、preparingCount 按 Asia/Taipei 当日创建时间 cretim 统计,范围为当天00:00(含)至次日00:00(不含)。不按出餐时间或预约时间归日。readyCount 按用户确认不限制日期,统计所有当前仍处于已出餐状态且无售后的订单,不包括已完成订单。现有 orderList 不限制日期,所以待受理和待出餐数量只对应当天创建的订单;已出餐数量对应完整ready列表。
+
+复用 orderList 的门店范围和全部筛选规则:外送须 deliveryStatus 为1/2/3且 qsId非空;pending 为 state=0 且已支付或现金(payType=1/4)的自取/堂食;preparing 为state=1;ready 为state=2且afterSaleStatus=0。多店订单按子订单数量计数。
+
+普通商家和分管账号使用授权门店,夜市使用shId,摊位使用绑定门店;新接口明确拒绝用户/骑手账号。越权门店不能返回其他商家数量。保持既有列表响应及业务状态规则不变。
+
+不改数据库、App、打印代码或分支。

+ 19 - 0
specs/041-merchant-today-order-counts/tasks.md

@@ -0,0 +1,19 @@
+# 任务
+
+- [x] T001 共用列表条件及新增当日计数接口、中文注释。
+- [x] T002 验证状态/权限/日期/响应及原列表回归、模块构建。
+- [x] T003 更新前端接入说明及验证结果。
+
+2026-11-06 验证:先运行新增测试确认接口缺失(11项失败/错误);实现后修正测试重复打桩时捕获到null的问题。最终以JDK21执行:
+
+```text
+mvn -q -pl ruoyi-admin -am -Dtest=MerchantTodayOrderCountsTest,OrderListPaymentFilterTest -Dsurefire.failIfNoSpecifiedTests=false package
+```
+
+24项全部通过(新增14、既有列表10),失败/错误/跳过均为0,模块构建退出码0。覆盖四类商家列表条件一致性、授权门店/空授权、拒绝非商家与非法类型、当日创建时间半开区间、付款及配送条件、返回JSON和原列表发票标识回填。`git diff --check`通过;修改的既有Java文件保持CRLF。
+
+日志:`ruoyi-admin/target/merchant-today-red.log`、`merchant-today-green.log`。未连接真实数据库、未做App联调、全项目回归或部署;无需SQL。沿用test-202609v2,按用户要求提交推送,结果以Git记录为准。暂停的蓝牙附件未改动、未纳入本轮功能。
+
+- [x] T004 按用户确认调整readyCount:不限制日期,仍使用当前ready状态和售后条件,不包含已完成订单。待受理、待出餐继续统计当天。同步规格、计划及接入文档,测试明确断言ready查询不含cretim条件。
+
+T004提交前重新执行上述JDK21定向测试和package,24项全部通过、失败/错误/跳过为0,构建退出码0。未进行真实数据库、App联调或部署。