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

完善商家现金下单推送与自动开票并优化蓝牙打印

qmj 2 өдөр өмнө
parent
commit
461c90bfd8
21 өөрчлөгдсөн 1275 нэмэгдсэн , 12 устгасан
  1. 5 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/MerchantNotificationRouter.java
  2. 12 6
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java
  3. 45 1
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  4. 138 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantCashOrderNotificationTest.java
  5. 212 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantCreateOrderInvoiceChoiceTest.java
  6. 46 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantNotificationRouterTest.java
  7. 102 0
      ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderInvoiceAutoIssueTest.java
  8. 442 0
      specs/010-order-invoice/bluetooth-printing/BluetoothPrinter.js
  9. 154 0
      specs/010-order-invoice/bluetooth-printing/BluetoothPrinter.test.cjs
  10. 14 0
      specs/010-order-invoice/bluetooth-printing/README.md
  11. 23 0
      specs/010-order-invoice/frontend-order-invoice-choice.md
  12. 8 0
      specs/010-order-invoice/plan.md
  13. 10 0
      specs/010-order-invoice/spec.md
  14. 18 0
      specs/010-order-invoice/tasks.md
  15. 7 0
      specs/022-merchant-store-subaccounts/plan.md
  16. 8 0
      specs/022-merchant-store-subaccounts/spec.md
  17. 10 0
      specs/022-merchant-store-subaccounts/tasks.md
  18. 3 3
      specs/039-merchant-order-receipt/frontend-app-integration.md
  19. 4 0
      specs/039-merchant-order-receipt/plan.md
  20. 4 0
      specs/039-merchant-order-receipt/spec.md
  21. 10 2
      specs/039-merchant-order-receipt/tasks.md

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

@@ -148,6 +148,11 @@ public class MerchantNotificationRouter {
             log.error("Store notification message publish failed, userId={}", recipient.getUserId(), exception);
         }
 
+        // 主账号兜底和分管账号发送前都检查App会话;仅登录PC不能接收设备推送。
+        // 站内消息仍保留,避免退出App期间的订单通知在下次登录后丢失。
+        if (!tokenSessionService.hasAppSession(recipient.getUserId())) {
+            return;
+        }
         String address = businessPushService.addressKey(recipient);
         if (address == null || !pushedAddresses.add(address)) {
             return;

+ 12 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java

@@ -31,6 +31,8 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Date;
 import java.util.LinkedHashMap;
@@ -261,21 +263,24 @@ public class OrderInvoiceService {
     }
 
     /**
-     * 商家出餐自动开票(010 Phase 11):读 pos_order_invoice 发票意图行 → 组装 dto(个人 BuyerName=手机号、
+     * 商家现金创建提交后 / 出餐自动开票(010 Phase 11):读 pos_order_invoice 发票意图行 → 组装 dto(个人 BuyerName=手机号、
      * 捐赠=机构名)→ 复用开票核心 {@link #reIssueAndSave}。系统触发,不校验客户归属;货到付款未付款也开
      * (送达失败/取消时作废兜底)。门店不可开票 / 订单无发票意图 / 已开 → 静默跳过,不影响出餐。
      *
+     * 独立事务确保创建事务 afterCommit 中的开票结果可以提交;意图行加锁串行化自动触发。
+     *
      * @param orderId PosOrder.id(PK,与 invalid/retry/selectInvoiceDetail 一致)
      * @return 1=已开 0=跳过或失败
      */
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
     public int autoIssue(Long orderId) {
         PosOrder order = posOrderMapper.selectPosOrderById(orderId);
-        if (order == null) {
-            return 0; // 订单不存在
+        if (order == null || Long.valueOf(4L).equals(order.getState())) {
+            return 0; // 订单不存在或已取消
         }
         // 发票意图自 Phase 11 起落 pos_order_invoice(下单捕获的意图行),不再读 pos_order
         PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
-                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId).last("FOR UPDATE"));
         if (row == null || StrUtil.isBlank(row.getInvoiceChoice())) {
             return 0; // 无发票意图(门店不可开票时 createOrder 未插意图行)
         }
@@ -288,8 +293,9 @@ public class OrderInvoiceService {
         if (ez == null) {
             return 0;
         }
-        // 防重复:已开则跳过
-        if (Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
+        // 创建与出餐自动触发共用行锁:已开或已作废不再自动开立。
+        if (Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())
+                || Integer.valueOf(STATUS_INVALID).equals(row.getInvoiceStatus())) {
             return 0;
         }
         ApplyInvoiceDto dto = buildAutoIssueDto(row);

+ 45 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java

@@ -106,6 +106,8 @@ public class PosOrderShOprateController extends BaseController {
     private OrderNumberReservationService orderNumberReservations;
     @Autowired
     private MerchantOrderPrintService merchantOrderPrintService;
+    @Autowired
+    private MerchantNotificationRouter merchantNotificationRouter;
 
     @Autowired
     private com.ruoyi.app.order.invoice.MerchantInvoicePrintService merchantInvoicePrintService;
@@ -190,6 +192,14 @@ public class PosOrderShOprateController extends BaseController {
         InfoUser merchant = infoUserService.getOne(new LambdaQueryWrapper<InfoUser>()
                 .eq(InfoUser::getUserId, loginUserId));
         validateMerchantOrderInput(merchant, input);
+        if (!StringUtils.isEmpty(input.getInvoiceChoice())) {
+            Long storeId = input.getItems().get(0).getMdId();
+            if (!"PAPER".equals(input.getInvoiceChoice()) && !orderInvoiceService.canInvoice(storeId)) {
+                throw new ServiceException(MessageUtils.message("multi.store.checkout.invoice.unavailable"));
+            }
+            orderInvoiceService.validateCheckoutInvoiceIntent(input.getInvoiceChoice(), input.getCarrierNum(),
+                    input.getLoveCode(), input.getBuyerUbn(), input.getBuyerEmail(), storeId);
+        }
         Long ownerUserId = resolveOrderOwner(merchant);
         String shdz=null;
         createOrderParent(input,ownerUserId,shdz);
@@ -300,7 +310,41 @@ public class PosOrderShOprateController extends BaseController {
                 posOrder.setOrderCategory(String.valueOf(st.getType()));
             });
             setPickUpNum(posOrder);
-            posOrderService.save(posOrder);
+            if (!posOrderService.save(posOrder)) {
+                throw new ServiceException(MessageUtils.message("no.system.error"));
+            }
+            // 发票意图随订单落库;商家现金单提交后开票,不保存商家默认抬头。
+            if (!StringUtils.isEmpty(input.getInvoiceChoice()) && orderInvoiceService.canInvoice(item.getMdId())) {
+                PosOrderInvoice intent = new PosOrderInvoice();
+                intent.setOrderId(posOrder.getId());
+                intent.setOrderNo(subddId);
+                intent.setStoreId(item.getMdId());
+                intent.setInvoiceChoice(input.getInvoiceChoice());
+                intent.setCarrierNum(input.getCarrierNum());
+                intent.setBuyerUbn(input.getBuyerUbn());
+                intent.setBuyerName(input.getBuyerName());
+                intent.setBuyerEmail(input.getBuyerEmail());
+                intent.setLoveCode(input.getLoveCode());
+                if ("PHONE_BARCODE".equals(input.getInvoiceChoice())) {
+                    intent.setCarrierType("0");
+                } else if ("CITIZEN".equals(input.getInvoiceChoice())) {
+                    intent.setCarrierType("1");
+                }
+                orderInvoiceService.captureInvoiceIntent(intent);
+                // 独立提交后回调:开票异常被隔离,后续新单通知仍会发送。
+                if (OrderLifecycleService.PAY_TYPE_CASH.equals(posOrder.getPayType())) {
+                    runNotificationAfterCommit(() -> orderInvoiceService.autoIssue(posOrder.getId()));
+                }
+            }
+            // 现金单创建提交后即可通知商家打印小票;不等待收款,也不改变支付状态。
+            // 回滚不发通知,沿用门店路由和完整子订单号,供App读取小票并按订单号去重。
+            if (OrderLifecycleService.PAY_TYPE_CASH.equals(posOrder.getPayType())) {
+                runNotificationAfterCommit(() -> merchantNotificationRouter.sendStoreNewOrder(
+                        posOrder.getMdId(), posOrder.getShId(), "no.message.push.message",
+                        "no.message.push.new.order",
+                        OrderPushBodyDto.getJson(subddId, String.valueOf(posOrder.getState()), 0),
+                        subddId, posOrder.getType()));
+            }
         }
     }
 

+ 138 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantCashOrderNotificationTest.java

@@ -0,0 +1,138 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.*;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class MerchantCashOrderNotificationTest {
+    private final PosOrderShOprateController controller = new PosOrderShOprateController();
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IOrderParentService parents = mock(IOrderParentService.class);
+    private final MerchantNotificationRouter router = mock(MerchantNotificationRouter.class);
+    private MockedStatic<AuthContext> auth;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setup() {
+        auth = mockStatic(AuthContext.class);
+        auth.when(AuthContext::requireUserId).thenReturn(101L);
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+        InfoUser merchant = new InfoUser();
+        merchant.setUserId(101L);
+        merchant.setUserType("1");
+        IInfoUserService users = mock(IInfoUserService.class);
+        when(users.getOne(any())).thenReturn(merchant);
+        IPosStoreService stores = mock(IPosStoreService.class);
+        PosStore store = new PosStore();
+        store.setId(9);
+        store.setUserId(101L);
+        when(stores.getById(9L)).thenReturn(store);
+        when(stores.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(store));
+        OrderNumberReservationService reservations = mock(OrderNumberReservationService.class);
+        when(reservations.reserveIfUnused(anyString(), anyString())).thenReturn(true);
+        ReflectionTestUtils.setField(controller, "orderNumberReservations", reservations);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "orderParentService", parents);
+        ReflectionTestUtils.setField(controller, "operatingHoursService", mock(IOperatingHoursService.class));
+        ReflectionTestUtils.setField(controller, "selfDeliveryService", mock(SelfDeliveryService.class));
+        ReflectionTestUtils.setField(controller, "stallQualificationGateService", mock(StallQualificationGateService.class));
+        ReflectionTestUtils.setField(controller, "merchantNotificationRouter", router);
+        when(orders.save(any())).thenReturn(true);
+        when(parents.save(any())).thenReturn(true);
+        TransactionSynchronizationManager.initSynchronization();
+    }
+
+    @AfterEach
+    void cleanup() {
+        TransactionSynchronizationManager.clearSynchronization();
+        auth.close();
+        messages.close();
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0, 1, 2})
+    void cashNotifiesOnlyAfterCommitWithFullOrderNumber(long type) {
+        controller.createOrder("token", input("4", type));
+        verifyNoInteractions(router);
+        commit();
+        ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
+        verify(router).sendStoreNewOrder(eq(9L), eq(101L), eq("no.message.push.message"),
+                eq("no.message.push.new.order"), body.capture(), eq("DD-CASH-100"), eq(type));
+        assertEquals("DD-CASH-100", JSON.parseObject(body.getValue()).getString("ddId"));
+        assertEquals(0, JSON.parseObject(body.getValue()).getIntValue("type"));
+        assertEquals(1, JSON.parseObject(body.getValue()).getIntValue("pushType"));
+        ArgumentCaptor<PosOrder> saved = ArgumentCaptor.forClass(PosOrder.class);
+        verify(orders).save(saved.capture());
+        assertEquals(0L, saved.getValue().getPayStatus());
+        verifyNoMoreInteractions(router);
+    }
+
+    @Test
+    void rollbackDoesNotNotify() {
+        controller.createOrder("token", input("4", 2));
+        TransactionSynchronizationManager.getSynchronizations().forEach(
+                s -> s.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
+        verifyNoInteractions(router);
+    }
+
+    @Test
+    void failedSaveDoesNotNotify() {
+        when(orders.save(any())).thenReturn(false);
+        assertThrows(ServiceException.class, () -> controller.createOrder("token", input("4", 2)));
+        commit();
+        verifyNoInteractions(router);
+    }
+
+    @Test
+    void notificationFailureDoesNotFailCommittedCreation() {
+        doThrow(new IllegalStateException("push unavailable")).when(router).sendStoreNewOrder(
+                anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong());
+        controller.createOrder("token", input("4", 2));
+        assertDoesNotThrow(this::commit);
+        verify(router).sendStoreNewOrder(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"1", "2", "3", "5", "6"})
+    void otherPaymentMethodsDoNotSendCashNotification(String method) {
+        controller.createOrder("token", input(method, 2));
+        commit();
+        verifyNoInteractions(router);
+    }
+
+    private void commit() {
+        TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);
+    }
+
+    private OrderCreateInput input(String method, long type) {
+        OrderCreatItem item = new OrderCreatItem();
+        item.setMdId(9L);
+        item.setShId(101L);
+        item.setAmount(280);
+        item.setType(type);
+        OrderCreateInput input = new OrderCreateInput();
+        input.setDdId("DD-CASH-100");
+        input.setPaymentMethod(method);
+        input.setItems(List.of(item));
+        return input;
+    }
+}

+ 212 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantCreateOrderInvoiceChoiceTest.java

@@ -0,0 +1,212 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson2.JSON;
+import com.ruoyi.app.order.dto.OrderCreateInput;
+import com.ruoyi.app.order.dto.OrderCreatItem;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.service.*;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.*;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class MerchantCreateOrderInvoiceChoiceTest {
+    private final PosOrderShOprateController controller = new PosOrderShOprateController();
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IOrderParentService parents = mock(IOrderParentService.class);
+    private final MerchantNotificationRouter router = mock(MerchantNotificationRouter.class);
+    private final OrderInvoiceService invoices = mock(OrderInvoiceService.class);
+    private MockedStatic<AuthContext> auth;
+    private MockedStatic<MessageUtils> messages;
+
+    @BeforeEach
+    void setup() {
+        auth = mockStatic(AuthContext.class);
+        auth.when(AuthContext::requireUserId).thenReturn(101L);
+        messages = mockStatic(MessageUtils.class);
+        messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+        InfoUser merchant = new InfoUser();
+        merchant.setUserId(101L);
+        merchant.setUserType("1");
+        IInfoUserService users = mock(IInfoUserService.class);
+        when(users.getOne(any())).thenReturn(merchant);
+        IPosStoreService stores = mock(IPosStoreService.class);
+        PosStore store = new PosStore();
+        store.setId(9);
+        store.setUserId(101L);
+        when(stores.getById(9L)).thenReturn(store);
+        when(stores.list(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of(store));
+        OrderNumberReservationService reservations = mock(OrderNumberReservationService.class);
+        when(reservations.reserveIfUnused(anyString(), anyString())).thenReturn(true);
+        ReflectionTestUtils.setField(controller, "orderNumberReservations", reservations);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posStoreService", stores);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "orderParentService", parents);
+        ReflectionTestUtils.setField(controller, "operatingHoursService", mock(IOperatingHoursService.class));
+        ReflectionTestUtils.setField(controller, "selfDeliveryService", mock(SelfDeliveryService.class));
+        ReflectionTestUtils.setField(controller, "stallQualificationGateService", mock(StallQualificationGateService.class));
+        ReflectionTestUtils.setField(controller, "merchantNotificationRouter", router);
+        ReflectionTestUtils.setField(controller, "orderInvoiceService", invoices);
+        when(invoices.canInvoice(9L)).thenReturn(true);
+        when(orders.save(any())).thenAnswer(i -> { ((PosOrder)i.getArgument(0)).setId(88L); return true; });
+        when(parents.save(any())).thenReturn(true);
+        TransactionSynchronizationManager.initSynchronization();
+    }
+
+    @AfterEach
+    void cleanup() {
+        TransactionSynchronizationManager.clearSynchronization();
+        auth.close();
+        messages.close();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"PAPER", "PHONE_BARCODE", "CITIZEN", "MEMBER", "LOVE_CODE", "COMPANY"})
+    void savesChoiceAndSupportingDetailsBeforeNotification(String choice) {
+        OrderCreateInput request = input("4", 2);
+        request.setInvoiceChoice(choice);
+        request.setCarrierType("untrusted");
+        request.setCarrierNum("/ABC1234");
+        request.setBuyerUbn("12345678");
+        request.setBuyerName("Company");
+        request.setBuyerEmail("buyer@example.com");
+        request.setLoveCode("123");
+        controller.createOrder("token", request);
+        var sequence = inOrder(invoices, orders);
+        sequence.verify(invoices).validateCheckoutInvoiceIntent(choice, "/ABC1234", "123", "12345678", "buyer@example.com", 9L);
+        sequence.verify(orders).save(any());
+        ArgumentCaptor<PosOrderInvoice> captured = ArgumentCaptor.forClass(PosOrderInvoice.class);
+        sequence.verify(invoices).captureInvoiceIntent(captured.capture());
+        PosOrderInvoice row = captured.getValue();
+        assertEquals(88L, row.getOrderId());
+        assertEquals("DD-CASH-100", row.getOrderNo());
+        assertEquals(9L, row.getStoreId());
+        assertEquals(choice, row.getInvoiceChoice());
+        assertEquals("/ABC1234", row.getCarrierNum());
+        assertEquals("12345678", row.getBuyerUbn());
+        assertEquals("Company", row.getBuyerName());
+        assertEquals("buyer@example.com", row.getBuyerEmail());
+        assertEquals("123", row.getLoveCode());
+        assertEquals("PHONE_BARCODE".equals(choice) ? "0" : "CITIZEN".equals(choice) ? "1" : null, row.getCarrierType());
+        verifyNoInteractions(router);
+    }
+
+    @Test
+    void omittedChoiceKeepsLegacyBehavior() {
+        controller.createOrder("token", input("4", 2));
+        verifyNoInteractions(invoices);
+    }
+
+    @Test
+    void invalidInvoiceStopsBeforeCreatingOrder() {
+        OrderCreateInput request = input("4", 2);
+        request.setInvoiceChoice("INVALID");
+        doThrow(new ServiceException("invalid choice")).when(invoices)
+                .validateCheckoutInvoiceIntent(eq("INVALID"), any(), any(), any(), any(), eq(9L));
+        assertThrows(ServiceException.class, () -> controller.createOrder("token", request));
+        verifyNoInteractions(parents, orders, router);
+    }
+
+    @Test
+    void unsupportedElectronicInvoiceStopsBeforeCreatingOrder() {
+        when(invoices.canInvoice(9L)).thenReturn(false);
+        OrderCreateInput request = input("4", 2);
+        request.setInvoiceChoice("COMPANY");
+        assertThrows(ServiceException.class, () -> controller.createOrder("token", request));
+        verifyNoInteractions(parents, orders, router);
+    }
+
+    @Test
+    void paperAtUnsupportedStoreDoesNotCreateInvoiceIntent() {
+        when(invoices.canInvoice(9L)).thenReturn(false);
+        OrderCreateInput request = input("4", 2);
+        request.setInvoiceChoice("PAPER");
+        controller.createOrder("token", request);
+        verify(orders).save(any());
+        verify(invoices, never()).captureInvoiceIntent(any());
+    }
+
+    @Test
+    void intentSaveFailureDoesNotScheduleNewOrderPush() {
+        OrderCreateInput request = input("4", 2);
+        request.setInvoiceChoice("PAPER");
+        doThrow(new ServiceException("save failed")).when(invoices).captureInvoiceIntent(any());
+        assertThrows(ServiceException.class, () -> controller.createOrder("token", request));
+        assertTrue(TransactionSynchronizationManager.getSynchronizations().isEmpty());
+        verifyNoInteractions(router);
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = {0, 1, 2})
+    void cashIssuesAfterCommitBeforeNewOrderPush(long type) {
+        OrderCreateInput request = input("4", type);
+        request.setInvoiceChoice("PAPER");
+        controller.createOrder("token", request);
+        verify(invoices, never()).autoIssue(anyLong());
+        verifyNoInteractions(router);
+        commit();
+        var sequence = inOrder(invoices, router);
+        sequence.verify(invoices).autoIssue(88L);
+        sequence.verify(router).sendStoreNewOrder(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), eq(type));
+    }
+
+    @Test
+    void rollbackNeverIssuesInvoice() {
+        OrderCreateInput request = input("4", 2);
+        request.setInvoiceChoice("PAPER");
+        controller.createOrder("token", request);
+        TransactionSynchronizationManager.getSynchronizations().forEach(
+                s -> s.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
+        verify(invoices, never()).autoIssue(anyLong());
+        verifyNoInteractions(router);
+    }
+
+    @Test
+    void invoiceExceptionStillSendsNewOrderNotification() {
+        OrderCreateInput request = input("4", 2);
+        request.setInvoiceChoice("PAPER");
+        doThrow(new IllegalStateException("invoice unavailable")).when(invoices).autoIssue(88L);
+        controller.createOrder("token", request);
+        assertDoesNotThrow(this::commit);
+        verify(invoices).autoIssue(88L);
+        verify(router).sendStoreNewOrder(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"1", "2", "3", "5", "6"})
+    void nonCashCreationDoesNotIssueInvoice(String method) {
+        OrderCreateInput request = input(method, 2);
+        request.setInvoiceChoice("PAPER");
+        controller.createOrder("token", request);
+        commit();
+        verify(invoices, never()).autoIssue(anyLong());
+    }
+
+    private void commit() {
+        TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit);
+    }
+
+    private OrderCreateInput input(String method, long type) {
+        OrderCreatItem item = new OrderCreatItem();
+        item.setMdId(9L);
+        item.setShId(101L);
+        item.setAmount(280);
+        item.setType(type);
+        OrderCreateInput input = new OrderCreateInput();
+        input.setDdId("DD-CASH-100");
+        input.setPaymentMethod(method);
+        input.setItems(List.of(item));
+        return input;
+    }
+}

+ 46 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantNotificationRouterTest.java

@@ -50,6 +50,7 @@ class MerchantNotificationRouterTest {
         doAnswer(invocation -> invocation.getArgument(0)).when(router).localize(anyString(), anyLong());
         when(stores.selectPosStoreById(9L)).thenReturn(store(9, 101L));
         when(users.getById(101L)).thenReturn(owner(101L));
+        when(sessions.hasAppSession(101L)).thenReturn(true);
     }
 
     @Test
@@ -195,6 +196,51 @@ class MerchantNotificationRouterTest {
         verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
     }
 
+    @Test
+    void loggedOutOwnerKeepsInboxMessageButDoesNotReceiveDevicePush() {
+        when(sessions.hasAppSession(101L)).thenReturn(false);
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(101L, "title", "content,NO:DD-1", "body");
+        verify(pushService, never()).send(any(), anyLong(), any(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void pcOnlyOwnerDoesNotReceiveAppPush() {
+        when(sessions.isOnline(101L)).thenReturn(true);
+        when(sessions.hasAppSession(101L)).thenReturn(false);
+
+        router.sendStoreNewOrder(9L, 101L, "title", "content", "{\"ddId\":\"DD-1\"}", "DD-1", 0L);
+
+        verify(pushService, never()).send(any(), anyLong(), any(), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void ownerWithoutAppSessionDoesNotBlockOnlineSubaccount() {
+        when(sessions.hasAppSession(101L)).thenReturn(false);
+        when(sessions.hasAppSession(501L)).thenReturn(true);
+        when(relations.selectEligibleSubaccountsByStoreId(9L))
+                .thenReturn(List.of(subaccount(501L, "cid-a")));
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(pushService).send(RecipientApp.MERCHANT, 501L, "cid-a", "title", "content,NO:DD-1", "body");
+        verify(events, never()).PublisherEvent(eq(101L), anyString(), anyString(), anyString());
+    }
+
+    @Test
+    void sessionRevokedAfterRecipientSelectionStopsDevicePush() {
+        when(relations.selectEligibleSubaccountsByStoreId(9L))
+                .thenReturn(List.of(subaccount(501L, "cid-a")));
+        when(sessions.hasAppSession(501L)).thenReturn(true, false);
+
+        router.sendStoreNotification(9L, 101L, "title", "content", "body", "DD-1");
+
+        verify(events).PublisherEvent(501L, "title", "content,NO:DD-1", "body");
+        verify(pushService, never()).send(any(), anyLong(), any(), anyString(), anyString(), anyString());
+    }
+
     private static InfoUser owner(Long id) {
         InfoUser user = new InfoUser();
         user.setUserId(id);

+ 102 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/OrderInvoiceAutoIssueTest.java

@@ -0,0 +1,102 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.ezPay.EzPay;
+import com.ruoyi.system.domain.*;
+import com.ruoyi.system.mapper.*;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class OrderInvoiceAutoIssueTest {
+    private final OrderInvoiceService service = new OrderInvoiceService();
+    private final PosOrderMapper orders = mock(PosOrderMapper.class);
+    private final PosOrderInvoiceMapper invoices = mock(PosOrderInvoiceMapper.class);
+    private final EzPay ezPay = mock(EzPay.class);
+    private final PosOrderInvoice intent = new PosOrderInvoice();
+    private final PosOrder order = new PosOrder();
+
+    @BeforeEach
+    void setup() {
+        order.setId(88L);
+        order.setDdId("CASH-88");
+        order.setMdId(9L);
+        order.setUserId(101L);
+        order.setOrderSource("MERCHANT");
+        order.setPayType("4");
+        order.setPayStatus(0L);
+        order.setState(1L);
+        order.setAmount(100);
+        intent.setId(1L);
+        intent.setOrderId(88L);
+        intent.setInvoiceChoice("PAPER");
+        intent.setInvoiceStatus(0);
+        when(orders.selectPosOrderById(88L)).thenReturn(order);
+        when(invoices.selectOne(any())).thenReturn(intent);
+        PosStoreEzpay config = new PosStoreEzpay();
+        config.setEzpayStatus(2);
+        config.setIsEnabled(1);
+        PosStoreEzpayMapper configs = mock(PosStoreEzpayMapper.class);
+        when(configs.selectOne(any())).thenReturn(config);
+        ReflectionTestUtils.setField(service, "posOrderMapper", orders);
+        ReflectionTestUtils.setField(service, "posOrderInvoiceMapper", invoices);
+        ReflectionTestUtils.setField(service, "posStoreEzpayMapper", configs);
+        ReflectionTestUtils.setField(service, "posStoreMapper", mock(PosStoreMapper.class));
+        ReflectionTestUtils.setField(service, "infoUserService", mock(IInfoUserService.class));
+        ReflectionTestUtils.setField(service, "ezPay", ezPay);
+        ReflectionTestUtils.setField(service, "ezpayBaseUrl", "test");
+    }
+
+    @Test
+    void usesIndependentTransactionForAfterCommitPersistence() throws Exception {
+        Transactional config = OrderInvoiceService.class.getMethod("autoIssue", Long.class)
+                .getAnnotation(Transactional.class);
+        assertNotNull(config);
+        assertEquals(Propagation.REQUIRES_NEW, config.propagation());
+    }
+
+    @Test
+    void successfulCashInvoiceIsSavedAndLaterTriggerDoesNotIssueAgain() throws Exception {
+        JSONObject response = JSONObject.parseObject("{\"Status\":\"SUCCESS\",\"Result\":{\"InvoiceNumber\":\"AB12345678\"}}");
+        when(ezPay.issueInvoice(anyString(), any(), anyMap())).thenReturn(response);
+        assertEquals(1, service.autoIssue(88L));
+        assertEquals(1, intent.getInvoiceStatus());
+        assertEquals(1, intent.getIssueTriggered());
+        assertEquals("AB12345678", intent.getInvoiceNumber());
+        assertEquals(0L, order.getPayStatus());
+        verify(invoices).updateById(intent);
+        assertEquals(0, service.autoIssue(88L));
+        verify(ezPay, times(1)).issueInvoice(anyString(), any(), anyMap());
+    }
+
+    @Test
+    void externalFailureIsRecordedWithoutMarkingOrderPaid() throws Exception {
+        when(ezPay.issueInvoice(anyString(), any(), anyMap())).thenThrow(new IllegalStateException("offline"));
+        assertEquals(0, service.autoIssue(88L));
+        assertEquals(2, intent.getInvoiceStatus());
+        assertNotNull(intent.getFailReason());
+        assertEquals(0L, order.getPayStatus());
+        verify(invoices).updateById(intent);
+    }
+
+    @Test
+    void cancelledOrderDoesNotIssue() {
+        order.setState(4L);
+        assertEquals(0, service.autoIssue(88L));
+        verifyNoInteractions(ezPay);
+    }
+
+    @Test
+    void invalidatedInvoiceIsNotAutomaticallyReissued() {
+        intent.setInvoiceStatus(3);
+        assertEquals(0, service.autoIssue(88L));
+        verifyNoInteractions(ezPay);
+    }
+}

+ 442 - 0
specs/010-order-invoice/bluetooth-printing/BluetoothPrinter.js

@@ -0,0 +1,442 @@
+/**
+ * 编码横向1位点阵(1为黑,最高位为左像素),不能直接传PNG或Base64。
+ * 标准模式、正常倍率、空行缓冲下使用GS v 0;保持所有像素和空白高度。
+ */
+export function encodeMonoRaster(data, width, height, options = {}) {
+	if (!(data instanceof Uint8Array) || !Number.isInteger(width) || width < 8 || width > 384 ||
+		width % 8 || !Number.isInteger(height) || height < 1 || height > 40000 || data.length !== width / 8 * height) {
+		throw new Error('点阵必须是宽度不超过384且为8的倍数、长度匹配的1位黑白数据');
+	}
+	const stride = width / 8, blocks = [];
+	const append = (from, to, blank) => {
+		for (let y = from; y < to; y += 2048) {
+			const rows = Math.min(2048, to - y), columns = blank ? 1 : stride;
+			const block = new Uint8Array(8 + columns * rows);
+			block.set([29, 118, 48, 0, columns, 0, rows & 255, rows >> 8]);
+			if (!blank) block.set(data.subarray(y * stride, (y + rows) * stride), 8);
+			blocks.push(block);
+		}
+	};
+	let contentStart = 0;
+	if (options.compactBlankRows !== false) {
+		let y = 0;
+		while (y < height) {
+			const start = y;
+			while (y < height && data.subarray(y * stride, (y + 1) * stride).every(value => value === 0)) y++;
+			if (y - start >= 8) {
+				append(contentStart, start, false);
+				// 全白行只传1字节宽,仍按原行数打印,不依赖走纸指令的单位。
+				append(start, y, true);
+				contentStart = y;
+			}
+			if (start === y) y++;
+		}
+	}
+	append(contentStart, height, false);
+	let length = blocks.reduce((sum, block) => sum + block.length, 0);
+	// 块头使数据变大时退回原始编码,适用于极窄图或密集短空白。
+	if (length > data.length + Math.ceil(height / 2048) * 8) {
+		blocks.length = 0;
+		append(0, height, false);
+		length = blocks.reduce((sum, block) => sum + block.length, 0);
+	}
+	const result = new Uint8Array(length);
+	let offset = 0;
+	for (const block of blocks) { result.set(block, offset); offset += block.length; }
+	return result.buffer;
+}
+
+export function choosePrinterCharacteristic(candidates) {
+	const writable = candidates.filter(item => {
+		const service = String(item.serviceId).toLowerCase();
+		const characteristic = String(item.characteristicId).toLowerCase();
+		// GAP/GATT and device information characteristics are not print data channels.
+		if (/^000018(?:0[0-9a-f]|[12][0-9a-f]|3[0-9a-f])-0000-1000-8000-00805f9b34fb$/.test(service) ||
+			/^00002a00-0000-1000-8000-00805f9b34fb$/.test(characteristic)) return false;
+		return item.write || item.writeNoResponse;
+	});
+	writable.sort((a, b) => {
+		const priority = item => /^(0000ff00|000018f0)-/i.test(item.serviceId) ? 0 : 1;
+		return priority(a) - priority(b) || Number(!!b.write) - Number(!!a.write);
+	});
+	return writable[0] || null;
+}
+
+/**
+ * 蓝牙打印机管理器
+ * 处理蓝牙连接和数据发送
+ */
+
+class BluetoothPrinter {
+	constructor(options = {}) {
+		this.deviceId = '';
+		this.serviceId = '';
+		this.characteristicId = '';
+		this.writeType = 'write'; // 写入类型:'write' 或 'writeNoResponse'
+		this.writeDelayMs = 0;
+		this.transportProfile = null;
+		this.sending = false;
+		this.streamInterrupted = false;
+		this.writeTimeoutMs = Number.isFinite(options.writeTimeoutMs) && options.writeTimeoutMs > 0
+			? options.writeTimeoutMs : 10000;
+		this.isConnected = false;
+		this.silent = options.silent || false; // 是否静默模式(不显示提示)
+	}
+
+	/**
+	 * 初始化蓝牙适配器
+	 * @returns {Promise}
+	 */
+	async initBluetoothAdapter() {
+		return new Promise((resolve, reject) => {
+			uni.openBluetoothAdapter({
+				success: (res) => {
+					console.log('蓝牙适配器打开成功');
+					resolve(res);
+				},
+				fail: (err) => {
+					console.error('蓝牙适配器打开失败:', err);
+					if (err.errCode === 10001 && !this.silent) {
+						// 只有在非静默模式下才显示提示
+						uni.showModal({
+							title: '提示',
+							content: '检测到蓝牙未打开,请打开蓝牙后重试',
+							showCancel: true,
+							confirmText: '知道了'
+						});
+					}
+					reject(err);
+				}
+			});
+		});
+	}
+
+	/**
+	 * 搜索蓝牙设备
+	 * @param {Function} onDeviceFound - 发现设备时的回调函数
+	 * @returns {Promise}
+	 */
+	async searchDevices(onDeviceFound) {
+		return new Promise((resolve, reject) => {
+			uni.getBluetoothAdapterState({
+				success: (res) => {
+					if (!res.available) {
+						reject(new Error('本机蓝牙不可用'));
+						return;
+					}
+
+					if (res.discovering) {
+						uni.stopBluetoothDevicesDiscovery({
+							success: () => {
+								this.startDiscovery(onDeviceFound, resolve, reject);
+							}
+						});
+					} else {
+						this.startDiscovery(onDeviceFound, resolve, reject);
+					}
+				},
+				fail: reject
+			});
+		});
+	}
+
+	/**
+	 * 开始搜索设备
+	 */
+	startDiscovery(onDeviceFound, resolve, reject) {
+		// 监听寻找到新设备的事件
+		uni.onBluetoothDeviceFound((devices) => {
+			if (onDeviceFound) {
+				onDeviceFound(devices);
+			}
+		});
+
+		// 获取平台信息
+		const systemInfo = uni.getSystemInfoSync();
+		const isIOS = systemInfo.platform === 'ios';
+
+		// iOS 端可能需要指定 services 参数,但先尝试不指定(通用蓝牙搜索)
+		// 如果打印机支持标准 BLE 服务,可以尝试添加 services 参数
+		// 常见的 ESC/POS 打印机服务 UUID: '0000ff00-0000-1000-8000-00805f9b34fb' 或 '000018f0-0000-1000-8000-00805f9b34fb'
+		const discoveryOptions = {
+			success: (res) => {
+				console.log('开始搜索蓝牙设备', isIOS ? '(iOS)' : '(Android)');
+				resolve(res);
+			},
+			fail: (err) => {
+				console.error('搜索蓝牙设备失败:', err);
+				// iOS 端如果失败,可能是权限问题或需要 services 参数
+				if (isIOS) {
+					console.warn('iOS 端搜索失败,可能需要检查蓝牙权限或指定服务 UUID');
+				}
+				reject(err);
+			}
+		};
+
+		// iOS 端可以尝试添加 allowDuplicatesKey 参数,允许重复发现设备
+		if (isIOS) {
+			discoveryOptions.allowDuplicatesKey = false; // iOS 端建议设为 false
+		}
+
+		uni.startBluetoothDevicesDiscovery(discoveryOptions);
+	}
+
+	/**
+	 * 停止搜索设备
+	 */
+	stopDiscovery() {
+		return new Promise((resolve, reject) => {
+			uni.stopBluetoothDevicesDiscovery({
+				success: resolve,
+				fail: reject
+			});
+		});
+	}
+
+	/**
+	 * 连接蓝牙设备
+	 * @param {String} deviceId - 设备ID
+	 * @returns {Promise}
+	 */
+	async connect(deviceId) {
+		return new Promise((resolve, reject) => {
+			// 如果已经连接到同一个设备,直接返回成功
+			if (this.isConnected && this.deviceId === deviceId && this.serviceId && this.characteristicId) {
+				console.log('蓝牙已连接,无需重复连接');
+				resolve();
+				return;
+			}
+
+			if (this.sending) { reject(new Error('打印发送进行中,不能切换连接')); return; }
+			this.transportProfile = null;
+			uni.createBLEConnection({
+				deviceId: deviceId,
+				success: (res) => {
+					console.log('蓝牙连接成功');
+					this.deviceId = deviceId;
+					this.isConnected = true;
+					// 连接成功后获取服务,增加延迟确保设备完全准备好
+					// iOS 端可能需要更长的延迟时间
+					const systemInfo = uni.getSystemInfoSync();
+					const isIOS = systemInfo.platform === 'ios';
+					const delay = isIOS ? 500 : 300; // iOS 延迟 500ms,Android 延迟 300ms
+					setTimeout(() => {
+						this.getBLEServices(deviceId).then(resolve).catch(reject);
+					}, delay);
+				},
+				fail: (err) => {
+					// 如果错误是"already connect",当作成功处理
+					if (err.errMsg && err.errMsg.includes('already connect')) {
+						console.log('蓝牙已经连接');
+						this.deviceId = deviceId;
+						this.isConnected = true;
+						// 已经连接的话,直接获取服务
+						setTimeout(() => {
+							this.getBLEServices(deviceId).then(resolve).catch(reject);
+						}, 100);
+					} else {
+						console.error('蓝牙连接失败:', err);
+						this.isConnected = false;
+						reject(err);
+					}
+				}
+			});
+		});
+	}
+
+	/**
+	 * 获取蓝牙设备服务
+	 * @param {String} deviceId - 设备ID
+	 * @returns {Promise}
+	 */
+	async getBLEServices(deviceId) {
+		return new Promise((resolve, reject) => {
+			uni.getBLEDeviceServices({
+				deviceId: deviceId,
+				success: (res) => {
+					if (!res.services || res.services.length === 0) {
+						reject(new Error('该蓝牙设备不可用'));
+						return;
+					}
+
+					const servicesPromise = [];
+					const candidates = [];
+
+					for (let s = 0; s < res.services.length; s++) {
+						const serviceId = res.services[s].uuid;
+						const prom = new Promise((resolveService) => {
+							uni.getBLEDeviceCharacteristics({
+								deviceId: deviceId,
+								serviceId: serviceId,
+								success: (charRes) => {
+									if (charRes.characteristics && charRes.characteristics.length > 0) {
+										for (let c = 0; c < charRes.characteristics.length; c++) {
+											const char = charRes.characteristics[c];
+											candidates.push({
+												serviceId,
+												characteristicId: char.uuid,
+												write: char.properties.write === true,
+												writeNoResponse: char.properties.writeNoResponse === true
+											});
+										}
+									}
+									// 无论是否找到特征值,都 resolve,继续尝试下一个服务
+									resolveService();
+								},
+								fail: (err) => {
+									// 某些服务可能没有特征值(如系统服务),这是正常的,不视为错误
+									// 继续尝试下一个服务
+									console.warn(`服务 ${serviceId} 获取特征值失败:`, err.errMsg || err);
+									resolveService();
+								}
+							});
+						});
+						servicesPromise.push(prom);
+					}
+
+					// 使用 Promise.allSettled 确保所有服务都尝试完成,即使某些失败也不影响
+					Promise.allSettled(servicesPromise).then(() => {
+						const selected = choosePrinterCharacteristic(candidates);
+						if (selected) {
+							this.transportProfile = null;
+							this.serviceId = selected.serviceId;
+							this.characteristicId = selected.characteristicId;
+							this.writeType = selected.write ? 'write' : 'writeNoResponse';
+							console.log('找到可写特征值:', this.serviceId, this.characteristicId,
+								'写入类型:', this.writeType, '支持 write:', selected.write,
+								'支持 writeNoResponse:', selected.writeNoResponse);
+							resolve();
+						} else {
+							reject(new Error('未找到蓝牙打印数据通道,请确认设备支持 BLE ESC/POS 打印'));
+						}
+					});
+				},
+				fail: reject
+			});
+		});
+	}
+
+	/**
+	 * 断开蓝牙连接
+	 */
+	disconnect() {
+		this.transportProfile = null;
+		if (this.deviceId && this.isConnected) {
+			uni.closeBLEConnection({
+				deviceId: this.deviceId
+			});
+			this.deviceId = '';
+			this.serviceId = '';
+			this.characteristicId = '';
+			this.writeType = 'write'; // 重置写入类型
+			this.writeDelayMs = 0;
+			this.isConnected = false;
+		}
+	}
+
+	/**
+	 * 发送打印数据
+	 * @param {ArrayBuffer} buffer - 打印数据缓冲区
+	 * @param {Function} onProgress - 进度回调函数 (current, total)
+	 * @returns {Promise}
+	 */
+	setTransportProfile({ deviceId, maxWriteBytes = 20, writeDelayMs } = {}) {
+		// 只能使用当前连接实际确认的有效负载上限,不能直接使用请求MTU。
+		if (this.sending || !this.isConnected || deviceId !== this.deviceId ||
+			!Number.isInteger(maxWriteBytes) || maxWriteBytes < 1 || maxWriteBytes > 509 ||
+			(writeDelayMs !== undefined && (!Number.isFinite(writeDelayMs) || writeDelayMs < 0 || writeDelayMs > 1000))) {
+			throw new Error('打印连接传输参数无效');
+		}
+		this.transportProfile = { deviceId, maxWriteBytes, writeDelayMs };
+	}
+
+	/** 中断后需要操作员清空/重启打印机;仅重连不代表设备的点阵缓冲已清空。 */
+	confirmPrinterBufferCleared() {
+		if (this.sending) throw new Error('打印发送进行中');
+		this.streamInterrupted = false;
+	}
+
+	async sendPrintData(buffer, onProgress) {
+		if (this.sending) throw new Error('打印发送进行中,请顺序发送');
+		if (this.streamInterrupted) throw new Error('打印流已中断,请清空打印机缓冲后确认恢复');
+		if (!this.isConnected || !this.deviceId || !this.serviceId || !this.characteristicId) throw new Error('蓝牙未连接');
+		if (!(buffer instanceof ArrayBuffer)) throw new Error('打印数据必须是ArrayBuffer');
+		const profile = this.transportProfile?.deviceId === this.deviceId ? this.transportProfile : null;
+		const maxChunk = profile?.maxWriteBytes || 20;
+		const total = buffer.byteLength;
+		const isIOS = /^(ios|iphone|ipad)$/.test(uni.getSystemInfoSync().platform);
+		const delayMs = profile?.writeDelayMs ?? Math.max(isIOS ? 20 : 10, this.writeDelayMs);
+		let lastProgressAt = 0;
+		this.sending = true;
+		try {
+			for (let offset = 0; offset < total; offset += maxChunk) {
+				const end = Math.min(offset + maxChunk, total);
+				await this.writeBLECharacteristicValue(buffer.slice(offset, end));
+				// 未实测设备保留原安全间隔,包括末包,避免下一分段立即写入。
+				if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs));
+				if (onProgress && (end === total || Date.now() - lastProgressAt >= 100)) {
+					lastProgressAt = Date.now();
+					// UI异常不能触发重发或截断打印数据。
+					try { onProgress(end, total); } catch (error) { console.warn('打印进度回调失败', error); }
+				}
+			}
+		} catch (error) {
+			// 失败回调不能证明设备未收到数据,禁止自动重放导致像素/指令错位。
+			this.streamInterrupted = true;
+			throw error;
+		} finally {
+			this.sending = false;
+		}
+	}
+
+	/** 串行写入并设置超时;迟到回调不再改变任务结果。 */
+	writeBLECharacteristicValue(buffer) {
+		return new Promise((resolve, reject) => {
+			if (!this.isConnected || !this.deviceId || !this.serviceId || !this.characteristicId) {
+				reject(new Error('蓝牙未连接,无法写入数据'));
+				return;
+			}
+			let settled = false;
+			const finish = (error, result) => {
+				if (settled) return;
+				settled = true;
+				clearTimeout(timer);
+				if (error) reject(error); else resolve(result);
+			};
+			const timer = setTimeout(() => finish(new Error('蓝牙写入超时,请检查打印机')), this.writeTimeoutMs);
+			try {
+				uni.writeBLECharacteristicValue({
+					deviceId: this.deviceId, serviceId: this.serviceId,
+					characteristicId: this.characteristicId, value: buffer, writeType: this.writeType,
+					success: res => {
+						if (!res?.errMsg || res.errMsg.includes('ok')) finish(null, res);
+						else finish(new Error(res.errMsg));
+					},
+					fail: error => finish(error)
+				});
+			} catch (error) { finish(error); }
+		});
+	}
+
+	/**
+	 * 从本地存储加载设备ID
+	 */
+	loadDeviceFromStorage() {
+		const deviceId = uni.getStorageSync('iasei-bluetooth-id');
+		if (deviceId) {
+			this.deviceId = deviceId;
+		}
+		return deviceId;
+	}
+
+	/**
+	 * 保存设备ID到本地存储
+	 * @param {String} deviceId - 设备ID
+	 */
+	saveDeviceToStorage(deviceId) {
+		uni.setStorageSync('iasei-bluetooth-id', deviceId);
+		this.deviceId = deviceId;
+	}
+}
+
+export default BluetoothPrinter;

+ 154 - 0
specs/010-order-invoice/bluetooth-printing/BluetoothPrinter.test.cjs

@@ -0,0 +1,154 @@
+const {test} = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const path = require('node:path');
+function load(write) {
+  const source = fs.readFileSync(path.join(__dirname, 'BluetoothPrinter.js'), 'utf8')
+    .replace(/export function /g, 'function ')
+    .replace('export default BluetoothPrinter;', 'globalThis.Printer = BluetoothPrinter; globalThis.encode = typeof encodeMonoRaster === "function" ? encodeMonoRaster : undefined;');
+  const context = {ArrayBuffer, Uint8Array, Date, Promise, setTimeout, clearTimeout,
+    console: {log(){}, warn(){}, error(){}},
+    uni: {getSystemInfoSync: () => ({platform:'android'}), writeBLECharacteristicValue: write}};
+  vm.runInNewContext(source, context);
+  return context;
+}
+function printer(write) {
+  const context = load(write);
+  const p = new context.Printer({writeTimeoutMs:30});
+  Object.assign(p, {isConnected:true, deviceId:'device', serviceId:'service', characteristicId:'characteristic'});
+  return p;
+}
+// 独立解码GS v 0;将白色窄光栅补回完整宽度,验证高度和全部黑色像素不变。
+function decode(buffer, width) {
+  const bytes = new Uint8Array(buffer), rows = [];
+  for (let i=0; i<bytes.length;) {
+    assert.deepEqual(Array.from(bytes.slice(i,i+4)), [29,118,48,0]);
+    const stride=bytes[i+4]+bytes[i+5]*256, height=bytes[i+6]+bytes[i+7]*256;
+    assert.ok(stride>0 && stride<=width/8 && height>0);
+    i+=8;
+    assert.ok(i+stride*height<=bytes.length);
+    for(let y=0;y<height;y++) {
+      const row = new Uint8Array(width/8);
+      row.set(bytes.slice(i,i+stride)); rows.push(...row); i+=stride;
+    }
+  }
+  return new Uint8Array(rows);
+}
+test('混合空白与非空行压缩后逐像素和高度一致', () => {
+  const {encode} = load();
+  const data = new Uint8Array(48*720);
+  for(let y=110;y<320;y++) for(let x=0;x<48;x++) data[y*48+x]=(x*37+y)%256;
+  const encoded = encode(data,384,720);
+  assert.deepEqual(decode(encoded,384),data);
+  assert.ok(encoded.byteLength < data.byteLength*0.5);
+});
+test('黑白交替、全白、全黑、短空白和2048行边界不改变数据', () => {
+  const {encode} = load();
+  for (const height of [1,7,8,255,2048,2050]) for(const mode of ['white','black','alternating']) {
+    const data=new Uint8Array(48*height);
+    for(let y=0;y<height;y++) if(mode==='black'||mode==='alternating'&&y%2) data.fill(255,y*48,(y+1)*48);
+    const encoded=encode(data,384,height);
+    assert.deepEqual(decode(encoded,384),data);
+    assert.ok(encoded.byteLength<=data.byteLength+8*Math.ceil(height/2048));
+  }
+});
+test('拒绝PNG或长度/尺寸不正确的点阵,关闭优化仍可正常还原', () => {
+  const {encode} = load();
+  assert.throws(()=>encode(new Uint8Array(7),384,2));
+  assert.throws(()=>encode(new Uint8Array(48),383,1));
+  assert.throws(()=>encode(new Uint8Array(0),384,0));
+  const data=new Uint8Array(48*100);
+  const result=encode(data,384,100,{compactBlankRows:false});
+  assert.equal(result.byteLength,4808);
+  assert.deepEqual(decode(result,384),data);
+});
+test('默认20字节且原始数据顺序不变',async()=>{
+  const chunks=[];
+  const p=printer(o=>{chunks.push(new Uint8Array(o.value));o.success({errMsg:'ok'});});
+  const data=Uint8Array.from({length:43},(_,i)=>i);
+  await p.sendPrintData(data.buffer);
+  assert.deepEqual(chunks.map(c=>c.length),[20,20,3]);
+  assert.deepEqual(Uint8Array.from(chunks.flatMap(c=>Array.from(c))),data);
+});
+test('已验证的连接传输参数降低分包数,且限制作用于当前设备',async()=>{
+  const chunks=[];
+  const p=printer(o=>{chunks.push(o.value.byteLength);o.success({errMsg:'ok'});});
+  assert.throws(()=>p.setTransportProfile({deviceId:'other',maxWriteBytes:180,writeDelayMs:0}));
+  assert.throws(()=>p.setTransportProfile({deviceId:'device',maxWriteBytes:0}));
+  p.setTransportProfile({deviceId:'device',maxWriteBytes:180,writeDelayMs:0});
+  await p.sendPrintData(new ArrayBuffer(361));
+  assert.deepEqual(chunks,[180,180,1]);
+});
+test('整次发送互斥,进度节流且最终进度必达',async()=>{
+  let first;
+  const p=printer(o=>{if(!first)first=o;else o.success({errMsg:'ok'});});
+  p.setTransportProfile({deviceId:'device',maxWriteBytes:20,writeDelayMs:0});
+  const progress=[];
+  const pending=p.sendPrintData(new ArrayBuffer(2000),(a,b)=>progress.push([a,b]));
+  await assert.rejects(p.sendPrintData(new ArrayBuffer(20)),/进行中/);
+  first.success({errMsg:'ok'});
+  await pending;
+  assert.ok(progress.length<10);
+  assert.deepEqual(progress.at(-1),[2000,2000]);
+});
+test('写入失败不重试,禁止后续任务接在可能残缺的光栅后',async()=>{
+  let writes=0;
+  const p=printer(o=>{writes++;o.fail({errCode:10008,errMsg:'fail'});});
+  await assert.rejects(p.sendPrintData(new ArrayBuffer(40)));
+  await assert.rejects(p.sendPrintData(new ArrayBuffer(40)),/中断/);
+  assert.equal(writes,1);
+});
+test('写入无回调会超时,不重发数据',async()=>{
+  let writes=0;
+  const p=printer(()=>writes++);
+  await assert.rejects(p.sendPrintData(new ArrayBuffer(40)),/超时/);
+  assert.equal(writes,1);
+});
+test('UI进度回调异常不重发或中断数据',async()=>{
+  let bytes=0;
+  const p=printer(o=>{bytes+=o.value.byteLength;o.success({errMsg:'ok'});});
+  await p.sendPrintData(new ArrayBuffer(21),()=>{throw new Error('UI failure');});
+  assert.equal(bytes,21);
+});
+module.exports={load,decode};
+
+test('错误后的人工恢复与传输参数清理',async()=>{
+  let fail=true;
+  const p=printer(o=>fail?o.fail({errCode:10007}):o.success({errMsg:'ok'}));
+  p.setTransportProfile({deviceId:'device',maxWriteBytes:180,writeDelayMs:0});
+  await assert.rejects(p.sendPrintData(new ArrayBuffer(8)));
+  fail=false;
+  p.confirmPrinterBufferCleared();
+  await p.sendPrintData(new ArrayBuffer(8));
+  // 断开时禁止把已测连接的包长沿用到另一台打印机。
+  p.isConnected=false;
+  p.disconnect();
+  assert.equal(p.transportProfile,null);
+});
+
+test('超时后的迟到成功回调不会继续发送余下数据',async()=>{
+  let late, writes=0;
+  const p=printer(o=>{late=o;writes++;});
+  await assert.rejects(p.sendPrintData(new ArrayBuffer(41)),/超时/);
+  late.success({errMsg:'ok'});
+  await Promise.resolve();
+  assert.equal(writes,1);
+  await assert.rejects(p.sendPrintData(new ArrayBuffer(1)),/中断/);
+});
+
+// 可选地传入由真实PNG解码得到的点阵样张;没有样张时仍运行全部独立单元测试。
+if (process.env.INVOICE_RASTER_SAMPLES) {
+  const samples=JSON.parse(fs.readFileSync(process.env.INVOICE_RASTER_SAMPLES,'utf8'));
+  for(const sample of samples) test(`实际发票样张像素还原:${sample.name}`,()=>{
+    const data=new Uint8Array(Buffer.from(sample.raster,'base64'));
+    const {encode}=load();
+    const before=encode(data,sample.width,sample.height,{compactBlankRows:false});
+    const after=encode(data,sample.width,sample.height);
+    assert.deepEqual(decode(after,sample.width),data);
+    assert.ok(after.byteLength<=before.byteLength);
+    console.log(JSON.stringify({name:sample.name,width:sample.width,height:sample.height,
+      pngBytes:sample.pngBytes,before:before.byteLength,after:after.byteLength,
+      savedPercent:Math.round((1-after.byteLength/before.byteLength)*1000)/10}));
+  });
+}

+ 14 - 0
specs/010-order-invoice/bluetooth-printing/README.md

@@ -0,0 +1,14 @@
+# 蓝牙发票打印优化
+
+本目录交付可替换的 `BluetoothPrinter.js`,原微信附件不改动。后端图片与接口不变。
+
+## 设计与实施计划
+
+按用户确认优化传输体积、保留清晰度。图片保持384点宽、8点/mm和所有空白高度。
+
+- 编写点阵还原、分包顺序、并发、失败与超时测试,先验证旧文件不能满足新增行为。
+- 新增明确的1位点阵编码入口:连续空白行用1字节宽的白色GS v 0光栅块表示,非空行原样保留;不使用走纸指令,不改变单位或缩放。小于原始编码才启用优化。
+- 蓝牙整任务互斥、进度节流、超时停止;只对明确确认的设备单包上限调整分包,未知设备保持20字节和原发送间隔;不自动重放失败包。
+- 模拟还原逐字节核对,并用本地发票PNG样张测量数据量;补充前端接入说明。实体打印验收由前端使用目标打印机完成。
+
+本轮不改变发票首次/补印规则、不执行SQL、不部署,也不创建分支。

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

@@ -56,3 +56,26 @@
 - 六种选择及无类型订单均显示对应文案,未开票订单可显示已保存类型。
 - 请求失败时按现有逻辑显示错误,不把无访问权限当作“暂无发票信息”。
 - 商家原有打印入口和其他订单详情字段正常。
+
+## 商家下单传入发票类型
+
+`POST /system/orderShOprate/createOrder`(请求头 `token`)在 JSON 顶层传 `invoiceChoice`,与 `paymentMethod` 同级,不能放在 `items` 内。其他下单必填参数保持不变。
+
+| 选择 | 配套参数 |
+|---|---|
+| `PAPER` | 无 |
+| `PHONE_BARCODE` | `carrierNum`:手机条码 |
+| `CITIZEN` | `carrierNum`:自然人凭证号码 |
+| `MEMBER` | 无 |
+| `LOVE_CODE` | `loveCode`:捐赠码 |
+| `COMPANY` | `buyerUbn`:8位统编;`buyerEmail`:买方邮箱;`buyerName`:公司名称 |
+
+例如,现金纸本订单在原请求顶层增加 `"invoiceChoice": "PAPER"`;公司发票还需带对应的买方资料。载具类型由后端根据 `invoiceChoice` 确定,无需传 `carrierType`。
+
+不传或传空字符串时不记录发票选择;不支持开票的门店选择纸本可正常下单,但不会生成电子发票意图,详情类型仍为空。其他类型在门店不支持开票时返回业务错误。商家下单不支持把顾客发票资料保存为商家默认抬头。下单响应结构不变,保存的选择从本页订单详情接口读取;商家现金单在创建提交后尝试自动开票;开票可能失败或跳过,须查询发票接口确认结果,不能只凭创建成功判断已开票。
+
+## 现金订单创建后的发票结果
+
+商家现金订单(`paymentMethod="4"`)传入有效发票选择且门店支持开票时,后端在创建提交后自动尝试开票,再发送新订单通知。App 收到通知后,可调用 `GET /system/orderShOprate/getInvoice?orderId=订单主键` 查询结果,按原 `canPrintInvoice`、`invoicePrintCount` 等打印资格操作。小票仍通过完整 `ddId` 获取;发票查询用订单主键。
+
+开票失败不影响下单或新单通知,也不代表现金已收款。未传发票选择或门店不支持开票时不会生成电子发票。非现金订单本次不增加创建即开票的行为。

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

@@ -256,3 +256,11 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 2. 共用订单详情与骑手订单详情在权限校验后,将结果放入响应 data.invoiceChoice,不修改订单实体持久化结构。
 3. 覆盖六种选择、无发票/空选择、用户/商家/分管账号、骑手及拒绝越权后不查发票,验证只查询指定订单和选择列。
 4. 更新前端字段枚举与示例;运行发票及受影响订单详情回归、JDK21模块打包与差异检查。无需SQL迁移或第三方调用。
+
+## 2026-11-06 商家下单发票选择接入
+
+复用已有 DTO、`validateCheckoutInvoiceIntent`、`canInvoice`、`captureInvoiceIntent`,仅接通商家创建入口:订单权限校验之后、父单写入之前验证;子订单成功保存之后、新单提交回调注册之前保存发票意图。配套字段沿用已有载具、统编、买方、捐赠字段,无 SQL。先通过定向测试复现忽略参数,再验证六类选择、缺省兼容、无开票能力、校验失败及保存失败,并回归现金通知和订单详情。
+
+## 2026-11-06 现金创建提交后开票实施
+
+在商家现金订单发票意图保存后注册开票回调,并置于原新单通知回调之前。复用异常隔离机制;`OrderInvoiceService.autoIssue` 使用 REQUIRES_NEW 事务与意图行 FOR UPDATE 锁,确保 afterCommit 中开票结果提交且自动触发不并发重开。回归现金三种订单类型、回滚/非现金不触发、异常仍推送、重复触发、成功/失败结果和收款状态;不修改支付、打印设备或数据库结构。

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

@@ -303,3 +303,13 @@
 - 无发票记录或未保存选择返回 null,前端显示“暂无发票信息”,不默认纸本。MEMBER 是会员选择,当前开票流程实际为纸本;类型不代表已开票、可打印或已交付。
 - 仅在既有订单归属鉴权通过后读取本地选择;不附加载具号、邮箱、统编或开票凭证等信息。
 - 本轮只补两个订单详情响应和前端接入文档,不新增数据库字段、不修改开票/支付/打印流程,不扩展订单列表或闪送发票,不创建分支。
+
+## 2026-11-06 商家下单接收发票选择
+
+`POST /system/orderShOprate/createOrder` 使用已有 `OrderCreateInput.invoiceChoice`,支持 PAPER、PHONE_BARCODE、CITIZEN、MEMBER、LOVE_CODE、COMPANY。不传或空字符串保持原行为。复用现有发票校验及门店可开票规则:非纸本选择要求门店支持开票;纸本在不支持开票门店仍可下单但不创建电子发票意图。可开票门店在订单保存后,同一事务保存订单主键、完整订单号、门店及发票配套资料至 `pos_order_invoice`,供详情查询和既有出餐开票使用。载具类型从选择推导,不信任客户端冲突值。商家代客下单不把顾客资料保存为商家默认抬头,商家现金单创建提交后调用自动开票。发票意图保存失败回滚订单,不触发现金新单通知。
+
+## 2026-11-06 商家现金创建后自动开票
+
+商家 `createOrder` 的现金 `payType=4` 订单,在订单与发票意图事务提交后立即调用既有自动开票,覆盖外送、自取、堂食;先尝试开票再发送商家新单通知。未传发票选择或门店不可开票则不触发,不为缺省请求补建发票意图。保持 `payStatus=0`,确认收款仍由原流程负责。
+
+自动开票使用独立事务保存结果,发票意图行锁串行化创建/出餐自动开票;已开或已作废跳过,已取消订单跳过。开票服务返回失败或抛异常不影响已提交订单及后续通知。ezPay失败沿用原有失败记录和后台重试,不增加定时补偿或打印回执。

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

@@ -382,3 +382,21 @@ description: "Task list for 订单 ezPay 电子发票开立"
 验证(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记录为准。
+
+## 2026-11-06 商家下单发票选择
+
+- [x] T088 接通商家 createOrder 的 invoiceChoice 校验及订单发票意图保存,复用已有 DTO 与数据库结构。
+- [x] T089 补充六类选择及配套信息、载具类型推导、缺省兼容、不支持开票、校验失败与保存失败测试,更新接入说明。
+- [x] T090 执行 JDK21 定向回归与打包:MerchantCreateOrderInvoiceChoiceTest、MerchantCashOrderNotificationTest、PosOrderShOprateControllerTest、OrderDetailInvoiceChoiceTest、OrderInvoiceFreeDeliveryTest、MerchantOrderPrintControllerTest、MerchantOrderPrintServiceTest 共56项,0失败/错误/跳过;package退出码0,git diff --check通过。
+
+新增11项测试先运行复现9项失败,实现后通过。日志位于忽略目录 target/merchant-create-invoice-red.log、merchant-create-invoice-green.log。未执行真实数据库、ezPay联网校验或 App 联调,未部署、提交或推送。此前现金新单通知和工作区原有路由改动保留。
+
+## 2026-11-06 商家现金创建后自动开票
+
+- [x] T091 在现金单创建提交后、新单推送前触发自动开票,异常不阻断后续通知;非现金和无发票意图不触发。
+- [x] T092 自动开票使用独立事务及发票意图行锁,已开/作废/取消跳过,保留失败结果及原重试流程。
+- [x] T093 新增创建/回滚/异常/非现金和服务成功/失败/重复/取消/作废定向测试,更新接入文档。JDK21执行 MerchantCreateOrderInvoiceChoiceTest、MerchantCashOrderNotificationTest、OrderInvoiceAutoIssueTest、PosOrderShOprateControllerTest、OrderDetailInvoiceChoiceTest、OrderInvoiceFreeDeliveryTest、MerchantOrderPrintControllerTest、MerchantOrderPrintServiceTest 的 Maven package,共71项测试0失败/错误/跳过,打包退出码0。
+
+测试为模拟外部服务/Mapper和Spring事务同步回调,并检查独立事务声明;未验证真实MySQL行锁并发、真实事务落库、ezPay开票、App/打印设备。无SQL迁移,未部署、提交或推送。日志 target/merchant-cash-invoice-red.log、merchant-cash-invoice-green.log,git diff --check通过。
+
+提交前验证(2026-11-06):按用户“都一起提交”要求纳入工作区21个文件,包含商家推送会话限制及蓝牙打印交付文件。JDK21上述定向测试加 MerchantNotificationRouterTest 共87项全部通过,Maven package退出码0;`node --test specs/010-order-invoice/bluetooth-printing/BluetoothPrinter.test.cjs` 11项通过。暂存差异检查通过;未执行真实MySQL、ezPay、App或打印机联调及部署。提交和推送状态以Git记录为准。

+ 7 - 0
specs/022-merchant-store-subaccounts/plan.md

@@ -221,3 +221,10 @@ ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTe
 1. 创建/编辑DTO及列表视图增加userName;创建不再用手机号填充登录名,姓名降为可选。
 2. 复用主账号用户名查询判重;编辑限定账号归属,改名后撤销会话;手机号校验保持。
 3. 更新022契约与接入指引,回归用户名与手机号分离、必填、冲突、存量改名及登录权限。既有登录接口已按user_name查询,无需修改。
+
+
+## 2026-11-06 主账号推送会话校验
+
+1. 在MerchantNotificationRouter.sendToRecipient中保留站内消息发布,在外部设备推送前统一调用hasAppSession;不把主账号登录状态混入isActiveOwner,避免误拦截有效分管。
+2. 覆盖主账号退出、仅PC在线、有效App兜底、主账号离线但分管在线、分管会话筛选后撤销;回归原生语音、uni推送、去重和订单通知入口。
+3. 更新现有规格和任务,运行JDK21定向测试及模块构建,无SQL或前端调整。

+ 8 - 0
specs/022-merchant-store-subaccounts/spec.md

@@ -229,3 +229,11 @@
 创建分管账号必须独立提供userName、phone、password、storeIds;name改为可选显示姓名,未传时使用用户名。登录继续使用现有shanglodeing的userName,不新增手机号登录或手机号回退。用户名复用info_user.user_name,与商家主账号共用唯一性检查;联系手机号保存tel_phone,沿用必填及手机号唯一性校验。
 列表和创建/编辑响应新增userName。主账号可编辑所属分管账号用户名,不能与其他账号重名;改名撤销旧会话,原密码及联系手机号保留。编辑未传userName时保留原值,兼容只改店铺授权。存量用户名原来是手机号的账号不自动改名,主账号可逐个编辑后告知使用人。
 本轮只修改后端、测试及接入文档,不修改商家PC/App代码、不操作数据库、不创建分支。无需数据库字段或迁移SQL。
+
+
+## 2026-11-06 主账号设备推送补齐App会话检查
+
+- 主账号兜底和分管账号发送设备推送前均须存在有效的商家App会话(hasAppSession);只有PC会话、App退出或App会话过期时不发送设备推送。
+- 不改变分管优先、多分管同时接收、主账号兜底及推送地址去重规则;主账号没有App会话不影响有效分管账号接收。
+- 站内消息按原规则入库,登录后仍可查看;只阻止设备推送,不清空设备标识、不调整登录/退出接口及客户端识别方式。
+- 发送前再次检查会话可覆盖收件人筛选后已被撤销的情况;不声称与Redis退出操作、第三方投递形成原子事务,也不能撤回已发送通知。

+ 10 - 0
specs/022-merchant-store-subaccounts/tasks.md

@@ -419,3 +419,13 @@ T005..T007 --------------------------> T012
 - [x] 定向测试与JDK21后端打包,核对最终差异。
 
 验证:JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=MerchantSubaccountApplicationServiceTest,MerchantSubaccountControllerTest,MerchantSubaccountAdminControllerTest,InfoUserControllerTest,MerchantStoreAccessServiceTest,BusinessPhoneServiceTest -Dsurefire.failIfNoSpecifiedTests=false package`,99项全部通过,失败/错误/跳过均0,后端打包成功。日志 `ruoyi-admin/target/subaccount-username.log`。本轮首次执行因测试把在线状态读取也误判为撤销会话而失败,修正断言为仅禁止revokeAll后通过。未修改前端、数据库或已有账号,未执行真实App/PC联调或部署;提交状态以Git记录为准,工作区其他任务改动保留。
+
+
+## T017 主账号设备推送App会话校验(2026-11-06)
+
+- [x] 统一在设备推送前校验App会话,保留站内消息和原收件人路由。
+- [x] 新增退出、PC在线、分管独立接收及筛选后会话撤销用例,先复现3项失败。
+- [x] 运行通知链路定向测试、JDK21构建和差异检查。
+
+
+验证:JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=MerchantNotificationRouterTest,DeliveryOrderNotificationServiceTest,RiderAcceptanceMerchantNotificationTest,MultiStoreCheckoutNotificationServiceTest -Dsurefire.failIfNoSpecifiedTests=false package`,26项通过、失败/错误/跳过均0,构建退出码0,git diff --check通过。日志为 `ruoyi-admin/target/merchant-push-session-red.log`、`merchant-push-session-green.log`。未运行真实Redis/App/PC/推送服务联调、全项目回归或部署;无SQL、无前端改动,尚未提交推送。

+ 3 - 3
specs/039-merchant-order-receipt/frontend-app-integration.md

@@ -65,17 +65,17 @@
 - 到付 `payType="1"` 即使 `payStatus=1`,也不代表客户已经交现金,应标记到付,不能标记已收款。
 - 用户、骑手和无权限的商家不能读取;分管账号受授权门店限制,摊主受绑定摊位限制。
 - 商品或地址快照损坏会返回业务错误,App 提示错误,不打印空白小票。
-- 本轮未接入自动打印、设备绑定、打印状态回执、去重或补打记录。App 不应把每一次推送都直接当作可重复出纸的命令;新订单触发、重试与设备能力需要后续单独完成。
+- App 负责自动打印、设备绑定、打印去重和失败重试;后端提供新订单通知和小票数据,不提供打印状态回执或补打记录。
 - 这里是订单小票,不是电子发票证明联;不返回发票防伪码或打印发票凭证。
 
-## 商家代客下单自动打印触发(2026-11-03 已确认,后端待实现)
+## 商家代客下单自动打印触发
 
 | 代客订单支付方式 | 新订单通知与打印时机 |
 |---|---|
 | 现金 `payType=4` | 订单创建事务提交成功后;不用等待确认收款 |
 | 信用卡 `2`、LINE Pay `3`、Apple Pay `5` | 服务端确认支付成功后;创建时不打印 |
 
-以上是本轮新增规则,不代表后端或 App 已上线。到付 `1` 和线下转账 `6` 不纳入本轮现金/线上规则变更。
+现金创建成功后,后端发送新订单通知,App 按下述步骤接入打印。线上支付自动打印需等待对应支付通知功能完成联调后启用。到付 `1` 和线下转账 `6` 不纳入本次变更。
 
 接入步骤:
 

+ 4 - 0
specs/039-merchant-order-receipt/plan.md

@@ -28,3 +28,7 @@
 5. 更新 App 接入说明:通知中的完整 `ddId` 用于读取小票,App 管理打印去重与失败恢复;后端测试不宣称已验证真机打印。
 
 不新增分支、数据库字段、SQL、字典或用户可见文案;复用现有消息国际化和声音场景。当前工作区 `specs/010-order-invoice` 的既有变更与本任务分开保留。
+
+## 2026-11-06 现金创建通知实施范围
+
+沿用现有 `runNotificationAfterCommit` 和 `MerchantNotificationRouter.sendStoreNewOrder`,在子订单成功保存后注册现金新单通知;保存返回失败则抛出既有国际化业务错误使事务回滚。补充三种订单类型、事务提交/回滚、保存失败、通知异常以及非现金创建不触发的定向测试,再执行控制器、小票、路由回归与 JDK21 模块打包。本次不修改线上支付回调或现有门店收件人路由。

+ 4 - 0
specs/039-merchant-order-receipt/spec.md

@@ -41,3 +41,7 @@
 ### 验收
 
 现金创建提交后通知、提交前不通知、回滚不通知;三类线上支付创建不通知,付款成功后通知;现金确认及重复支付不重复发新单;商家代客外送单不能被普通用户单“等骑手接单”的通知分支漏掉;普通订单和多店通知无回归。App 需另行验证重复通知、创建页本地打印与推送同时到达、打印失败重试及实际出纸。
+
+## 2026-11-06 商家现金创建通知
+
+本次实施现金 `payType=4` 的创建通知:商家端 `/system/orderShOprate/createOrder` 成功保存订单且事务提交后,通过现有门店新单路由发送通知,覆盖外送、自取、堂食。回滚或保存失败不通知;推送异常不改变已提交订单的创建结果。保持 `payStatus=0`,确认现金收款不重复发送创建通知。App 使用完整 `ddId` 获取小票并去重打印。线上支付通知的既有待办不属于本次实施范围。

+ 10 - 2
specs/039-merchant-order-receipt/tasks.md

@@ -31,11 +31,19 @@
 
 ## 2026-11-03 商家代客下单通知与打印触发
 
-状态:未开始(本轮已记录规格与实现计划,代码及验证待后续执行)。
+状态:现金创建通知已验证;线上支付通知及 App 真机打印仍待对应任务执行。
 
 - [x] T005 记录用户确认的现金创建后打印、线上支付成功后打印规则,并核对当前创建及支付通知入口。
 - [x] T006 更新 App 接入说明,明确后端推送、App 获取小票/去重/驱动打印机的职责。
-- [ ] T007 补充现金创建成功提交后通知;回滚不通知、通知异常不影响已提交订单,添加代码注释。
+- [x] T007 补充现金创建成功提交后通知;回滚不通知、通知异常不影响已提交订单,添加代码注释。
 - [ ] T008 核对并补齐 LINE Pay/OMG 商家代客订单支付成功的新单通知,避免外送分流漏通知或自配送重复通知,保留普通用户/多店路径。
 - [ ] T009 补充现金提交/回滚、线上创建无通知、付款成功及重复回调、普通订单路由回归测试;运行 JDK21 定向测试、模块构建与差异检查。
 - [ ] T010 前端人员接入 App 自动打印和去重;使用商家 App 与打印设备完成联调和实际出纸验收(非后端代码交付范围)。
+
+## 2026-11-06 现金创建通知验证
+
+- [x] T011 实现商家现金单提交后新单通知,覆盖外送、自取、堂食;复用现有路由,不改变收款状态。
+- [x] T012 新增11项现金通知定向测试(含参数化):提交前不发送、提交后发送、回滚不发送、保存失败、推送异常、其他支付方式不触发。修复前11项中5项失败,修复后全部通过。
+- [x] T013 JDK21 运行 `mvn -q -pl ruoyi-admin -am -Dtest=MerchantCashOrderNotificationTest,PosOrderShOprateControllerTest,MerchantOrderPrintControllerTest,MerchantOrderPrintServiceTest,MerchantNotificationRouterTest -Dsurefire.failIfNoSpecifiedTests=false package` 成功,52项测试0失败/错误/跳过,模块打包成功;`git diff --check` 通过。
+
+测试使用模拟依赖与 Spring 事务同步回调验证发送时机;未执行真实数据库、推送服务、商家 App 和打印机联调,未部署、提交或推送。本次未修改线上支付路径,T008及T009中线上支付验证仍待执行。保留工作区原有门店推送路由及发票文档改动。