Эх сурвалжийг харах

补充用户商家和骑手订单详情发票类型

在既有订单权限校验后返回发票选择,无记录保持空值。补齐类型与权限测试、规格及前端接入文档,不改开票和打印流程。
qmj 3 өдөр өмнө
parent
commit
746111db01

+ 8 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java

@@ -355,6 +355,14 @@ public class OrderInvoiceService {
                 || "MEMBER".equals(choice);
     }
 
+    /** 仅供已完成订单归属校验的详情入口读取本地发票选择,不包含买方或载具资料。 */
+    public String getInvoiceChoice(Long orderId) {
+        if (orderId == null) return null;
+        PosOrderInvoice invoice = posOrderInvoiceMapper.selectOne(new LambdaQueryWrapper<PosOrderInvoice>()
+                .select(PosOrderInvoice::getInvoiceChoice).eq(PosOrderInvoice::getOrderId, orderId));
+        return invoice == null || StrUtil.isBlank(invoice.getInvoiceChoice()) ? null : invoice.getInvoiceChoice();
+    }
+
     /** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */
     public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) {
         PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,String.valueOf(orderId)));

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderController.java

@@ -765,6 +765,7 @@ public class PosOrderController extends BaseController {
         org.put("food", JSONArray.parseArray(orlist.getFood()));
         Long count = posOrderRatingService.getBaseMapper().selectCount(new QueryWrapper<PosOrderRating>().eq("dd_id", ddid));
         org.put("isCommented", count > 0);
+        org.put("invoiceChoice", orderInvoiceService.getInvoiceChoice(orlist.getId()));
         // 查询促销快照明细
         List<PosOrderPromotion> promotions = posOrderPromotionMapper.selectByOrderId(orlist.getId());
         org.put("promotions", promotions);

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderQsOprateController.java

@@ -76,6 +76,8 @@ public class PosOrderQsOprateController extends BaseController {
     private MerchantNotificationRouter merchantNotificationRouter;
     @Autowired
     private MultiStoreCheckoutQueryService multiStoreQueries;
+    @Autowired
+    private OrderInvoiceService orderInvoiceService;
 
     /**
      * 骑手接单:校验 type=0 且 afterSaleStatus=0,设置 deliveryStatus=1
@@ -579,6 +581,7 @@ public class PosOrderQsOprateController extends BaseController {
         );
         org.put("juli", null);
         org.put("imGroupId", order.getImGroupId());
+        org.put("invoiceChoice", orderInvoiceService.getInvoiceChoice(order.getId()));
         org.put("store", order.getMdId() == null ? null : posStoreService.getById(order.getMdId().intValue()));
         org.put("user", order.getUserId() == null ? null : infoUserService.getById(order.getUserId()));
         return success(org);

+ 158 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderDetailInvoiceChoiceTest.java

@@ -0,0 +1,158 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.ruoyi.app.order.invoice.OrderInvoicePrintAvailabilityService;
+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.domain.PosOrderInvoice;
+import com.ruoyi.system.mapper.PosOrderInvoiceMapper;
+import com.ruoyi.system.mapper.PosOrderPromotionMapper;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IPosOrderRatingService;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import com.ruoyi.system.utils.JwtUtil;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class OrderDetailInvoiceChoiceTest {
+    private final PosOrderController controller = new PosOrderController();
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IInfoUserService users = mock(IInfoUserService.class);
+    private final PosOrderInvoiceMapper invoices = mock(PosOrderInvoiceMapper.class);
+    private final MerchantStoreAccessService merchants = mock(MerchantStoreAccessService.class);
+    private final OrderInvoiceService invoiceService = new OrderInvoiceService();
+
+    OrderDetailInvoiceChoiceTest() {
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrderInvoice.class);
+        ReflectionTestUtils.setField(invoiceService, "posOrderInvoiceMapper", invoices);
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", invoiceService);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", merchants);
+        ReflectionTestUtils.setField(controller, "invoicePrintAvailability", mock(OrderInvoicePrintAvailabilityService.class));
+        ReflectionTestUtils.setField(controller, "posStoreService", mock(IPosStoreService.class));
+        ReflectionTestUtils.setField(controller, "posOrderRatingService", mock(IPosOrderRatingService.class, RETURNS_DEEP_STUBS));
+        ReflectionTestUtils.setField(controller, "posOrderPromotionMapper", mock(PosOrderPromotionMapper.class));
+        PosOrder order = new PosOrder();
+        order.setId(7L); order.setDdId("ORDER-1"); order.setUserId(21L);
+        order.setShId(11L); order.setMdId(5L); order.setFood("[]"); order.setShAddress("{}");
+        when(orders.getOne(any(Wrapper.class))).thenReturn(order);
+    }
+
+    @Test
+    void customerAndMerchantDetailsReturnEveryStoredChoiceEvenBeforeIssuing() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            for (String role : List.of("0", "1", "5")) {
+                long actorId = "0".equals(role) ? 21L : 11L;
+                auth.when(AuthContext::requireUserId).thenReturn(actorId);
+                InfoUser actor = new InfoUser(); actor.setUserId(actorId); actor.setUserType(role);
+                when(users.getById(actorId)).thenReturn(actor);
+                for (String choice : List.of("PAPER", "PHONE_BARCODE", "CITIZEN", "MEMBER", "LOVE_CODE", "COMPANY")) {
+                    PosOrderInvoice invoice = new PosOrderInvoice();
+                    invoice.setInvoiceChoice(choice); invoice.setInvoiceStatus(0);
+                    when(invoices.selectOne(any(Wrapper.class))).thenReturn(invoice);
+                    JSONObject data = (JSONObject) controller.getorderxq("token", "ORDER-1").get("data");
+                    assertEquals(choice, data.getString("invoiceChoice"));
+                    assertFalse(data.containsKey("carrierNum"));
+                    assertFalse(data.containsKey("buyerUbn"));
+                }
+            }
+            verify(merchants, times(12)).requireOrderAccess(11L, 7L);
+        }
+    }
+
+    @Test
+    void missingInvoiceDoesNotInventPaperChoice() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(21L);
+            InfoUser actor = new InfoUser(); actor.setUserId(21L); actor.setUserType("0");
+            when(users.getById(21L)).thenReturn(actor);
+            JSONObject data = (JSONObject) controller.getorderxq("token", "ORDER-1").get("data");
+            assertTrue(data.containsKey("invoiceChoice"));
+            assertNull(data.get("invoiceChoice"));
+        }
+    }
+
+    @Test
+    void unauthorizedCustomerDoesNotReadInvoice() {
+        try (var auth = mockStatic(AuthContext.class); var messages = mockStatic(MessageUtils.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(99L);
+            InfoUser actor = new InfoUser(); actor.setUserId(99L); actor.setUserType("0");
+            when(users.getById(99L)).thenReturn(actor);
+            assertThrows(ServiceException.class, () -> controller.getorderxq("token", "ORDER-1"));
+            verifyNoInteractions(invoices);
+        }
+    }
+
+    @Test
+    void unauthorizedMerchantDoesNotReadInvoice() {
+        try (var auth = mockStatic(AuthContext.class)) {
+            auth.when(AuthContext::requireUserId).thenReturn(11L);
+            InfoUser actor = new InfoUser(); actor.setUserId(11L); actor.setUserType("5");
+            when(users.getById(11L)).thenReturn(actor);
+            doThrow(new ServiceException("denied")).when(merchants).requireOrderAccess(11L, 7L);
+            assertThrows(ServiceException.class, () -> controller.getorderxq("token", "ORDER-1"));
+            verifyNoInteractions(invoices);
+        }
+    }
+
+    @Test
+    void riderReadsChoiceOnlyAfterScopedOrderLookup() {
+        PosOrderQsOprateController rider = new PosOrderQsOprateController();
+        ReflectionTestUtils.setField(rider, "orderInvoiceService", invoiceService);
+        ReflectionTestUtils.setField(rider, "posOrderService", orders);
+        ReflectionTestUtils.setField(rider, "posStoreService", mock(IPosStoreService.class));
+        ReflectionTestUtils.setField(rider, "infoUserService", users);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class);
+        try (var messages = mockStatic(MessageUtils.class)) {
+            String token = JwtUtil.token("88", "rider");
+            for (String choice : List.of("PAPER", "PHONE_BARCODE", "CITIZEN", "MEMBER", "LOVE_CODE", "COMPANY")) {
+                PosOrderInvoice invoice = new PosOrderInvoice(); invoice.setInvoiceChoice(choice);
+                when(invoices.selectOne(any(Wrapper.class))).thenReturn(invoice);
+                JSONObject data = (JSONObject) rider.getOrderDetial(token, "ORDER-1").get("data");
+                assertEquals(choice, data.getString("invoiceChoice"));
+            }
+            ArgumentCaptor<LambdaQueryWrapper<PosOrder>> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+            verify(orders, times(6)).getOne(query.capture());
+            query.getValue().getSqlSegment();
+            assertTrue(query.getValue().getParamNameValuePairs().containsValue(88L));
+            assertTrue(query.getValue().getParamNameValuePairs().containsValue("ORDER-1"));
+            clearInvocations(invoices);
+            when(orders.getOne(any(Wrapper.class))).thenReturn(null);
+            assertThrows(ServiceException.class, () -> rider.getOrderDetial(token, "ORDER-1"));
+            verifyNoInteractions(invoices);
+        }
+    }
+
+    @Test
+    void queryReadsOnlyChoiceForTheAuthorizedOrderAndKeepsBlankUnknown() {
+        PosOrderInvoice invoice = new PosOrderInvoice(); invoice.setInvoiceChoice(" ");
+        when(invoices.selectOne(any(Wrapper.class))).thenReturn(invoice);
+        assertNull(invoiceService.getInvoiceChoice(7L));
+        ArgumentCaptor<LambdaQueryWrapper<PosOrderInvoice>> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(invoices).selectOne(query.capture());
+        assertEquals("invoice_choice", query.getValue().getSqlSelect());
+        assertTrue(query.getValue().getSqlSegment().contains("order_id"));
+        assertEquals(List.of(7L), List.copyOf(query.getValue().getParamNameValuePairs().values()));
+        clearInvocations(invoices);
+        assertNull(invoiceService.getInvoiceChoice(null));
+        verifyNoInteractions(invoices);
+    }
+}

+ 1 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderImGroupControllerTest.java

@@ -26,6 +26,7 @@ class OrderImGroupControllerTest {
     private final MerchantStoreAccessService merchants = mock(MerchantStoreAccessService.class);
 
     OrderImGroupControllerTest() {
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", mock(OrderInvoiceService.class));
         ReflectionTestUtils.setField(controller, "invoicePrintAvailability", mock(com.ruoyi.app.order.invoice.OrderInvoicePrintAvailabilityService.class));
         ReflectionTestUtils.setField(controller, "posOrderService", orders);
         ReflectionTestUtils.setField(controller, "infoUserService", users);

+ 1 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/PosOrderQsOprateControllerTest.java

@@ -100,6 +100,7 @@ class PosOrderQsOprateControllerTest {
     @Test
     void riderOrderListAndDetailReturnImGroupId() {
         PosOrderQsOprateController controller = new PosOrderQsOprateController();
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", mock(OrderInvoiceService.class));
         IPosOrderService orders = mock(IPosOrderService.class);
         IPosStoreService stores = mock(IPosStoreService.class);
         IInfoUserService users = mock(IInfoUserService.class);

+ 58 - 0
specs/010-order-invoice/frontend-order-invoice-choice.md

@@ -0,0 +1,58 @@
+# 订单详情:发票类型接入说明
+
+更新:2026-11-05。适用:用户、商家和骑手 App 的外卖订单详情。
+
+## 接口与字段
+
+请求参数保持不变,均需携带 `token` 请求头。
+
+| 使用端 | GET 接口 | 订单号参数 |
+|---|---|---|
+| 用户、商家 | `/system/order/getorderxq` | `ddid` |
+| 骑手 | `/system/orderQsOprate/getOrderDetial` | `ddId` |
+
+两个接口在 `data` 中增加 `invoiceChoice`,类型为字符串或 null。
+
+| invoiceChoice | 页面显示 |
+|---|---|
+| `PAPER` | 纸本发票 |
+| `PHONE_BARCODE` | 手机条码载具 |
+| `CITIZEN` | 自然人凭证载具 |
+| `MEMBER` | 会员发票 |
+| `LOVE_CODE` | 捐赠发票 |
+| `COMPANY` | 公司发票 |
+| `null`、空值或未识别值 | 暂无发票信息 |
+
+页面显示“发票类型:纸本发票”等文案,标题和类型名称使用客户端现有国际化机制。
+
+## 响应示例
+
+下面只展示与本次接入相关的字段,订单详情原有字段保持不变。
+
+```json
+{
+  "code": 200,
+  "msg": "操作成功",
+  "data": {
+    "id": 1001,
+    "ddId": "991793757396125",
+    "invoiceChoice": "PAPER"
+  }
+}
+```
+
+## 展示规则
+
+- 直接读取 `data.invoiceChoice`,不需要额外调用发票详情接口获取类型。
+- 尚未开票时也可能已有发票类型;显示类型不代表已经开票或已交付。
+- 无类型时显示“暂无发票信息”,不要默认显示纸本发票。
+- `MEMBER` 表示会员选择,当前流程实际开立纸本;不能仅凭名称判断为电子载具票。
+- 商家打印入口继续使用 `canPrintInvoice` 和 `invoicePrintCount`,不要根据 `invoiceChoice` 单独开放打印。
+- 本字段用于显示类型,不包含载具号码、统编、邮箱或发票凭证。
+
+## 联调检查
+
+- 用户、商家与骑手查看同一订单时,发票类型一致。
+- 六种选择及无类型订单均显示对应文案,未开票订单可显示已保存类型。
+- 请求失败时按现有逻辑显示错误,不把无访问权限当作“暂无发票信息”。
+- 商家原有打印入口和其他订单详情字段正常。

+ 7 - 0
specs/010-order-invoice/plan.md

@@ -249,3 +249,10 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 1. 证明联顶部使用确认的平台名称「恰恰吃」,在有效区域内居中加粗;明细用带字号/字重/行距的内部行记录处理长店名和动态纸高。
 2. 升级预生成版式版本,保留历史打印任务;无需数据库结构变更或App改动。
 3. 运行发票定向测试与模块构建,检查384点黑白输出、长店名、B2B/B2C以及203dpi条码解码,输出实际渲染预览。
+
+## 2026-11-05 订单详情发票类型实施
+
+1. OrderInvoiceService 增加本地选择查询,按已经授权订单的主键查询 pos_order_invoice,仅选择 invoice_choice;无记录/空值返回 null。
+2. 共用订单详情与骑手订单详情在权限校验后,将结果放入响应 data.invoiceChoice,不修改订单实体持久化结构。
+3. 覆盖六种选择、无发票/空选择、用户/商家/分管账号、骑手及拒绝越权后不查发票,验证只查询指定订单和选择列。
+4. 更新前端字段枚举与示例;运行发票及受影响订单详情回归、JDK21模块打包与差异检查。无需SQL迁移或第三方调用。

+ 8 - 0
specs/010-order-invoice/spec.md

@@ -295,3 +295,11 @@
 - 用户确认平台名称为「恰恰吃」,证明联顶部以16pt加粗居中显示;门店名称仅在交易明细标题下出现一次,11pt加粗,长名称按实际字号换行。
 - 明细标题加粗、商品之间增加留白、合计11pt加粗;B2C分离页与B2B同页共用排版。保留证明联期别、号码、补印标记、真实条码/二维码及57mm纸面、48mm有效区域。
 - 版式版本升级为 SUNMI_384_V2,后台按现有机制为符合条件的发票生成新版文件。新版READY前不能将旧版缓存当作新版可打印;已保存的历史打印任务及相同requestId重试仍保持原文件、原次数。
+
+## 2026-11-05 三端订单详情发票类型
+
+- 用户、商家共用 `/system/order/getorderxq`,骑手 `/system/orderQsOprate/getOrderDetial` 的订单详情增加 invoiceChoice。
+- 返回订单发票记录已保存的选择:PAPER、PHONE_BARCODE、CITIZEN、MEMBER、LOVE_CODE、COMPANY;未开票也可展示已保存选择,不根据打印是否就绪决定类型。
+- 无发票记录或未保存选择返回 null,前端显示“暂无发票信息”,不默认纸本。MEMBER 是会员选择,当前开票流程实际为纸本;类型不代表已开票、可打印或已交付。
+- 仅在既有订单归属鉴权通过后读取本地选择;不附加载具号、邮箱、统编或开票凭证等信息。
+- 本轮只补两个订单详情响应和前端接入文档,不新增数据库字段、不修改开票/支付/打印流程,不扩展订单列表或闪送发票,不创建分支。

+ 9 - 0
specs/010-order-invoice/tasks.md

@@ -373,3 +373,12 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - [x] T085 验证发票定向测试、构建及实际打印图预览,记录验证边界。
 
 验证:JDK 21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=*Invoice*Test -Dsurefire.failIfNoSpecifiedTests=false package` 退出码0,36项测试通过(admin 35、system 1),失败/错误/跳过均0。覆盖203dpi真实码值解码、384点有效区、B2B/B2C、长店名换行及新版缓存重建。已查看实际黑白PNG预览;`git diff --check`通过。日志 `ruoyi-admin/target/invoice-brand-layout.log`,样张位于 `ruoyi-admin/target/invoice-print-samples/`。未进行实体热敏打印机验收或线上部署;提交状态以 Git 记录为准。
+
+## 2026-11-05 三端订单详情发票类型
+
+- [x] T086 在既有授权详情中返回 invoiceChoice,复用本地发票选择,保持空值、开票和打印语义。
+- [x] T087 补齐类型、权限与查询范围测试,更新前端接入说明并执行定向回归和JDK21打包。
+
+验证(2026-11-05):新增详情用例先复现 invoiceChoice 缺失(3项中2项断言失败),实现后执行 JDK21 `mvn -q -pl ruoyi-admin -am -Dtest=*Invoice*Test,OrderImGroupControllerTest,PosOrderQsOprateControllerTest -Dsurefire.failIfNoSpecifiedTests=false package`,53项测试全部通过(新增详情6项),失败/错误/跳过均0,打包退出码0。覆盖六类选择、无票空值、用户/商家/分管及骑手读取、越权拒绝、仅查询指定订单的选择列,原打印回归通过。接入示例JSON及git diff --check检查通过,生产文件保留Windows工作区CRLF换行。日志:`ruoyi-admin/target/order-detail-invoice-red.log`、`order-detail-invoice-green.log`。未执行真实MySQL/ezPay、App联调、全量回归或部署;无SQL迁移,未创建分支、提交或推送。
+
+提交前验证(2026-11-05):按用户 commit push 指令暂存本次10个文件;PosOrderController只纳入发票类型返回这一行,保留其余工作区改动。从暂存区导出独立源码副本,执行上述定向测试与JDK21 package,53项全部通过、失败/错误/跳过均0,打包退出码0;暂存差异检查通过。未执行App联调、真实MySQL/ezPay、全量回归或部署。提交与推送状态以Git记录为准。