Browse Source

新增商家订单小票数据接口并完善接入文档

qmj 9 hours ago
parent
commit
32e4b06481

+ 114 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/MerchantOrderPrintService.java

@@ -0,0 +1,114 @@
+package com.ruoyi.app.order;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.ruoyi.app.order.dto.MerchantOrderPrintView;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/** 在控制器完成订单归属校验之后,只读装配小票,不执行真实打印或修改订单。 */
+@Service
+public class MerchantOrderPrintService {
+    private final IPosOrderService orders;
+    private final IPosStoreService stores;
+
+    public MerchantOrderPrintService(IPosOrderService orders, IPosStoreService stores) {
+        this.orders = orders;
+        this.stores = stores;
+    }
+
+    public MerchantOrderPrintView getOrderPrintInfo(Long orderId) {
+        PosOrder order = orderId == null ? null : orders.getById(orderId);
+        if (order == null) throw new ServiceException(MessageUtils.message("merchant.order.not.found"));
+        PosStore store = order.getMdId() == null ? null : stores.getById(order.getMdId());
+        MerchantOrderPrintView view = new MerchantOrderPrintView();
+        view.setOrderId(order.getId());
+        view.setOrderNo(order.getDdId());
+        view.setStoreId(order.getMdId());
+        view.setStoreName(StringUtils.isNotBlank(order.getPosName()) ? order.getPosName()
+                : store == null ? null : store.getPosName());
+        view.setStoreAddress(store == null ? null : store.getAddress());
+        view.setStoreTelephone(store == null ? null : store.getTelephone());
+        view.setCreateTime(order.getCretim());
+        view.setOrderType(order.getType());
+        view.setSelfDelivery(order.getSelfDelivery());
+        view.setState(order.getState());
+        view.setPayType(order.getPayType());
+        view.setPayStatus(order.getPayStatus());
+        view.setPickUpNum(order.getPickUpNum());
+        view.setTableNo(order.getTableNo());
+        view.setDelryTime(order.getDelryTime());
+        view.setPickUpTime(order.getPickUpTime());
+        view.setReservePhone(order.getReservePhone());
+        view.setItems(items(order.getFood()));
+        view.setRecipient(recipient(order.getShAddress()));
+        view.setFoodAmount(order.getFoodAmount());
+        view.setAmount(order.getAmount());
+        BigDecimal freight = order.getFreight() == null ? BigDecimal.ZERO : BigDecimal.valueOf(order.getFreight());
+        view.setFreight(freight);
+        view.setCustomerDeliveryFee(order.getCustomerDeliveryFee() == null ? freight : order.getCustomerDeliveryFee());
+        view.setMerchantDeliverySubsidy(order.getMerchantDeliverySubsidy() == null
+                ? BigDecimal.ZERO : order.getMerchantDeliverySubsidy());
+        BigDecimal platformCoupon = amount(order.getDiscountAmount()), storeCoupon = amount(order.getMdDiscountAmount());
+        BigDecimal platformPromotion = amount(order.getSalesReduction()), storePromotion = amount(order.getMdSalesReduction());
+        BigDecimal points = amount(order.getPointsReduction());
+        view.setDiscounts(new MerchantOrderPrintView.Discounts(platformCoupon, storeCoupon,
+                platformPromotion, storePromotion, points,
+                platformCoupon.add(storeCoupon).add(platformPromotion).add(storePromotion).add(points)));
+        view.setRemarks(order.getRemarks());
+        return view;
+    }
+
+    private List<MerchantOrderPrintView.Item> items(String snapshot) {
+        try {
+            JSONArray rows = JSON.parseArray(snapshot);
+            if (rows == null || rows.isEmpty()) throw new IllegalArgumentException();
+            List<MerchantOrderPrintView.Item> result = new ArrayList<>();
+            for (int i = 0; i < rows.size(); i++) {
+                JSONObject row = rows.getJSONObject(i);
+                if (row == null || StringUtils.isBlank(row.getString("name"))
+                        || row.getInteger("number") == null || row.getInteger("number") < 1)
+                    throw new IllegalArgumentException();
+                // 普通单和多店子单均已固化 ask/beizhu;不能查当前规格覆盖顾客原选择。
+                JSONArray selected = row.getJSONArray("ask");
+                List<String> specifications = selected == null ? List.of() : selected.toJavaList(String.class);
+                result.add(new MerchantOrderPrintView.Item(row.getLong("id"), row.getString("name"),
+                        row.getInteger("number"), row.getBigDecimal("price"), row.getBigDecimal("otherPrice"),
+                        specifications, row.getString("beizhu")));
+            }
+            return result;
+        } catch (RuntimeException failure) {
+            // 不回显商品、电话或地址原文,也不把损坏数据变成一张空白小票。
+            throw invalidSnapshot();
+        }
+    }
+
+    private MerchantOrderPrintView.Recipient recipient(String snapshot) {
+        if (StringUtils.isBlank(snapshot)) return null;
+        try {
+            JSONObject row = JSON.parseObject(snapshot);
+            // 自取、堂食和商家代客下单允许没有收货地址。
+            return row == null ? null : new MerchantOrderPrintView.Recipient(row.getString("name"),
+                    row.getString("phone"), row.getString("address"), row.getString("addressDetail"));
+        } catch (RuntimeException failure) {
+            throw invalidSnapshot();
+        }
+    }
+
+    private BigDecimal amount(Integer value) { return value == null ? BigDecimal.ZERO : BigDecimal.valueOf(value); }
+
+    private ServiceException invalidSnapshot() {
+        return new ServiceException(MessageUtils.message("merchant.order.print.invalidSnapshot"));
+    }
+}

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

@@ -99,6 +99,17 @@ public class PosOrderShOprateController extends BaseController {
     private MultiStoreCheckoutLegacyGuard multiStoreCheckoutLegacyGuard;
     @Autowired
     private OrderNumberReservationService orderNumberReservations;
+    @Autowired
+    private MerchantOrderPrintService merchantOrderPrintService;
+
+    /** 小票仅提供授权订单的快照数据,读取接口不触发打印或订单状态变化。 */
+    @GetMapping("/getOrderPrintInfo")
+    @Auth
+    @Anonymous
+    public AjaxResult getOrderPrintInfo(@RequestHeader String token, @RequestParam Long orderId) {
+        requireManagedOrder(orderId);
+        return success(merchantOrderPrintService.getOrderPrintInfo(orderId));
+    }
 
 
 

+ 57 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MerchantOrderPrintView.java

@@ -0,0 +1,57 @@
+package com.ruoyi.app.order.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+/** 商家 App 小票数据;只读订单快照,不承载打印机配置或发票证明联。 */
+@Data
+public class MerchantOrderPrintView {
+    private Long orderId;
+    private String orderNo;
+    private Long storeId;
+    /** 优先取下单时店名快照;地址、电话为当前门店资料。 */
+    private String storeName;
+    private String storeAddress;
+    private String storeTelephone;
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createTime;
+    /** 0外送、1自取、2堂食;App 按码值本地化。 */
+    private Long orderType;
+    private Integer selfDelivery;
+    private Long state;
+    private String payType;
+    /** 到付订单的1表示付款方式已确认,不表示已收现金。 */
+    private Long payStatus;
+    private Long pickUpNum;
+    private String tableNo;
+    private String delryTime;
+    private String pickUpTime;
+    private String reservePhone;
+    private Recipient recipient;
+    private List<Item> items;
+    /** 以下金额沿用订单存储单位(TWD元),不在查询时重算应付。 */
+    private Integer foodAmount;
+    private Integer amount;
+    /** 订单原配送费(含商家承担部分),不是客户实付运费。 */
+    private BigDecimal freight;
+    /** 旧订单无该快照时回退 freight。 */
+    private BigDecimal customerDeliveryFee;
+    private BigDecimal merchantDeliverySubsidy;
+    private Discounts discounts;
+    private String remarks;
+
+    public record Recipient(String name, String phone, String address, String addressDetail) { }
+
+    /** price/otherPrice 原样取商品快照;历史订单不可凭此重新结算。 */
+    public record Item(Long foodId, String name, Integer quantity, BigDecimal price,
+                       BigDecimal otherPrice, List<String> specifications, String remarks) { }
+
+    /** 商品优惠单独汇总,不包含 merchantDeliverySubsidy,避免配送补贴重复扣减。 */
+    public record Discounts(BigDecimal platformCouponAmount, BigDecimal storeCouponAmount,
+                            BigDecimal platformPromotionAmount, BigDecimal storePromotionAmount,
+                            BigDecimal pointsAmount, BigDecimal totalAmount) { }
+}

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -503,3 +503,4 @@ native.push.test.invalid=原生推送测试参数不完整或不正确
 native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
 order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符
+merchant.order.print.invalidSnapshot=訂單小票資料不完整,請聯絡平台處理

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -489,3 +489,4 @@ native.push.test.invalid=Invalid or incomplete native push test parameters
 native.push.test.accepted=The push provider accepted the message; check the phone notification
 native.push.test.failed=Native push delivery failed
 order.im.group.invalid=Order number and chat group ID are required; the chat group ID must not exceed 200 characters
+merchant.order.print.invalidSnapshot=Order receipt data is incomplete. Please contact support.

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -490,3 +490,4 @@ native.push.test.invalid=ข้อมูลทดสอบการแจ้ง
 native.push.test.accepted=ผู้ให้บริการรับข้อความแล้ว โปรดตรวจสอบการแจ้งเตือนบนโทรศัพท์
 native.push.test.failed=ส่งการแจ้งเตือนแบบเนทีฟไม่สำเร็จ
 order.im.group.invalid=ต้องระบุหมายเลขคำสั่งซื้อและรหัสกลุ่มแชต โดยรหัสกลุ่มแชตต้องไม่เกิน 200 ตัวอักษร
+merchant.order.print.invalidSnapshot=ข้อมูลใบรายการสั่งซื้อไม่ครบถ้วน กรุณาติดต่อฝ่ายสนับสนุน

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -27,3 +27,4 @@ multi.store.checkout.excluded.no.payment=không có phương thức thanh toán
 multi.store.checkout.payment.dispatched=Cửa hàng này đã gửi yêu cầu thanh toán {0} nên không thể đổi phương thức thanh toán. Vui lòng quay lại hoàn tất thanh toán, hoặc hủy giao dịch nhiều cửa hàng này và đặt lại.
 multi.store.checkout.invoice.unavailable=Các cửa hàng tham gia chưa hỗ trợ hóa đơn điện tử
 order.im.group.invalid=Mã đơn hàng và mã nhóm trò chuyện không được để trống; mã nhóm không được vượt quá 200 ký tự
+merchant.order.print.invalidSnapshot=Dữ liệu phiếu đơn hàng không đầy đủ. Vui lòng liên hệ hỗ trợ.

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -491,3 +491,4 @@ native.push.test.invalid=原生推送测试参数不完整或不正确
 native.push.test.accepted=推送服务已接受消息,请检查手机通知
 native.push.test.failed=原生推送发送失败
 order.im.group.invalid=订单号和群聊标识不能为空,群聊标识不能超过200个字符
+merchant.order.print.invalidSnapshot=订单小票数据不完整,请联系平台处理

+ 1 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -490,3 +490,4 @@ native.push.test.invalid=原生推播測試參數不完整或不正確
 native.push.test.accepted=推播服務已接受訊息,請檢查手機通知
 native.push.test.failed=原生推播傳送失敗
 order.im.group.invalid=訂單編號和群聊識別碼不可空白,群聊識別碼不可超過200個字元
+merchant.order.print.invalidSnapshot=訂單小票資料不完整,請聯絡平台處理

+ 83 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantOrderPrintControllerTest.java

@@ -0,0 +1,83 @@
+package com.ruoyi.app.order;
+
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.app.pay.LinePayOrderGuard;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosOrderLinePaymentService;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.MerchantStoreAccessService;
+import com.ruoyi.system.utils.AuthContext;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class MerchantOrderPrintControllerTest {
+    @Test
+    void ownerAndSubaccountMustPassExistingStoreOrderAuthorizationBeforeLoadingReceipt() {
+        var controller = new PosOrderShOprateController();
+        var users = mock(IInfoUserService.class);
+        var access = mock(MerchantStoreAccessService.class);
+        var print = mock(MerchantOrderPrintService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "merchantStoreAccessService", access);
+        ReflectionTestUtils.setField(controller, "merchantOrderPrintService", print);
+        InfoUser merchant = new InfoUser();
+        merchant.setUserId(10L);
+        when(users.getOne(any())).thenReturn(merchant);
+        try (var auth = mockStatic(AuthContext.class);
+             var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            auth.when(AuthContext::requireUserId).thenReturn(10L);
+            for (String role : List.of("1", "5")) {
+                merchant.setUserType(role);
+                controller.getOrderPrintInfo("token", 123L);
+            }
+            verify(print, times(2)).getOrderPrintInfo(123L);
+            doThrow(new ServiceException("denied")).when(access).requireOrderAccess(10L, 456L);
+            assertThrows(ServiceException.class, () -> controller.getOrderPrintInfo("token", 456L));
+            verify(print, never()).getOrderPrintInfo(456L);
+        }
+    }
+
+    @Test
+    void nightMarketOwnerAndStallCannotReadOtherOrdersAndUsersAndRidersAreDenied() {
+        var controller = new PosOrderShOprateController();
+        var users = mock(IInfoUserService.class);
+        var orders = mock(IPosOrderService.class);
+        var print = mock(MerchantOrderPrintService.class);
+        ReflectionTestUtils.setField(controller, "infoUserService", users);
+        ReflectionTestUtils.setField(controller, "posOrderService", orders);
+        ReflectionTestUtils.setField(controller, "merchantOrderPrintService", print);
+        ReflectionTestUtils.setField(controller, "linePayOrderGuard",
+                new LinePayOrderGuard(mock(IPosOrderLinePaymentService.class)));
+        InfoUser merchant = new InfoUser(); merchant.setUserId(10L);
+        PosOrder order = new PosOrder(); order.setId(123L); order.setShId(10L); order.setMdId(7L);
+        when(users.getOne(any())).thenReturn(merchant);
+        when(orders.getById(123L)).thenReturn(order);
+        try (var auth = mockStatic(AuthContext.class);
+             var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            auth.when(AuthContext::requireUserId).thenReturn(10L);
+            merchant.setUserType("3");
+            controller.getOrderPrintInfo("token", 123L);
+            order.setShId(99L);
+            assertThrows(ServiceException.class, () -> controller.getOrderPrintInfo("token", 123L));
+            merchant.setUserType("4"); merchant.setStoreId(7L);
+            controller.getOrderPrintInfo("token", 123L);
+            merchant.setStoreId(8L);
+            assertThrows(ServiceException.class, () -> controller.getOrderPrintInfo("token", 123L));
+            for (String role : List.of("0", "2")) {
+                merchant.setUserType(role);
+                assertThrows(ServiceException.class, () -> controller.getOrderPrintInfo("token", 123L));
+            }
+            verify(print, times(2)).getOrderPrintInfo(123L);
+        }
+    }
+}

+ 116 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/order/MerchantOrderPrintServiceTest.java

@@ -0,0 +1,116 @@
+package com.ruoyi.app.order;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosStore;
+import com.ruoyi.system.service.IPosOrderService;
+import com.ruoyi.system.service.IPosStoreService;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class MerchantOrderPrintServiceTest {
+    private final IPosOrderService orders = mock(IPosOrderService.class);
+    private final IPosStoreService stores = mock(IPosStoreService.class);
+    private final MerchantOrderPrintService service = new MerchantOrderPrintService(orders, stores);
+
+    private PosOrder order() {
+        PosOrder order = new PosOrder();
+        order.setId(123L); order.setDdId("ORDER-123"); order.setMdId(7L);
+        order.setPosName("下单时店名"); order.setType(0L);
+        order.setFood("[{\"id\":9129,\"name\":\"薑蔥焗三鮮\",\"price\":40,\"otherPrice\":5,\"number\":2,\"ask\":[\"小\",\"中辣\"],\"beizhu\":\"少盐\"}]");
+        order.setFoodAmount(90); order.setAmount(100); order.setFreight(40.0);
+        order.setShAddress("{\"name\":\"顾客\",\"phone\":\"0900000000\",\"address\":\"台北\",\"addressDetail\":\"二楼\",\"userId\":999}");
+        when(orders.getById(123L)).thenReturn(order);
+        PosStore store = new PosStore(); store.setPosName("新店名"); store.setAddress("门店地址"); store.setTelephone("0200000000");
+        when(stores.getById(7L)).thenReturn(store);
+        return order;
+    }
+
+    @Test
+    void freezesProductSpecsAndNotesForBothOrdinaryAndMultiStoreChildOrders() throws Exception {
+        PosOrder order = order();
+        for (String parent : List.of("ORDER-123", "BATCH-123")) {
+            order.setParentDdId(parent);
+            var view = service.getOrderPrintInfo(123L);
+            assertEquals("下单时店名", view.getStoreName());
+            assertEquals("门店地址", view.getStoreAddress());
+            assertEquals("ORDER-123", view.getOrderNo());
+            assertEquals(List.of("小", "中辣"), view.getItems().getFirst().specifications());
+            assertEquals("少盐", view.getItems().getFirst().remarks());
+            assertEquals(2, view.getItems().getFirst().quantity());
+            assertEquals(new BigDecimal("5"), view.getItems().getFirst().otherPrice());
+            assertEquals("二楼", view.getRecipient().addressDetail());
+            var json = new ObjectMapper().valueToTree(view);
+            assertTrue(json.get("items").isArray());
+            assertFalse(json.get("recipient").has("userId"));
+            assertFalse(json.has("shAddress"));
+        }
+        verify(stores, times(2)).getById(7L);
+        verifyNoMoreInteractions(stores);
+    }
+
+    @Test
+    void keepsAmountAndSeparatesCustomerFreightFromMerchantSubsidyAndGoodsDiscounts() {
+        PosOrder order = order();
+        order.setCustomerDeliveryFee(new BigDecimal("8"));
+        order.setMerchantDeliverySubsidy(new BigDecimal("32"));
+        order.setDiscountAmount(1); order.setMdDiscountAmount(2);
+        order.setSalesReduction(3); order.setMdSalesReduction(4); order.setPointsReduction(5);
+        var view = service.getOrderPrintInfo(123L);
+        assertEquals(100, view.getAmount());
+        assertEquals(90, view.getFoodAmount());
+        assertEquals(0, view.getFreight().compareTo(new BigDecimal("40")));
+        assertEquals(new BigDecimal("8"), view.getCustomerDeliveryFee());
+        assertEquals(new BigDecimal("32"), view.getMerchantDeliverySubsidy());
+        assertEquals(new BigDecimal("15"), view.getDiscounts().totalAmount());
+    }
+
+    @Test
+    void pickupAndDineInMayHaveNoRecipientAndLegacyFreightAndEmptySpecsArePreserved() {
+        PosOrder order = order();
+        order.setFood("[{\"name\":\"旧商品\",\"price\":40,\"number\":1}]");
+        order.setShAddress(null); order.setPickUpNum(8L); order.setTableNo("A3");
+        order.setReservePhone("0900000000"); order.setPickUpTime("18:30");
+        for (Long type : List.of(1L, 2L)) {
+            order.setType(type);
+            var view = service.getOrderPrintInfo(123L);
+            assertNull(view.getRecipient());
+            assertEquals(8L, view.getPickUpNum());
+            assertEquals("A3", view.getTableNo());
+            assertEquals("18:30", view.getPickUpTime());
+            assertEquals(view.getFreight(), view.getCustomerDeliveryFee());
+            assertEquals(BigDecimal.ZERO, view.getMerchantDeliverySubsidy());
+            assertEquals(BigDecimal.ZERO, view.getDiscounts().totalAmount());
+            assertTrue(view.getItems().getFirst().specifications().isEmpty());
+        }
+        order.setShAddress("null");
+        assertNull(service.getOrderPrintInfo(123L).getRecipient());
+    }
+
+    @Test
+    void missingStoreUsesStoredNameAndMissingOrderOrCorruptSnapshotFailsExplicitly() {
+        PosOrder order = order();
+        when(stores.getById(7L)).thenReturn(null);
+        assertEquals("下单时店名", service.getOrderPrintInfo(123L).getStoreName());
+        assertNull(service.getOrderPrintInfo(123L).getStoreAddress());
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            assertEquals("merchant.order.not.found", assertThrows(ServiceException.class,
+                    () -> service.getOrderPrintInfo(999L)).getMessage());
+            for (String data : List.of("bad-json", "[]", "[null]", "[{\"name\":\"商品\",\"number\":0}]")) {
+                order.setFood(data);
+                assertEquals("merchant.order.print.invalidSnapshot", assertThrows(ServiceException.class,
+                        () -> service.getOrderPrintInfo(123L)).getMessage());
+            }
+            order.setFood("[{\"name\":\"商品\",\"number\":1}]"); order.setShAddress("bad-address");
+            assertEquals("merchant.order.print.invalidSnapshot", assertThrows(ServiceException.class,
+                    () -> service.getOrderPrintInfo(123L)).getMessage());
+        }
+    }
+}

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSessionAuthAnnotationTest.java

@@ -79,7 +79,7 @@ class MerchantSessionAuthAnnotationTest {
         assertAuth(PosOrderController.class,
                 "setorderuzt", "getorderxq", "getstoreorderlist");
         assertAuth(PosOrderShOprateController.class,
-                "getInvoice", "invalidInvoice", "createOrder", "acceptOrder", "dispatchOrder",
+                "getInvoice", "invalidInvoice", "getOrderPrintInfo", "createOrder", "acceptOrder", "dispatchOrder",
                 "completeOrder", "confirmCashPayment", "cancelOrder", "orderList", "orderDetail");
     }
 

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

@@ -0,0 +1,67 @@
+# 商家 App 小票数据接入
+
+接口使用请求头 `token: 商家登录token`,返回统一包装 `{code:200,msg:"...",data:...}`。接口只提供数据,连接打印机、排版和出纸由 App 实现。
+
+## 订单小票信息
+
+小票功能直接提供,无需查询启用状态或配置字典。
+
+`GET /system/orderShOprate/getOrderPrintInfo?orderId=3173`
+
+`orderId` 是订单列表里的 `pos_order.id`,**不是**订单号 `ddId`、父单号或多店批次 ID。多店订单对每张子单分别读取,按对应门店打印。
+
+### 返回字段(data)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| orderId / orderNo | long / string | 订单主键 / 完整订单号,订单号按字符串处理 |
+| storeId / storeName | long / string | 门店ID / 下单时店名快照(没有快照才取当前店名) |
+| storeAddress / storeTelephone | string | 当前门店地址 / 电话;可能为空 |
+| createTime | string | 下单时间,`yyyy-MM-dd HH:mm:ss`,UTC+8 |
+| orderType | long | 0外送、1自取、2堂食 |
+| selfDelivery | int | 1商家自配送、0平台配送;老单可能为空 |
+| state | long | 订单现有状态码,沿用商家订单页面解释 |
+| payType / payStatus | string / long | 原订单支付方式 / 支付状态,不做文字转换 |
+| pickUpNum / tableNo | long / string | 取餐号 / 桌号;不适用时为空 |
+| delryTime / pickUpTime | string | 原订单预约配送信息 / 自取时间,原样展示;可能为空 |
+| reservePhone | string | 自取预留电话;可能为空 |
+| recipient | object或null | 收货信息,仅 `{name,phone,address,addressDetail}`;无地址时为 null |
+| items | array | 商品快照,结构见下表,不需要再解析 JSON 字符串 |
+| foodAmount | int或null | 原订单保存的商品总额;不根据当前菜单重算 |
+| amount | int或null | 原订单应付总额,作为小票总额直接展示 |
+| freight | number | 原配送费,包含客户实付和商家承担部分 |
+| customerDeliveryFee | number | 客户实付配送费;老单无此快照时回退 freight,小票客户运费显示此字段 |
+| merchantDeliverySubsidy | number | 商家承担的免运金额;老单返回0,与商品优惠分开 |
+| discounts | object | 商品优惠分项和合计,结构见下文 |
+| remarks | string | 整张子单备注 |
+
+### items[]
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| foodId | long或null | 商品ID;老单可能缺失 |
+| name | string | 下单时商品名称 |
+| quantity | int | 商品数量,取快照 number |
+| price / otherPrice | number或null | 快照商品价格 / 规格加价;保留原值,老单字段可能为空 |
+| specifications | string[] | 取快照 ask,例如 `["小","中辣"]`;没有时为空数组 |
+| remarks | string或null | 该商品备注,取快照 beizhu |
+
+`discounts` 字段均为 number,缺失按0返回:
+
+- `platformCouponAmount`:平台优惠券,取 discountAmount。
+- `storeCouponAmount`:门店优惠券,取 mdDiscountAmount。
+- `platformPromotionAmount`:平台促销,取 salesReduction。
+- `storePromotionAmount`:门店促销,取 mdSalesReduction。
+- `pointsAmount`:积分抵扣,取 pointsReduction。
+- `totalAmount`:上述五项之和,**不包含**商家运费补贴。
+
+所有金额沿用订单单位(TWD元),不要再除100。`amount` 是权威订单总额;优惠只用于列示,不能再从 amount 扣一次。历史订单 `price/otherPrice` 不统一重算,App 不应靠逐行金额重建应付。没有存储税额,不返回固定为0的 tax。
+
+### 关键处理
+
+- 收货人、商品、规格、备注、金额取下单快照,不因用户改地址、商品改价或规格删除而改变。
+- 到付 `payType="1"` 即使 `payStatus=1`,也不代表客户已经交现金,应标记到付,不能标记已收款。
+- 用户、骑手和无权限的商家不能读取;分管账号受授权门店限制,摊主受绑定摊位限制。
+- 商品或地址快照损坏会返回业务错误,App 提示错误,不打印空白小票。
+- 本轮未接入自动打印、设备绑定、打印状态回执、去重或补打记录。App 不应把每一次推送都直接当作可重复出纸的命令;新订单触发、重试与设备能力需要后续单独完成。
+- 这里是订单小票,不是电子发票证明联;不返回发票防伪码或打印发票凭证。

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

@@ -0,0 +1,10 @@
+# 商家小票接口实现计划
+
+目标:沿用现有商家订单控制器和权限,提供结构化打印数据,不接入打印设备。
+
+1. 小票功能始终提供,不添加开关接口、字典或 SQL。
+2. 新增 `MerchantOrderPrintView` 和 `MerchantOrderPrintService`,按订单快照装配;控制器新增小票 GET 接口,复用订单权限与商家身份检查。
+3. 覆盖商品规格/备注、运费优惠、自取堂食空地址、坏快照及越权;不增加 SQL,新增错误同步所有后端语言文件。
+4. 更新 App 接入文档,执行 JDK21 定向测试及模块打包、差异检查。设备绑定、自动打印和真机出纸不在本轮验证范围。
+
+备选:直接返回 PosOrder 会暴露无关信息并要求 App 解析 JSON;复刻 CTE 的 Map 会丢失本系统运费契约。采用明确 DTO 和快照转换,不引入新表或新的订单状态。

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

@@ -0,0 +1,22 @@
+# 039 商家 App 订单小票数据
+
+## 已确认范围(2026-10-29)
+
+小票 GET 接口放在现有商家订单控制器 `/system/orderShOprate`:
+- `/getOrderPrintInfo?orderId=...`:按本系统字段提供一张真实订单的小票数据。
+
+只做后端和 App 接入说明;不修改 App、不绑定或驱动打印机、不发送打印任务,不改变普通单、多店子单的付款或履约流程。不新增分支。
+
+## 数据与权限
+
+- 接口声明 `@Auth`、显式 token 请求头;小票查询复用商家订单权限,覆盖普通商家、分管账号、夜市主和摊主,禁止跨店读取。
+- 小票功能直接提供,不设置启用开关,不增加字典或初始化 SQL。
+- 商品从 `pos_order.food` 快照解析成数组,包含名称、数量、价格、规格加价、已选规格和逐商品备注;不查当前商品或规格,不重新计算订单金额。
+- 订单金额、优惠及运费均来自订单快照。区分骑手原运费 `freight` 和客户实付运费 `customerDeliveryFee`;旧订单无新字段时,实付运费回退 freight。优惠分项和合计不包含商家配送补贴,避免混算。
+- 订单类型、自配送、支付方式及支付状态返回码值,由 App 本地化;到付 payStatus=1 不代表已收到现金,不能据此打印“已收款”。
+- 收货信息只读订单地址快照;自取/堂食无收货人正常返回 null,并保留取餐号、预留电话、桌号及预约信息。
+- 商品 JSON 损坏时明确返回国际化错误,不伪造空白小票;缺失门店时保留订单店名快照,门店地址/电话为空。不返回用户整表或支付密钥。
+
+## 验收
+
+小票功能无需开关;有效商家可读取授权订单;跨店、普通用户和骑手被拒;普通/多店快照规格保留;无地址、旧运费、免运、积分及其他优惠可正确展示;不产生订单写入或真实打印。

+ 18 - 0
specs/039-merchant-order-receipt/tasks.md

@@ -0,0 +1,18 @@
+# 039 任务进度
+
+状态:已验证(后端代码;提交和推送状态以 Git 为准)。
+
+- [x] T001 完成小票 DTO/服务及商家控制器接入;按最新要求移除启用查询接口及字典依赖。
+- [x] T002 覆盖权限、历史/多店商品规格和金额、自取堂食与异常快照。
+- [x] T003 完成国际化和前端接入说明;移除未执行的字典初始化 SQL。
+- [x] T004 执行 JDK21 定向测试、打包和差异检查,记录未执行的设备验证。
+
+## 验证记录(2026-10-29)
+
+- 分支仍为 `test-202609v2`,没有创建分支或修改 App。
+- JDK21:`mvn -q -pl ruoyi-admin -am -Dtest=MerchantOrderPrintControllerTest,MerchantOrderPrintServiceTest,PosOrderShOprateControllerTest,MerchantSessionAuthAnnotationTest -Dsurefire.failIfNoSpecifiedTests=false package` 初版成功;29项测试,0失败/错误/跳过,JAR已构建。
+- `git diff --check` 通过。测试日志位于本地忽略目录 `ruoyi-admin/target/merchant-print-verified.log`,不提交。
+- 按最新要求取消打印开关接口、字典及初始化SQL;重新运行同范围 `test` 通过,27项0失败/错误/跳过,日志 `ruoyi-admin/target/merchant-print-no-switch.log`。本次删除开关后未重新打包或部署。
+- 提交前再次运行同范围 JDK21 `package` 成功:27项0失败/错误/跳过,已生成最新JAR,日志 `ruoyi-admin/target/merchant-print-commit.log`。此条更新上一条未重新打包的历史状态。
+- 未执行真实数据库联调、App/打印设备联调、实际出纸或部署。本轮无需数据库结构调整、字典配置或 SQL。
+- 自动打印触发、防重复、设备绑定、重试与补打记录均未实现;本轮只交付用户明确要求的小票数据接口。