Bladeren bron

多店下单沿用普通订单发票资料并完善接入文档

qmj 1 dag geleden
bovenliggende
commit
67f46bb2f5

+ 45 - 3
ruoyi-admin/src/main/java/com/ruoyi/app/order/MultiStoreCheckoutBatchService.java

@@ -13,6 +13,7 @@ import com.ruoyi.system.domain.OrderCheckoutMember;
 import com.ruoyi.system.domain.OrderDeliveryGroup;
 import com.ruoyi.system.domain.OrderParent;
 import com.ruoyi.system.domain.PosOrder;
+import com.ruoyi.system.domain.PosOrderInvoice;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.dto.PromotionCalcRequest;
 import com.ruoyi.system.dto.PromotionCalcResponse;
@@ -61,6 +62,7 @@ public class MultiStoreCheckoutBatchService {
     private final InfoAddressMapper addresses;
     private final PosStoreMapper stores;
     private final OrderPromotionHelper promotions;
+    private final OrderInvoiceService invoices;
     private final MultiStoreCheckoutActivationTrigger activationTrigger;
 
     public MultiStoreCheckoutBatchService(MultiStoreCheckoutQuoteService quotes,
@@ -69,7 +71,8 @@ public class MultiStoreCheckoutBatchService {
                                           InfoAddressMapper addresses, PosStoreMapper stores,
                                           OrderPromotionHelper promotions, StringRedisTemplate orderNumbers,
                                           OrderNumberReservationService numberReservations,
-                                          MultiStoreCheckoutActivationTrigger activationTrigger) {
+                                          MultiStoreCheckoutActivationTrigger activationTrigger,
+                                          OrderInvoiceService invoices) {
         this.quotes = quotes;
         this.config = config;
         this.checkout = checkout;
@@ -78,6 +81,7 @@ public class MultiStoreCheckoutBatchService {
         this.addresses = addresses;
         this.stores = stores;
         this.promotions = promotions;
+        this.invoices = invoices;
         this.orderNumbers = orderNumbers;
         this.numberReservations = numberReservations;
         this.activationTrigger = activationTrigger;
@@ -92,10 +96,16 @@ public class MultiStoreCheckoutBatchService {
             throw invalid("multi.store.checkout.invalid");
         }
         Map<Long, String> selectedPayments = paymentMethods(input);
-        // 幂等摘要包含逐店支付选择;同一 requestKey 不可悄悄改写已创建子单的付款方式。
+        // 幂等摘要包含逐店支付选择和发票资料;同一 requestKey 不可改写已创建子单。
+        String invoiceHash = input.invoiceChoice() == null && input.carrierType() == null
+                && input.carrierNum() == null && input.buyerUbn() == null && input.buyerName() == null
+                && input.buyerEmail() == null && input.loveCode() == null && input.saveAsDefault() == null
+                ? "" : "|" + JSON.toJSONString(java.util.Arrays.asList(input.invoiceChoice(), input.carrierType(),
+                input.carrierNum(), input.buyerUbn(), input.buyerName(), input.buyerEmail(),
+                input.loveCode(), input.saveAsDefault()));
         String requestHash = hash(input.quoteToken() + "|" + JSON.toJSONString(input.participatingStoreIds())
                 + "|" + JSON.toJSONString(input.participatingStoreIds().stream()
-                .map(storeId -> selectedPayments.get(storeId)).toList()));
+                .map(selectedPayments::get).toList()) + invoiceHash);
         // 相同请求键的网络重试在报价过期后仍可取回原批次;不同载荷不能借键覆盖旧单。
         OrderCheckoutBatch existing = checkout.selectByUserAndRequestKey(userId, input.requestKey());
         if (existing != null) return existingView(existing, requestHash);
@@ -122,6 +132,15 @@ public class MultiStoreCheckoutBatchService {
                 || address.getLatitude() == null || address.getLongitude() == null) {
             throw invalid("multi.store.checkout.address.invalid");
         }
+        // 同普通 /createOrder:非纸本选择先用首个可开票参与店校验,再写任何父子单。
+        if (input.invoiceChoice() != null && !input.invoiceChoice().isBlank()
+                && !"PAPER".equals(input.invoiceChoice())) {
+            Long invoiceStoreId = quote.participatingStoreIds().stream()
+                    .filter(invoices::canInvoice).findFirst().orElse(null);
+            if (invoiceStoreId == null) throw invalid("multi.store.checkout.invoice.unavailable");
+            invoices.validateCheckoutInvoiceIntent(input.invoiceChoice(), input.carrierNum(),
+                    input.loveCode(), input.buyerUbn(), input.buyerEmail(), invoiceStoreId);
+        }
         String frozenAddress = JSON.toJSONString(address);
         LocalDateTime now = LocalDateTime.now();
         // 父15位、子18位;与普通客户端共享号码占用,已有/并发普通单撞号时换下个毫秒值。
@@ -246,6 +265,24 @@ public class MultiStoreCheckoutBatchService {
             if (orders.insert(order) != 1 || order.getId() == null) {
                 throw invalid("multi.store.checkout.invalid");
             }
+            // 发票按真实子单归属门店处理;整批共用一次选择,不给不可开票门店生成意图行。
+            if (input.invoiceChoice() != null && !input.invoiceChoice().isBlank()
+                    && invoices.canInvoice(price.storeId())) {
+                PosOrderInvoice intent = new PosOrderInvoice();
+                intent.setOrderId(order.getId());
+                intent.setOrderNo(orderNo);
+                intent.setStoreId(price.storeId());
+                intent.setInvoiceChoice(input.invoiceChoice());
+                intent.setCarrierNum(input.carrierNum());
+                intent.setBuyerUbn(input.buyerUbn());
+                intent.setBuyerName(input.buyerName());
+                intent.setBuyerEmail(input.buyerEmail());
+                intent.setLoveCode(input.loveCode());
+                intent.setCarrierType("PHONE_BARCODE".equals(input.invoiceChoice()) ? "0"
+                        : "CITIZEN".equals(input.invoiceChoice()) ? "1"
+                        : "MEMBER".equals(input.invoiceChoice()) ? null : input.carrierType());
+                invoices.captureInvoiceIntent(intent);
+            }
             promotions.savePromotionSnapshots(order.getId(), food);
             Long coupon = promotions.resolveCouponToConsume(food, true);
             if (coupon != null) promotions.markCouponUsed(coupon, order.getId(), userId);
@@ -277,6 +314,11 @@ public class MultiStoreCheckoutBatchService {
         group.setCreateTime(now);
         group.setUpdateTime(now);
         if (checkout.insertGroup(group) != 1) throw invalid("multi.store.checkout.invalid");
+        if (Boolean.TRUE.equals(input.saveAsDefault()) && input.invoiceChoice() != null
+                && !input.invoiceChoice().isBlank()) {
+            invoices.saveDefaultInvoiceProfile(input.invoiceChoice(), input.carrierNum(), input.buyerUbn(),
+                    input.buyerName(), userId);
+        }
         if (allCod) {
             // 全到付在建批时已经逐店确认;只生成一次激活事件,实际开放仍由出站流程负责。
             if (!MultiStoreCheckoutRules.canActivate(created.stream()

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

@@ -17,6 +17,7 @@ import com.ruoyi.system.domain.PosOrderInvoice;
 import com.ruoyi.system.domain.PosStore;
 import com.ruoyi.system.domain.PosStoreEzpay;
 import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.domain.InfoInvoice;
 import com.ruoyi.system.domain.dto.ApplyInvoiceDto;
 import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
 import com.ruoyi.system.mapper.PosLoveOrgMapper;
@@ -25,6 +26,7 @@ import com.ruoyi.system.mapper.PosOrderMapper;
 import com.ruoyi.system.mapper.PosStoreEzpayMapper;
 import com.ruoyi.system.mapper.PosStoreMapper;
 import com.ruoyi.system.service.IInfoUserService;
+import com.ruoyi.system.service.IInfoInvoiceService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.dao.DuplicateKeyException;
@@ -67,6 +69,8 @@ public class OrderInvoiceService {
     private PosLoveOrgMapper posLoveOrgMapper;
     @Autowired
     private IInfoUserService infoUserService;
+    @Autowired
+    private IInfoInvoiceService infoInvoiceService;
 
     /** ezPay 接口根地址(从 application.yml 的 ezpay.base-url 注入,测试/正式在此切换) */
     @Value("${ezpay.base-url}")
@@ -218,6 +222,44 @@ public class OrderInvoiceService {
         saveOrUpdate(intent);
     }
 
+    /** 普通单和多店结账共用默认发票抬头规则;纸本、捐赠和会员载具不保存。 */
+    public void saveDefaultInvoiceProfile(String choice, String carrierNum, String buyerUbn,
+                                          String buyerName, Long userId) {
+        if ("PAPER".equals(choice) || "LOVE_CODE".equals(choice) || "MEMBER".equals(choice)) return;
+        InfoInvoice profile = new InfoInvoice();
+        profile.setUserId(userId);
+        profile.setIsDefault(1);
+        switch (choice) {
+            case "PHONE_BARCODE":
+                profile.setCategory("B2C");
+                profile.setTitleName("手机条码载具");
+                profile.setBuyerName("手机条码载具");
+                profile.setCarrierType("0");
+                profile.setCarrierNum(carrierNum);
+                break;
+            case "CITIZEN":
+                profile.setCategory("B2C");
+                profile.setTitleName("自然人凭证载具");
+                profile.setBuyerName("自然人凭证载具");
+                profile.setCarrierType("1");
+                profile.setCarrierNum(carrierNum);
+                break;
+            case "COMPANY":
+                profile.setCategory("B2B");
+                profile.setTitleName(buyerName);
+                profile.setBuyerName(buyerName);
+                profile.setBuyerUbn(buyerUbn);
+                break;
+            default:
+                return;
+        }
+        InfoInvoice reset = new InfoInvoice();
+        reset.setIsDefault(0);
+        infoInvoiceService.update(reset, new LambdaQueryWrapper<InfoInvoice>()
+                .eq(InfoInvoice::getUserId, userId).eq(InfoInvoice::getIsDefault, 1));
+        infoInvoiceService.save(profile);
+    }
+
     /**
      * 商家出餐自动开票(010 Phase 11):读 pos_order_invoice 发票意图行 → 组装 dto(个人 BuyerName=手机号、
      * 捐赠=机构名)→ 复用开票核心 {@link #reIssueAndSave}。系统触发,不校验客户归属;货到付款未付款也开

+ 2 - 45
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -90,8 +90,6 @@ public class UserOrderController extends BaseController {
     @Autowired
     private OrderInvoiceService orderInvoiceService;
     @Autowired
-    private IInfoInvoiceService infoInvoiceService;
-    @Autowired
     private IPosOrderLinePaymentService linePaymentService;
     @Autowired
     private LinePayRefundService linePayRefundService;
@@ -205,7 +203,8 @@ public class UserOrderController extends BaseController {
         createOrderChild(input,Long.valueOf(id),Long.valueOf(id),shdz,versionTwoQuote);
         // saveAsDefault:把载具/统编存为默认抬头(同一用户仅一条 is_default=1)
         if (Boolean.TRUE.equals(input.getSaveAsDefault()) && !StringUtils.isEmpty(input.getInvoiceChoice())) {
-            saveDefaultInvoiceProfile(input, Long.valueOf(id));
+            orderInvoiceService.saveDefaultInvoiceProfile(input.getInvoiceChoice(), input.getCarrierNum(),
+                    input.getBuyerUbn(), input.getBuyerName(), Long.valueOf(id));
         }
 
         OrderParent result = orderParentService.getOne(new LambdaQueryWrapper<OrderParent>().eq(OrderParent::getDdId, input.getDdId().toString()));
@@ -488,48 +487,6 @@ public class UserOrderController extends BaseController {
         return requestedShId != null ? requestedShId : store.getUserId();
     }
 
-    //保存默认发票抬头(010 Phase 11:结账勾「同意设置为默认」;同一用户仅一条 is_default=1)
-    private void saveDefaultInvoiceProfile(OrderCreateInput input, Long userId) {
-        String choice = input.getInvoiceChoice();
-        if ("PAPER".equals(choice) || "LOVE_CODE".equals(choice) || "MEMBER".equals(choice)) {
-            return; // 纸本/捐赠/会员载具不存抬头(会员载具=会员手机号,无需记忆)
-        }
-        InfoInvoice profile = new InfoInvoice();
-        profile.setUserId(userId);
-        profile.setIsDefault(1);
-        switch (choice) {
-            case "PHONE_BARCODE":
-                profile.setCategory("B2C");
-                profile.setTitleName("手机条码载具");
-                profile.setBuyerName("手机条码载具");
-                profile.setCarrierType("0");
-                profile.setCarrierNum(input.getCarrierNum());
-                break;
-            case "CITIZEN":
-                profile.setCategory("B2C");
-                profile.setTitleName("自然人凭证载具");
-                profile.setBuyerName("自然人凭证载具");
-                profile.setCarrierType("1");
-                profile.setCarrierNum(input.getCarrierNum());
-                break;
-            case "COMPANY":
-                profile.setCategory("B2B");
-                profile.setTitleName(input.getBuyerName());
-                profile.setBuyerName(input.getBuyerName());
-                profile.setBuyerUbn(input.getBuyerUbn());
-                break;
-            default:
-                return;
-        }
-        // 先清掉该用户其它默认(同一用户仅一条为 1),再存新的
-        InfoInvoice reset = new InfoInvoice();
-        reset.setIsDefault(0);
-        infoInvoiceService.update(reset, new LambdaQueryWrapper<InfoInvoice>()
-                .eq(InfoInvoice::getUserId, userId)
-                .eq(InfoInvoice::getIsDefault, 1));
-        infoInvoiceService.save(profile);
-    }
-
     //生成取餐码,根据门店id,当天取餐码从1开始累加
     private void setPickUpNum(PosOrder order) {
         if (order == null || order.getMdId() == null) {

+ 11 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/MultiStoreBatchCreateRequest.java

@@ -4,7 +4,17 @@ import java.util.List;
 
 /** 客户端逐店选择支付方式;商品、费用和支付资格仍由报价快照及服务端重算决定。 */
 public record MultiStoreBatchCreateRequest(String requestKey, String quoteToken,
-                                           List<Long> participatingStoreIds, List<PaymentMethod> paymentMethods) {
+                                           List<Long> participatingStoreIds, List<PaymentMethod> paymentMethods,
+                                           String invoiceChoice, String carrierType, String carrierNum,
+                                           String buyerUbn, String buyerName, String buyerEmail,
+                                           String loveCode, Boolean saveAsDefault) {
+    /** 兼容服务端既有无发票信息的建批调用;App 未传新字段时仍按无发票意图处理。 */
+    public MultiStoreBatchCreateRequest(String requestKey, String quoteToken,
+                                        List<Long> participatingStoreIds, List<PaymentMethod> paymentMethods) {
+        this(requestKey, quoteToken, participatingStoreIds, paymentMethods,
+                null, null, null, null, null, null, null, null);
+    }
+
     public record PaymentMethod(Long storeId, String payType) {
     }
 }

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

@@ -465,6 +465,7 @@ multi.store.config.conflict=Cấu hình đã thay đổi hoặc chưa được k
 multi.store.checkout.disabled=Thanh toán nhiều cửa hàng chưa được mở
 multi.store.checkout.upgrade.required=Vui lòng cập nhật ứng dụng để sử dụng thanh toán nhiều cửa hàng và nhóm giao hàng
 multi.store.checkout.invalid=Yêu cầu thanh toán nhiều cửa hàng không hợp lệ hoặc quá ít cửa hàng tham gia
+multi.store.checkout.invoice.unavailable=Các cửa hàng tham gia chưa hỗ trợ hóa đơn điện tử
 multi.store.checkout.payment.dispatched=该店已发起{0}支付请求,完成前不能更换支付方式;请返回完成支付,或取消本次多店结算重新下单
 multi.store.checkout.group.accept.required=Phải nhận toàn bộ nhóm giao hàng nhiều cửa hàng cùng lúc
 multi.store.checkout.batch.cancel.required=Vui lòng hủy qua quy trình đơn hàng nhiều cửa hàng

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

@@ -451,6 +451,7 @@ multi.store.config.conflict=Configuration changed or is not initialized. Refresh
 multi.store.checkout.disabled=Multi-store checkout is not available yet
 multi.store.checkout.upgrade.required=Update the app to use multi-store checkout and delivery groups
 multi.store.checkout.invalid=Invalid multi-store checkout request or too few participating stores
+multi.store.checkout.invoice.unavailable=Participating stores do not support electronic invoices
 multi.store.checkout.payment.dispatched=A {0} payment request has already been sent for this store and the payment method cannot be changed. Please go back and complete that payment, or cancel this checkout and start a new order.
 multi.store.checkout.group.accept.required=Accept the entire multi-store delivery group together
 multi.store.checkout.batch.cancel.required=Cancel this order through the multi-store checkout flow

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

@@ -452,6 +452,7 @@ multi.store.config.conflict=การตั้งค่าเปลี่ยน
 multi.store.checkout.disabled=ยังไม่เปิดใช้การชำระเงินหลายร้าน
 multi.store.checkout.upgrade.required=โปรดอัปเดตแอปเพื่อใช้การชำระเงินหลายร้านและรับงานจัดส่งเป็นกลุ่ม
 multi.store.checkout.invalid=คำขอชำระเงินหลายร้านไม่ถูกต้องหรือมีร้านที่เข้าร่วมน้อยเกินไป
+multi.store.checkout.invoice.unavailable=ร้านค้าที่เข้าร่วมยังไม่รองรับใบกำกับภาษีอิเล็กทรอนิกส์
 multi.store.checkout.payment.dispatched=ร้านนี้ได้ส่งคำขอชำระเงินผ่าน {0} แล้วและเปลี่ยนวิธีชำระเงินไม่ได้ กรุณากลับไปชำระให้เสร็จ หรือยกเลิกการสั่งซื้อหลายร้านนี้แล้วสั่งใหม่
 multi.store.checkout.group.accept.required=ต้องรับงานจัดส่งหลายร้านทั้งกลุ่มพร้อมกัน
 multi.store.checkout.batch.cancel.required=โปรดยกเลิกผ่านขั้นตอนคำสั่งซื้อหลายร้าน

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

@@ -25,3 +25,4 @@ multi.store.checkout.stores.excluded=Các cửa hàng sau không thể tham gia
 multi.store.checkout.excluded.outside.fence=ngoài phạm vi giao hàng tính từ cửa hàng đầu tiên
 multi.store.checkout.excluded.no.payment=không có phương thức thanh toán khả dụng
 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ử

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

@@ -453,6 +453,7 @@ multi.store.config.conflict=配置已变化或尚未初始化,请刷新或先
 multi.store.checkout.disabled=多店结算暂未开放
 multi.store.checkout.upgrade.required=请更新应用后使用多店结算和配送组功能
 multi.store.checkout.invalid=多店结算请求无效或参与店铺不足
+multi.store.checkout.invoice.unavailable=参与门店暂不支持电子发票
 multi.store.checkout.payment.dispatched=该店已发起{0}支付请求,完成前不能更换支付方式;请返回完成支付,或取消本次多店结算重新下单
 multi.store.checkout.group.accept.required=多店配送组必须整组接单
 multi.store.checkout.batch.cancel.required=请通过多店批次流程取消订单

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

@@ -452,6 +452,7 @@ multi.store.config.conflict=設定已變更或尚未初始化,請重新整理
 multi.store.checkout.disabled=多店結算暫未開放
 multi.store.checkout.upgrade.required=請更新應用程式後使用多店結算與配送組功能
 multi.store.checkout.invalid=多店結算請求無效或參與店鋪不足
+multi.store.checkout.invoice.unavailable=參與門市暫不支援電子發票
 multi.store.checkout.payment.dispatched=該店已發起{0}付款請求,完成前不能更換付款方式;請返回完成付款,或取消本次多店結算重新下單
 multi.store.checkout.group.accept.required=多店配送組必須整組接單
 multi.store.checkout.batch.cancel.required=請透過多店批次流程取消訂單

+ 87 - 36
ruoyi-admin/src/test/java/com/ruoyi/app/order/MultiStoreCheckoutBatchServiceTest.java

@@ -55,6 +55,7 @@ class MultiStoreCheckoutBatchServiceTest {
     private final StringRedisTemplate orderNumbers = mock(StringRedisTemplate.class);
     private final OrderNumberReservationService numberReservations = mock(OrderNumberReservationService.class);
     private final MultiStoreCheckoutActivationTrigger activationTrigger = mock(MultiStoreCheckoutActivationTrigger.class);
+    private final OrderInvoiceService invoices = mock(OrderInvoiceService.class);
 
     @BeforeEach
     void setUp() {
@@ -72,7 +73,7 @@ class MultiStoreCheckoutBatchServiceTest {
 
     private MultiStoreCheckoutBatchService subject() {
         return new MultiStoreCheckoutBatchService(quotes, config, checkout, parents, orders,
-                addresses, stores, promotions, orderNumbers, numberReservations, activationTrigger);
+                addresses, stores, promotions, orderNumbers, numberReservations, activationTrigger, invoices);
     }
 
     @Test
@@ -112,6 +113,11 @@ class MultiStoreCheckoutBatchServiceTest {
                 "retry-1", "quote-1", List.of(1L, 2L), List.of(
                 new MultiStoreBatchCreateRequest.PaymentMethod(1L, "2"),
                 new MultiStoreBatchCreateRequest.PaymentMethod(2L, "1"))), 42L));
+        assertThrows(ServiceException.class, () -> subject().create(new MultiStoreBatchCreateRequest(
+                "retry-1", "quote-1", List.of(1L, 2L), List.of(
+                new MultiStoreBatchCreateRequest.PaymentMethod(1L, "3"),
+                new MultiStoreBatchCreateRequest.PaymentMethod(2L, "1")),
+                "PAPER", null, null, null, null, null, null, false), 42L));
         verify(checkout, never()).insertOrGetBatch(any());
     }
 
@@ -125,41 +131,7 @@ class MultiStoreCheckoutBatchServiceTest {
             when(numberReservations.reserveIfUnused(org.mockito.ArgumentMatchers.eq("991789716730511"), anyString()))
                     .thenReturn(false);
         }
-        arrangeQuote();
-        when(config.selectCurrentForUpdate()).thenReturn(policy());
-        InfoAddress address = new InfoAddress();
-        address.setId(10L); address.setUserId(42L);
-        address.setLatitude(new BigDecimal("22.6000")); address.setLongitude(new BigDecimal("120.3000"));
-        when(addresses.selectById(10L)).thenReturn(address);
-        when(stores.selectById(1L)).thenReturn(store(1));
-        when(stores.selectById(2L)).thenReturn(store(2));
-        PromotionCalcResponse food = new PromotionCalcResponse();
-        food.setOriginalAmount(new BigDecimal("50")); food.setFinalAmount(new BigDecimal("50"));
-        food.setDeliveryFee(new BigDecimal("36"));
-        food.setDeliveryDiscountAmount(BigDecimal.ZERO);
-        food.setCustomerDeliveryFee(new BigDecimal("36"));
-        food.setPayableAmount(new BigDecimal("86"));
-        when(quotes.priceFoodForBatch(any(), any(), any())).thenReturn(food);
-        doAnswer(call -> {
-            PosOrder order = call.getArgument(0);
-            PromotionCalcResponse calculated = call.getArgument(1);
-            order.setAmount(calculated.getPayableAmount().intValueExact());
-            order.setFreight(calculated.getDeliveryFee().doubleValue());
-            order.setFoodAmount(calculated.getOriginalAmount().intValueExact());
-            order.setPromotionPricingVersion(2);
-            order.setMerchantDeliverySubsidy(calculated.getDeliveryDiscountAmount());
-            order.setCustomerDeliveryFee(calculated.getCustomerDeliveryFee());
-            return null;
-        }).when(promotions).applyVersion2Pricing(any(PosOrder.class), any(PromotionCalcResponse.class));
-        when(promotions.buildServerFoodJson(any())).thenReturn("[]");
-        when(parents.insert(any(OrderParent.class))).thenReturn(1);
-        when(checkout.insertMember(any())).thenReturn(1);
-        when(checkout.insertGroup(any())).thenReturn(1);
-        when(checkout.activateIfPending(org.mockito.ArgumentMatchers.eq(99L), any())).thenReturn(1);
-        doAnswer(call -> { ((OrderCheckoutBatch) call.getArgument(0)).setId(99L); return 1; })
-                .when(checkout).insertOrGetBatch(any());
-        doAnswer(call -> { ((PosOrder) call.getArgument(0)).setId(100L); return 1; })
-                .when(orders).insert(any(PosOrder.class));
+        arrangeSuccessfulBatch();
 
         var request = new com.fasterxml.jackson.databind.ObjectMapper().readValue("""
                 {"requestKey":"retry-1","quoteToken":"quote-1","participatingStoreIds":[1,2],
@@ -219,6 +191,85 @@ class MultiStoreCheckoutBatchServiceTest {
         }
     }
 
+    @Test
+    void rejectsElectronicInvoiceWhenNoParticipatingStoreCanInvoice() throws Exception {
+        arrangeSuccessfulBatch();
+        var request = new com.fasterxml.jackson.databind.ObjectMapper().readValue("""
+                {"requestKey":"retry-invoice","quoteToken":"quote-1","participatingStoreIds":[1,2],
+                 "paymentMethods":[{"storeId":1,"payType":"3"},{"storeId":2,"payType":"1"}],
+                 "invoiceChoice":"COMPANY","buyerUbn":"12345678","buyerEmail":"a@example.com"}
+                """, MultiStoreBatchCreateRequest.class);
+
+        assertThrows(ServiceException.class, () -> subject().create(request, 42L));
+        verify(checkout, never()).insertOrGetBatch(any());
+        verify(parents, never()).insert(any(OrderParent.class));
+    }
+
+    @Test
+    void capturesSharedInvoiceOnlyForCapableMemberAndKeepsDefaultProfileChoice() throws Exception {
+        arrangeSuccessfulBatch();
+        when(invoices.canInvoice(1L)).thenReturn(true);
+        var request = new com.fasterxml.jackson.databind.ObjectMapper().readValue("""
+                {"requestKey":"retry-invoice","quoteToken":"quote-1","participatingStoreIds":[1,2],
+                 "paymentMethods":[{"storeId":1,"payType":"3"},{"storeId":2,"payType":"1"}],
+                 "invoiceChoice":"COMPANY","buyerUbn":"12345678","buyerName":"甲公司",
+                 "buyerEmail":"a@example.com","saveAsDefault":true}
+                """, MultiStoreBatchCreateRequest.class);
+
+        subject().create(request, 42L);
+
+        verify(invoices).validateCheckoutInvoiceIntent("COMPANY", null, null,
+                "12345678", "a@example.com", 1L);
+        var captured = org.mockito.ArgumentCaptor.forClass(com.ruoyi.system.domain.PosOrderInvoice.class);
+        verify(invoices).captureInvoiceIntent(captured.capture());
+        assertEquals(100L, captured.getValue().getOrderId());
+        assertEquals(1L, captured.getValue().getStoreId());
+        assertEquals("COMPANY", captured.getValue().getInvoiceChoice());
+        assertEquals("12345678", captured.getValue().getBuyerUbn());
+        assertEquals("甲公司", captured.getValue().getBuyerName());
+        assertEquals("a@example.com", captured.getValue().getBuyerEmail());
+        verify(invoices).saveDefaultInvoiceProfile("COMPANY", null, "12345678", "甲公司", 42L);
+    }
+
+    private void arrangeSuccessfulBatch() {
+        arrangeQuote();
+        when(config.selectCurrentForUpdate()).thenReturn(policy());
+        InfoAddress address = new InfoAddress();
+        address.setId(10L); address.setUserId(42L);
+        address.setLatitude(new BigDecimal("22.6000")); address.setLongitude(new BigDecimal("120.3000"));
+        when(addresses.selectById(10L)).thenReturn(address);
+        when(stores.selectById(1L)).thenReturn(store(1));
+        when(stores.selectById(2L)).thenReturn(store(2));
+        PromotionCalcResponse food = new PromotionCalcResponse();
+        food.setOriginalAmount(new BigDecimal("50")); food.setFinalAmount(new BigDecimal("50"));
+        food.setDeliveryFee(new BigDecimal("36"));
+        food.setDeliveryDiscountAmount(BigDecimal.ZERO);
+        food.setCustomerDeliveryFee(new BigDecimal("36"));
+        food.setPayableAmount(new BigDecimal("86"));
+        when(quotes.priceFoodForBatch(any(), any(), any())).thenReturn(food);
+        doAnswer(call -> {
+            PosOrder order = call.getArgument(0);
+            PromotionCalcResponse calculated = call.getArgument(1);
+            order.setAmount(calculated.getPayableAmount().intValueExact());
+            order.setFreight(calculated.getDeliveryFee().doubleValue());
+            order.setFoodAmount(calculated.getOriginalAmount().intValueExact());
+            order.setPromotionPricingVersion(2);
+            order.setMerchantDeliverySubsidy(calculated.getDeliveryDiscountAmount());
+            order.setCustomerDeliveryFee(calculated.getCustomerDeliveryFee());
+            return null;
+        }).when(promotions).applyVersion2Pricing(any(PosOrder.class), any(PromotionCalcResponse.class));
+        when(promotions.buildServerFoodJson(any())).thenReturn("[]");
+        when(parents.insert(any(OrderParent.class))).thenReturn(1);
+        when(checkout.insertMember(any())).thenReturn(1);
+        when(checkout.insertGroup(any())).thenReturn(1);
+        when(checkout.activateIfPending(org.mockito.ArgumentMatchers.eq(99L), any())).thenReturn(1);
+        doAnswer(call -> { ((OrderCheckoutBatch) call.getArgument(0)).setId(99L); return 1; })
+                .when(checkout).insertOrGetBatch(any());
+        doAnswer(call -> { ((PosOrder) call.getArgument(0)).setId(100L); return 1; })
+                .when(orders).insert(any(PosOrder.class));
+
+    }
+
     @Test
     void retryReturnsCommittedBatchWithoutRequotingOrCreatingAnotherParent() {
         OrderCheckoutBatch existing = new OrderCheckoutBatch();

+ 1 - 1
specs/036-multi-store-checkout/contracts/api.md

@@ -7,7 +7,7 @@
 | 方法与路径 | 请求关键字段 | 成功响应 | 拒绝条件 |
 |---|---|---|---|
 | `POST /quote` | 地址 ID、顺序候选店与商品/规格/数量、同骑手、各店即时/预约时段、逐店备注 | `quoteToken`、有效至、锚点/围栏、参与店及逐店原运费/折后运费/商品价/实付/可选支付方式、圈外店和不兼容原因、规则版本 | 店/商品/配送资格不合格、支付方式仅转账、报价配置非法;圈外店仅报告不创建 |
-| `POST /batches` | `requestKey`、`quoteToken`、参与店 ID 列表、逐店 `paymentMethods:[{storeId,payType}]` | 父批次号、子单号/金额/已选方式及状态/可选方式、两个截止时间;逐店备注已按报价快照固化到子单 `remarks`,父单不存;含未付线上子单为 `PENDING`,全到付为 `ACTIVE`,但 `ACTIVE` 不等于已开放 | 报价过期、成员/地址/价格/规则变化、逐店方式缺漏/重复/夹带非参与店/不可用、权限不符;同用户同 requestKey 且载荷相同返回原批次,否则拒绝冲突 |
+| `POST /batches` | `requestKey`、`quoteToken`、参与店 ID 列表、逐店 `paymentMethods:[{storeId,payType}]`,可选顶层发票字段同普通下单 | 父批次号、子单号/金额/已选方式及状态/可选方式、两个截止时间;逐店备注已按报价快照固化到子单 `remarks`,可开票子单保存发票意图;含未付线上子单为 `PENDING`,全到付为 `ACTIVE`,但 `ACTIVE` 不等于已开放 | 报价过期、成员/地址/价格/规则变化、逐店方式缺漏/重复/夹带非参与店/不可用、电子发票无可开票店或资料校验失败、权限不符;同用户同 requestKey 且付款及发票载荷相同返回原批次,否则拒绝冲突 |
 | `GET /batches/{batchId}` | token + 路径批次 ID | 批次/逐店支付选择、真实实收、到付确认、退款与履约状态、截止时间、关联订单 | 非本人批次拒绝,不暴露其他用户父/子/组 |
 | `POST /batches/members/payment` | body `{batchId,orderId,payType,clientRequestId}`,`payType` = 1/2/3/5 | 新批次的安全改选:到付返回批次状态,线上返回 `ONLINE_PENDING`;网关创建仍走原接口 | 批次已生效/过操作截止、该店在途支付已实收或渠道核实未通过、商家不可用、归属不符;6 转账始终不支持。存在在途尝试时服务端先逐笔向渠道核实,确定未付(OMG UNPAID/FAILED、LINE 未捕获)即放行改选 |
 | `POST /batches/cancel` | body `{batchId,reason:"USER_CANCEL",clientRequestId}` | 当前入口只返回是否成功关闭未生效批次的布尔值;退款真实进度待详情接口返回 | 已 `ACTIVE` 须走子单取消规则;退款处理中不伪称退款成功 |

+ 13 - 3
specs/036-multi-store-checkout/frontend-app-integration.md

@@ -167,7 +167,7 @@
 ### 4.1 用户端
 
 1. 按顺序选择至少两家店与商品 → `POST /system/multiStoreCheckout/quote`。
-2. 展示参与店金额、各店 `availablePayTypes` 与 `excludedStores[]`;用户为**每家参与店**选择付款方式,按 `storeId` 组成 `paymentMethods` 后一次提交 `POST /system/multiStoreCheckout/batches`,保存 `batchId` 与各店 `orderId/orderNo`。圈外店不创建订单,引导剔除或另行单店下单。
+2. 展示参与店金额、各店 `availablePayTypes` 与 `excludedStores[]`;用户为**每家参与店**选择付款方式,按 `storeId` 组成 `paymentMethods`。发票选择沿普通订单整批只选一份,服务端只给可开票参与店子单保存意图;然后一次提交 `POST /system/multiStoreCheckout/batches`,保存 `batchId` 与各店 `orderId/orderNo`。圈外店不创建订单,引导剔除或另行单店下单。
 3. 建批返回的到付子单已是 `COD_CONFIRMED`,无需再次确认;线上子单为 `ONLINE_PENDING`,用该店 `orderNo` 调原渠道创建接口发起付款。仅在用户随后改选方式时调用 `POST /system/multiStoreCheckout/batches/members/payment`(见 5.3)。
 4. 支付回跳后刷新 `GET /system/multiStoreCheckout/batches/{batchId}`,以服务端状态为准。
 5. 订单列表用 `GET /user/cards` + `GET /user/cardCount`;不要调旧列表接口看多店单(旧接口不返回 036 子单/批次)。
@@ -258,7 +258,12 @@ POST /system/multiStoreCheckout/batches
   "paymentMethods": [
     { "storeId": 101, "payType": "3" },
     { "storeId": 102, "payType": "1" }
-  ]
+  ],
+  "invoiceChoice": "COMPANY",
+  "buyerUbn": "12345678",
+  "buyerName": "甲公司",
+  "buyerEmail": "invoice@example.com",
+  "saveAsDefault": false
 }
 ```
 
@@ -268,8 +273,13 @@ POST /system/multiStoreCheckout/batches
 | `quoteToken` | string | 是 | 5.1 返回的报价令牌;过期报"报价已过期,请重新获取" |
 | `participatingStoreIds` | long[] | 是 | 用户明确确认参与的门店,须是报价参与店的子集且 ≥2 家;夹带圈外店会被拒 |
 | `paymentMethods` | array | 是 | 与参与店一一对应的 `{storeId,payType}`;每店恰好一条,`payType` 是字符串且须在该店报价的 `availablePayTypes` 内;不支持转账 `"6"` |
+| `invoiceChoice` | string | 否 | 与普通订单相同:`PAPER`、`PHONE_BARCODE`、`CITIZEN`、`MEMBER`、`LOVE_CODE`、`COMPANY`;不传则不保存发票意图。整批只选一次,不是逐店数组 |
+| `carrierType` / `carrierNum` | string | 按选择 | 手机条码/自然人凭证使用普通订单同名字段与校验;服务端按选择规范化载具类型 |
+| `buyerUbn` / `buyerName` / `buyerEmail` | string | `COMPANY` 时 | 公司统编、名称及邮箱,沿普通订单校验;每个可开票子单各自开票 |
+| `loveCode` | string | `LOVE_CODE` 时 | 捐赠码,沿普通订单联网校验 |
+| `saveAsDefault` | boolean | 否 | 与普通订单相同;仅支持保存载具/公司抬头,纸本、会员及捐赠不保存 |
 
-不要只传普通单店请求的顶层 `paymentMethod` 或顶层 `payType`:多店建批只读取逐店的 `paymentMethods`,缺少、重复、夹带非参与店或选了该店不可用的方式都会被拒。网络重试须保持同一个 `requestKey`、`quoteToken`、参与店和逐店方式;**创建成功后要改方式,调用 5.3,不要用同一个 `requestKey` 重发不同的 `paymentMethods`**。
+不要只传普通单店请求的顶层 `paymentMethod` 或顶层 `payType`:多店建批只读取逐店的 `paymentMethods`,缺少、重复、夹带非参与店或选了该店不可用的方式都会被拒。电子发票至少需要一个参与店可开票;混合门店时不可开票的子单不生成发票意图,纸本选择按普通订单处理。建批只是保存意图,出餐后由各子单原开票流程处理,不代表提交时已经开票。网络重试须保持同一个 `requestKey`、`quoteToken`、参与店、逐店方式和发票字段;**创建成功后要改方式,调用 5.3,不要用同一个 `requestKey` 重发不同的付款或发票资料**。
 
 响应 `data`:
 

+ 1 - 0
specs/036-multi-store-checkout/plan.md

@@ -137,6 +137,7 @@
 - 真实回调/查询核销仍使用对应支付尝试的原交易及凭证快照验真,不用商家后续菜单/凭证变化抹掉已收款事实。实际不能出餐按既定子单规则,不设计结算未完成时新增商家供餐审核或菜品变化监听;快照不是库存保证,本轮不新增库存预占系统。
 - 新批次规则只用于服务端明确标识的新多店;普通创建/支付/价格/优惠/取消等不全局改写,未标识不误判未就绪或进入整批取消。唯一相关补齐是本次批准普通平台配送预约按旧时点后续扫描,不套新批次期限;单店转账、自取、自配送不改。
 - 单笔取餐/送达及结算/通知仍沿普通流程,组只汇总/去重任务。优惠、促销和发票逐单沿原有效规则;本次已确认补充新批次取消优惠券释放/返还、保留原有效期及幂等,见7.1,不全局重写普通券规则,不把返券或取消状态当作渠道退款成功。
+- 2026-10-22 开票补齐:建批顶层复用普通订单的一份发票选择与默认抬头字段;建父子单前校验非纸本意图,建每个子单后只为可开票门店捕获 `pos_order_invoice`,各店出餐继续复用既有自动开票。幂等摘要包含发票字段,无新增 SQL 或 App 源码改动。
 - 回归必须覆盖新批次创建后改价/上下架/普通停业仍按快照结算、非法付款继续拒绝、旧流程无新期限/就绪拦截、普通LINE/OMG/到付/转账、自取/自配送、促销优惠券发票、取消退款及消息时机;没有实际运行回归前不宣称兼容已验证。
 
 ## 5 配送组与金额快照

+ 6 - 0
specs/036-multi-store-checkout/spec.md

@@ -347,3 +347,9 @@
 - **FR-056** 线上最后一笔可信实收、到付最后确认或全到付建批令批次激活时,在原事务持久登记开放事件;仅事务提交后以独立事务复用统一开放条件。即时单及已到原预约开放时点的批次立即写入子单显示和组 `opened_at`;未到时点保留待处理事件。原分钟任务继续处理预约到点、即时触发失败和重启补偿,开放与通知仍为两个阶段,重复触发不得重复开放或推送。
 - **FR-057** 新版骑手列表和角标方法归于 `PosOrderQsOprateController`,对外保留 `/system/multiStoreCheckout/rider/tasks` 与 `/system/multiStoreCheckout/rider/taskCount`,响应、版本门禁和普通单/多店组聚合口径不变;旧 `/system/orderQsOprate/orderList`、`orderCount` 继续兼容旧版单笔列表。
 - **SC-042** 验证事务提交前及回滚时不开放;提交后的即时单开放一次,预约未到不开放、到点由原任务开放;触发失败保留事件可重试;新旧骑手 URL 与版本门禁保持兼容。
+
+## 2026-10-22 多店下单发票资料对齐普通订单
+
+- **FR-058** 建批请求可沿用普通 `/system/userOrder/createOrder` 的顶层 `invoiceChoice`、`carrierType`、`carrierNum`、`buyerUbn`、`buyerName`、`buyerEmail`、`loveCode`、`saveAsDefault`;整批只选一份发票资料,仅为可开票参与门店的每个子单写 `pos_order_invoice` 意图行,父单和不可开票子单不写。非纸本选择在创建前使用首个可开票参与门店执行既有载具/捐赠/统编校验;全体不可开票时拒绝电子发票选择。纸本与默认抬头沿用普通订单规则,出餐时各子单仍走既有自动开票流程,不在建批时直接开票。
+- **FR-059** 发票资料纳入建批 `requestKey` 的幂等摘要;同键网络重试须保持发票字段一致,不能更改已建子单的开票意图。未传发票字段的旧客户端保持无发票意图的现有行为;不新增数据库列。
+- **SC-043** 混合可开票/不可开票门店仅为前者保存一次意图;全部不可开票且选择电子发票、无效载具或统编均在父子单写入前拒绝;纸本与未选择发票兼容旧建批;同键改发票资料拒绝,普通单原行为不变。

+ 6 - 0
specs/036-multi-store-checkout/tasks.md

@@ -172,3 +172,9 @@ T040验证:2026-10-21 定向 23 项(核实器/两服务/选择入口)及 M
 ## 2026-10-22 多店逐店备注
 
 - [x] T043 报价请求 `stores[].remarks` 随快照固化到对应子单 `pos_order.remarks`(父单不另存),商家/骑手沿现有子单字段展示;补建批备注落库测试,更新 spec/契约/接入文档。
+
+## 2026-10-22 多店开票意图
+
+- [x] T044 建批接收与普通订单相同的顶层发票字段,非纸本提交前校验、仅可开票子单捕获意图,默认抬头规则共用,发票字段进入幂等摘要;补混合门店、全不可开票、重试冲突和普通单回归测试,同步 App 契约。无需数据库迁移,不修改 App 源码。
+
+T044验证(2026-10-22):先以无可开票门店的电子发票请求复现旧实现忽略字段、仍建批(定向测试失败);修复后 JDK 21 执行 `mvn -q -pl ruoyi-admin -am verify` 成功,213 份 Surefire 报告合计 1445 项、失败/错误/跳过均 0;`git diff --check` 通过。未连接真实数据库、ezPay 服务或 App 真机,未执行 SQL 迁移。