瀏覽代碼

开票功能调整

qmj 6 天之前
父節點
當前提交
dfd7687423

文件差異過大導致無法顯示
+ 22 - 0
.claude/homunculus/observations.jsonl


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

@@ -199,6 +199,94 @@ public class OrderInvoiceService {
         return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
     }
 
+    /**
+     * 商家出餐自动开票(010 Phase 11):读 pos_order 发票意图 → 组装 dto(个人 BuyerName=手机号、
+     * 捐赠=机构名)→ 复用开票核心 {@link #reIssueAndSave}。系统触发,不校验客户归属;货到付款未付款也开
+     * (送达失败/取消时作废兜底)。门店不可开票 / 订单无发票意图 / 已开 → 静默跳过,不影响出餐。
+     *
+     * @param orderId PosOrder.id(PK,与 invalid/retry/selectInvoiceDetail 一致)
+     * @return 1=已开 0=跳过或失败
+     */
+    public int autoIssue(Long orderId) {
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
+        if (order == null || StrUtil.isBlank(order.getInvoiceChoice())) {
+            return 0; // 订单不存在或无发票意图(门店不可开票时 createOrder 未写意图)
+        }
+        Long storeId = order.getMdId();
+        if (!canInvoice(storeId)) {
+            return 0; // 出餐时门店已不可开票(被停用等),跳过
+        }
+        PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
+        if (ez == null) {
+            return 0;
+        }
+        // 防重复:已开则跳过
+        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
+                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+        if (row != null && Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
+            return 0;
+        }
+        ApplyInvoiceDto dto = buildAutoIssueDto(order);
+        EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
+        // 捐赠=机构名;个人=手机号
+        String loveOrgName = null;
+        if ("LOVE_CODE".equals(order.getInvoiceChoice())) {
+            loveOrgName = resolveLoveOrg(dto.getLoveCode(), cfg);
+            dto.setBuyerName(loveOrgName);
+        } else if (isPersonalChoice(order.getInvoiceChoice())) {
+            InfoUser u = infoUserService.getOne(
+                    new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, order.getUserId()));
+            dto.setBuyerName(u != null && StrUtil.isNotBlank(u.getPhone()) ? u.getPhone() : "會員");
+        }
+        PosOrderInvoice saveRow = row == null ? new PosOrderInvoice() : row;
+        saveRow.setOrderId(orderId);
+        saveRow.setOrderNo(order.getDdId());
+        saveRow.setStoreId(storeId);
+        saveRow.setInvoiceCategory(dto.getCategory());
+        saveRow.setBuyerName(dto.getBuyerName());
+        saveRow.setBuyerUbn(dto.getBuyerUbn());
+        saveRow.setBuyerEmail(dto.getBuyerEmail());
+        saveRow.setCarrierType(dto.getCarrierType());
+        saveRow.setCarrierNum(dto.getCarrierNum());
+        saveRow.setLoveCode(dto.getLoveCode());
+        saveRow.setLoveOrgName(loveOrgName);
+        saveRow.setApplyTime(new Date());
+        return reIssueAndSave(order, saveRow, dto, ez);
+    }
+
+    /** 出餐自动开票:pos_order 发票意图 → ApplyInvoiceDto(捐赠走 DONATION 复用既有分支)。 */
+    private ApplyInvoiceDto buildAutoIssueDto(PosOrder order) {
+        ApplyInvoiceDto dto = new ApplyInvoiceDto();
+        dto.setOrderId(order.getId());
+        String choice = order.getInvoiceChoice();
+        switch (choice) {
+            case "PAPER":
+            case "PHONE_BARCODE":
+            case "CITIZEN":
+                dto.setCategory("B2C");
+                dto.setCarrierType(order.getCarrierType()); // PAPER 时为 null → 个人纸本分支
+                dto.setCarrierNum(order.getCarrierNum());
+                break;
+            case "LOVE_CODE":
+                dto.setCategory("DONATION");
+                dto.setLoveCode(order.getLoveCode());
+                break;
+            case "COMPANY":
+                dto.setCategory("B2B");
+                dto.setBuyerUbn(order.getBuyerUbn());
+                dto.setBuyerName(order.getInvoiceBuyerName());
+                break;
+            default:
+                break;
+        }
+        return dto;
+    }
+
+    private boolean isPersonalChoice(String choice) {
+        return "PAPER".equals(choice) || "PHONE_BARCODE".equals(choice) || "CITIZEN".equals(choice);
+    }
+
     /** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */
     public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) {
         PosOrder order = posOrderMapper.selectOne(new LambdaQueryWrapper<PosOrder>().eq(PosOrder::getDdId,orderId));
@@ -265,6 +353,11 @@ public class OrderInvoiceService {
         return posOrderInvoiceMapper.selectInvoiceList(query);
     }
 
+    /** 会员发票夹(010 Phase 11):按 userId 列已开发票(不含码图)。配合 PageHelper 分页。 */
+    public List<PosOrderInvoiceVo> listMyInvoices(Long userId) {
+        return posOrderInvoiceMapper.selectMyInvoices(userId);
+    }
+
     /** 管理端:订单发票详情。 */
     public PosOrderInvoiceVo detail(Long orderId) {
         return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
@@ -425,6 +518,75 @@ public class OrderInvoiceService {
                 && Integer.valueOf(1).equals(ez.getIsEnabled());
     }
 
+    /** 取门店 ezPay 凭证配置;未开通/未启用返回 null(不抛异常,供校验场景容忍使用)。 */
+    private EzPayConfig getEzpayConfig(Long storeId) {
+        if (storeId == null) {
+            return null;
+        }
+        PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
+        if (ez == null || !Integer.valueOf(2).equals(ez.getEzpayStatus())
+                || !Integer.valueOf(1).equals(ez.getIsEnabled())) {
+            return null;
+        }
+        return new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
+    }
+
+    /**
+     * 结账时校验发票意图(010 Phase 11):载具/捐赠联网验真、统编/凭证格式校验。
+     * PAPER(默认纸本)不校验。联网校验(手机条码/捐赠码)用 storeId 对应门店的 ezPay 凭证调 BDV。
+     *
+     * @param storeId 一个可开票门店(调用方负责选 canInvoice 的门店,用于取 ezPay 凭证)
+     */
+    public void validateCheckoutInvoiceIntent(String invoiceChoice, String carrierNum, String loveCode,
+                                              String buyerUbn, Long storeId) {
+        if (StrUtil.isBlank(invoiceChoice) || "PAPER".equals(invoiceChoice)) {
+            return;
+        }
+        switch (invoiceChoice) {
+            case "PHONE_BARCODE":
+                if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^/[0-9A-Z+\\-.]{7}$")) {
+                    throw new ServiceException("手机条码格式不正确(须为 / 加 7 位大写英文/数字/+-.)");
+                }
+                EzPayConfig barCfg = getEzpayConfig(storeId);
+                if (barCfg == null) {
+                    throw new ServiceException("门店暂不支持电子发票,无法校验手机条码");
+                }
+                boolean barExist;
+                try {
+                    barExist = ezPay.checkBarCode(ezpayBaseUrl, barCfg, carrierNum);
+                } catch (Exception e) {
+                    throw new ServiceException("手机条码校验服务暂不可用,请稍后重试");
+                }
+                if (!barExist) {
+                    throw new ServiceException("手机条码不存在,请确认或改选纸本发票");
+                }
+                break;
+            case "CITIZEN":
+                if (StrUtil.isBlank(carrierNum) || !carrierNum.matches("^[A-Z]{2}\\d{14}$")) {
+                    throw new ServiceException("自然人凭证格式不正确(须为 2 位大写英文 + 14 位数字)");
+                }
+                break;
+            case "LOVE_CODE":
+                if (StrUtil.isBlank(loveCode) || !loveCode.matches("^\\d{3,7}$")) {
+                    throw new ServiceException("捐赠码格式不正确(须为 3-7 位数字)");
+                }
+                EzPayConfig loveCfg = getEzpayConfig(storeId);
+                if (loveCfg == null) {
+                    throw new ServiceException("门店暂不支持电子发票,无法校验捐赠码");
+                }
+                resolveLoveOrg(loveCode, loveCfg); // 抛异常=捐赠码无效
+                break;
+            case "COMPANY":
+                if (StrUtil.isBlank(buyerUbn) || !buyerUbn.matches("^\\d{8}$")) {
+                    throw new ServiceException("统一编号格式不正确(须为 8 位数字)");
+                }
+                break;
+            default:
+                throw new ServiceException("发票类型不合法:" + invoiceChoice);
+        }
+    }
+
     /**
      * 捐赠机构列表(客户端选择器,仅 enabled=1,配合 PageHelper 分页)。
      */
@@ -490,8 +652,12 @@ public class OrderInvoiceService {
                 throw new ServiceException("买方名称不能为空");
             }
             String ct = dto.getCarrierType();
+            if (StrUtil.isBlank(ct)) {
+                // 无载具 → 个人纸本(PrintFlag=Y,零信息兜底),无需载具校验
+                return;
+            }
             if (!"0".equals(ct) && !"1".equals(ct) && !"2".equals(ct)) {
-                throw new ServiceException("B2C 发票须选择载具类型(0手机条码/1自然人凭证/2ezPay会员)");
+                throw new ServiceException("载具类型只能为 0手机条码/1自然人凭证/2ezPay会员");
             }
             if (StrUtil.isBlank(dto.getCarrierNum())) {
                 throw new ServiceException("载具号码不能为空");
@@ -537,13 +703,18 @@ public class OrderInvoiceService {
             inv.put("LoveCode", dto.getLoveCode());
             inv.put("PrintFlag", "N");
         } else {
-            // B2C 载具必填(validateInvoiceInput 已校验 carrierType∈0/1/2、号码非空),PrintFlag=N
-            inv.put("CarrierType", dto.getCarrierType());
-            inv.put("CarrierNum", dto.getCarrierNum());
-            inv.put("PrintFlag", "N");
-            // ezPay 会员载具(2)需邮箱;手机条码(0)/自然人凭证(1)票进载具,邮箱留空
-            if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
-                inv.put("BuyerEmail", dto.getBuyerEmail());
+            // B2C:有载具 → PrintFlag=N 存载具;无载具 → PrintFlag=Y 个人纸本(零信息兜底,010 Phase 11)
+            if (StrUtil.isNotBlank(dto.getCarrierType())) {
+                inv.put("CarrierType", dto.getCarrierType());
+                inv.put("CarrierNum", dto.getCarrierNum());
+                inv.put("PrintFlag", "N");
+                // ezPay 会员载具(2)需邮箱;手机条码(0)/自然人凭证(1)票进载具,邮箱留空
+                if ("2".equals(dto.getCarrierType()) && StrUtil.isNotBlank(dto.getBuyerEmail())) {
+                    inv.put("BuyerEmail", dto.getBuyerEmail());
+                }
+            } else {
+                // 个人纸本:无载具无捐赠,法定 PrintFlag=Y;不传载具/邮箱
+                inv.put("PrintFlag", "Y");
             }
         }
         inv.put("TaxType", "1");

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

@@ -279,6 +279,11 @@ public class PosOrderShOprateController extends BaseController {
         orderLogHelper.log(String.valueOf(order.getDdId()), 2, Long.valueOf(new JwtUtil().getusid(token)), shName, "商家" + shName + "已出餐");
         // 出餐推送
         chuCan(order, update);
+        // 出餐自动开票(010 Phase 11):按订单发票意图自动开,异常不阻断出餐,失败单由运营后台重试
+        try {
+            orderInvoiceService.autoIssue(order.getId());
+        } catch (Exception ignore) {
+        }
         return AjaxResult.success();
     }
 

+ 93 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -75,6 +75,8 @@ public class UserOrderController extends BaseController {
     private PosOrderPromotionMapper posOrderPromotionMapper;
     @Autowired
     private OrderInvoiceService orderInvoiceService;
+    @Autowired
+    private IInfoInvoiceService infoInvoiceService;
 
     /**
      * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
@@ -114,6 +116,21 @@ public class UserOrderController extends BaseController {
         return getDataTable(orderInvoiceService.listLoveOrg(keyword));
     }
 
+    /**
+     * 会员发票夹(010 Phase 11):我的发票列表,按 userId 查已开发票(不含码图)。
+     */
+    @GetMapping("/myInvoices")
+    @Auth
+    @Anonymous
+    public TableDataInfo myInvoices(@RequestHeader String token,
+                                    @RequestParam(defaultValue = "1") int pageNum,
+                                    @RequestParam(defaultValue = "10") int pageSize) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        PageHelper.startPage(pageNum, pageSize);
+        List<com.ruoyi.system.domain.vo.PosOrderInvoiceVo> list = orderInvoiceService.listMyInvoices(userId);
+        return getDataTable(list);
+    }
+
     /**
      * 创建订单
      */
@@ -133,8 +150,25 @@ public class UserOrderController extends BaseController {
             }
             shdz= JSON.toJSONString(usadd);
         }
+        // === 发票意图(010 Phase 11)===
+        // 校验:载具/捐赠/统编验真前置,不通过拦截下单(联网校验用第一个可开票门店的 ezPay 凭证)
+        if (!StringUtils.isEmpty(input.getInvoiceChoice()) && !"PAPER".equals(input.getInvoiceChoice())) {
+            Long invoiceStoreId = input.getItems() == null ? null : input.getItems().stream()
+                    .map(OrderCreatItem::getMdId)
+                    .filter(md -> orderInvoiceService.canInvoice(md))
+                    .findFirst().orElse(null);
+            if (invoiceStoreId == null) {
+                throw new ServiceException("所选门店暂不支持电子发票");
+            }
+            orderInvoiceService.validateCheckoutInvoiceIntent(input.getInvoiceChoice(), input.getCarrierNum(),
+                    input.getLoveCode(), input.getBuyerUbn(), invoiceStoreId);
+        }
         createOrderParent(input,Long.valueOf(id),shdz);
         createOrderChild(input,Long.valueOf(id),Long.valueOf(id),shdz);
+        // saveAsDefault:把载具/统编存为默认抬头(同一用户仅一条 is_default=1)
+        if (Boolean.TRUE.equals(input.getSaveAsDefault()) && !StringUtils.isEmpty(input.getInvoiceChoice())) {
+            saveDefaultInvoiceProfile(input, Long.valueOf(id));
+        }
 
         OrderParent result = orderParentService.getOne(new LambdaQueryWrapper<OrderParent>().eq(OrderParent::getDdId, input.getDdId().toString()));
         System.out.println("创建返回信息:"+result);
@@ -259,6 +293,23 @@ public class UserOrderController extends BaseController {
 
             createFootprint(userId, item.getMdId());
             setPickUpNum(posOrder);
+            // 发票意图落到子订单(010 Phase 11):仅开发票门店写,否则该子订单出餐时不自动开票
+            if (!StringUtils.isEmpty(input.getInvoiceChoice()) && orderInvoiceService.canInvoice(item.getMdId())) {
+                String choice = input.getInvoiceChoice();
+                posOrder.setInvoiceChoice(choice);
+                posOrder.setCarrierNum(input.getCarrierNum());
+                posOrder.setBuyerUbn(input.getBuyerUbn());
+                posOrder.setInvoiceBuyerName(input.getBuyerName());
+                posOrder.setLoveCode(input.getLoveCode());
+                // 载具类型由 invoiceChoice 推导(PHONE_BARCODE=0 / CITIZEN=1)
+                if ("PHONE_BARCODE".equals(choice)) {
+                    posOrder.setCarrierType("0");
+                } else if ("CITIZEN".equals(choice)) {
+                    posOrder.setCarrierType("1");
+                } else {
+                    posOrder.setCarrierType(input.getCarrierType());
+                }
+            }
             posOrderService.save(posOrder);
 
             // --- 促销:写入快照 + 标记券已使用(save之后,需要posOrder.id) ---
@@ -294,6 +345,48 @@ public class UserOrderController extends BaseController {
         }
     }
 
+    //保存默认发票抬头(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)) {
+            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) {

+ 16 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/order/dto/OrderCreateInput.java

@@ -47,5 +47,21 @@ public class OrderCreateInput {
     /** 自取预留电话 */
     private String reservePhone;
 
+    /** === 发票意图(下单时选,出餐自动开票;010 Phase 11)=== */
+    /** 发票选择 PAPER个人纸本(默认)/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司 */
+    private String invoiceChoice;
+    /** 载具类型 0手机条码/1自然人凭证(PHONE_BARCODE/CITIZEN 时填) */
+    private String carrierType;
+    /** 载具号码(PHONE_BARCODE/CITIZEN 时填) */
+    private String carrierNum;
+    /** 公司统编 8位(COMPANY 时填) */
+    private String buyerUbn;
+    /** 公司名称(COMPANY 时填,作为发票买方名) */
+    private String buyerName;
+    /** 捐赠码 3-7位(LOVE_CODE 时填) */
+    private String loveCode;
+    /** 是否将该载具/统编存为默认抬头(true 写 info_invoice 并标记默认) */
+    private Boolean saveAsDefault;
+
     private List<OrderCreatItem> items;
 }

+ 36 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/EzPay.java

@@ -147,6 +147,42 @@ public class EzPay {
         return decrypted != null && decrypted.matches(".*IsExist=Y(\\b|$).*");
     }
 
+    /**
+     * 手机条码验证。对应 POST /Api_inv_application/checkBarCode(BDV v1.0)。
+     *
+     * <p>校验手机条码是否真实注册于财政部电子发票整合服务平台(通用载具),用于结账时拦截无效条码、
+     * 避免发票存进不存在的载具(客户填错条码 → 发票丢失)。与 {@link #checkLoveCode} 同为 BDV 接口,
+     * 请求需带 CheckValue,回应 Result 为加密 hex,解密后形如 {@code BarCode=/ABC1234&IsExist=Y}。
+     *
+     * <p><b>文档勘误</b>:CheckValue 按手册「HashKey 在前」实现({@link EzPayEncryptUtil#genCheckValue}),
+     * 若测试环境 ezPay 回 CheckValue 校验失败,改为「HashIV 在前」重试(见加密工具 javadoc)。
+     *
+     * @param barCode 手机条码(/ + 7 位大写英文/数字/+-.)
+     * @return true=该手机条码存在于财政部平台
+     */
+    public boolean checkBarCode(String baseUrl, EzPayConfig config, String barCode) throws Exception {
+        Map<String, Object> postData = new LinkedHashMap<>();
+        postData.put("RespondType", "JSON");
+        postData.put("Version", "1.0");
+        postData.put("TimeStamp", String.valueOf(System.currentTimeMillis() / 1000L));
+        postData.put("BarCode", barCode);
+        String query = buildQuery(postData);
+        String postDataHex = EzPayEncryptUtil.encrypt(query, config.getHashKey(), config.getHashIV());
+        String checkValue = EzPayEncryptUtil.genCheckValue(postDataHex, config.getHashKey(), config.getHashIV());
+        String resp = postFormWithCheck(baseUrl + URL_CHECK_BARCODE, config.getMerchantId(), postDataHex, checkValue);
+        JSONObject json = JSON.parseObject(resp);
+        if (json == null || !"SUCCESS".equals(json.getString("Status"))) {
+            return false;
+        }
+        String resultHex = json.getString("Result");
+        if (resultHex == null || resultHex.isEmpty()) {
+            return false;
+        }
+        String decrypted = EzPayEncryptUtil.decrypt(resultHex, config.getHashKey(), config.getHashIV());
+        // decrypted 形如 BarCode=/ABC1234&IsExist=Y
+        return decrypted != null && decrypted.matches(".*IsExist=Y(\\b|$).*");
+    }
+
     /**
      * 底层调用:补充 TimeStamp → http_build_query → PostData_ 加密 → Form Post → JSON。
      *

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/InfoInvoice.java

@@ -70,6 +70,9 @@ public class InfoInvoice
     /** 载具号码(仅 B2C 必填,随 carrierType:0手机条码以/开头;1自然人凭证=2字母+14数字;2 ezPay会员账号) */
     private String carrierNum;
 
+    /** 是否默认载具/抬头(0否 1是;010 Phase 11 全联式「同意设置为默认」用,同一用户仅一条为 1) */
+    private Integer isDefault;
+
     /** 创建时间(DB DEFAULT CURRENT_TIMESTAMP,只读) */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;

+ 16 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrder.java

@@ -113,6 +113,22 @@ public class PosOrder {
     @Excel(name = "订单商品")
     private String food;
 
+    /**
+     * === 发票意图(下单 createOrder 时捕获,出餐 dispatchOrder 时自动开票消费;010 Phase 11)===
+     */
+    /** 发票选择 PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司;NULL=该单不开票 */
+    private String invoiceChoice;
+    /** 载具类型 0手机条码/1自然人凭证(仅 PHONE_BARCODE/CITIZEN) */
+    private String carrierType;
+    /** 载具号码(仅 PHONE_BARCODE/CITIZEN) */
+    private String carrierNum;
+    /** 公司统编 8位(仅 COMPANY) */
+    private String buyerUbn;
+    /** 公司名称=发票买方名(仅 COMPANY;个人发票买方名=InfoUser.phone 运行时取,不落此列) */
+    private String invoiceBuyerName;
+    /** 捐赠码 3-7位(仅 LOVE_CODE) */
+    private String loveCode;
+
 
     /**
      * 距离

+ 3 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoiceMapper.java

@@ -23,4 +23,7 @@ public interface PosOrderInvoiceMapper extends BaseMapper<PosOrderInvoice> {
 
     /** 订单发票详情(单行)。 */
     PosOrderInvoiceVo selectInvoiceDetail(@Param("orderId") Long orderId);
+
+    /** 会员发票夹(010 Phase 11):按 userId 列已开发票,join pos_order 过滤,不含码图。配合 PageHelper 分页。 */
+    List<PosOrderInvoiceVo> selectMyInvoices(@Param("userId") Long userId);
 }

+ 27 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoiceMapper.xml

@@ -79,4 +79,31 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         WHERE i.order_id = #{orderId}
     </select>
 
+    <!-- 会员发票夹(010 Phase 11):按 userId 列已开发票,不含码图(证明联一次为限,不返回 bar_code/qrcode) -->
+    <select id="selectMyInvoices" parameterType="Long"
+            resultType="com.ruoyi.system.domain.vo.PosOrderInvoiceVo">
+        SELECT
+            i.id,
+            i.order_id         AS orderId,
+            i.order_no         AS orderNo,
+            i.store_id         AS storeId,
+            s.pos_name         AS posName,
+            i.invoice_category AS invoiceCategory,
+            i.buyer_name       AS buyerName,
+            i.carrier_type     AS carrierType,
+            i.love_code        AS loveCode,
+            i.love_org_name    AS loveOrgName,
+            i.invoice_number   AS invoiceNumber,
+            i.random_num       AS randomNum,
+            i.invoice_status   AS invoiceStatus,
+            i.total_amt        AS totalAmt,
+            i.issue_time       AS issueTime
+        FROM pos_order_invoice i
+        LEFT JOIN pos_order o ON o.id = i.order_id
+        LEFT JOIN pos_store s ON s.id = i.store_id
+        WHERE o.user_id = #{userId}
+          AND i.invoice_status = 1
+        ORDER BY i.id DESC
+    </select>
+
 </mapper>

+ 172 - 0
specs/010-order-invoice/auto-invoice-design.md

@@ -0,0 +1,172 @@
+# 订单电子发票:全联式自动开票设计(010 演进)
+
+**Feature**: 演进 `010-order-invoice`(订单 ezPay 电子发票开立)
+**Created**: 2026-07-27
+**Status**: Draft(待审)
+**Supersedes**: 010 `spec.md` 中「客户在订单完成后手动申请开票」的**触发/入口模型**(改为下单捕获 + 出餐自动)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库等开票核心**全部复用**,不在本期重做。
+
+## 1. 背景与动机
+
+010 已实现「客户在订单完成后进订单详情手动申请发票」。现按**全联福利中心(PX Mart)**模式演进为:**下单(结账)时选发票类型(默认个人纸本),商家出餐时自动开票**。目标:
+
+- **零信息也能开**(默认个人纸本,BuyerName 取手机号),解决「客户没设载具怎么开」。
+- 发票自动随订单流转,**纸本由骑手送餐时一并交付**。
+- 会员有「**我的发票**」夹,App 内随时查看。
+- **开发票门店每笔必开**;免用发票门店不开。
+
+参考:全联福利中心 App、老板提供的纸本发票样张(标准台湾电子发票证明联:左右两组 QR 码 + 条码 + 发票号/卖方统编/买方/明细/随机码)。
+
+## 2. 范围
+
+**本期做(后端为主)**:
+
+- 下单(`createOrder`)捕获发票意图
+- 商家出餐(`dispatchOrder`)自动开票
+- 个人纸本零信息兜底分支(补 010 缺的 B2C 无载具→PrintFlag=Y 分支)
+- 载具校验(接入 `checkBarCode`)
+- 会员发票夹列表接口(按 user_id)
+- 发票信息双入口(结账时设 + 复用 014 `info_invoice` 作「我的发票抬头」管理)
+- `info_invoice` 加「是否默认」标记
+
+**本期不做(留增量)**:
+
+- 客户端 App 的结账页发票选择 UI、我的发票夹 UI、我的发票抬头管理 UI(沿用 010 分工,客户端团队对接)
+- POS 实体打印机对接(纸本证明联由商家自行打印,平台只生成码值)
+- 发票折让、字轨管理、批次开立、中奖通知
+
+## 3. 核心决策(已与需求方确认)
+
+| # | 决策 |
+|---|---|
+| D1 | 触发时机 = 商家出餐 `dispatchOrder`(state 1→2)统一开所有档位 |
+| D2 | 全联式 UX:发票类型在下单(结账)时选,**顶层只有 个人发票 / 公司发票 两种** |
+| D3 | 个人发票默认 = 纸本(零输入);要载具才现填手機條碜/自然人凭证;**捐赠归在个人发票下**(非并列类型) |
+| D4 | 个人(B2C)BuyerName = **用户手机号**(`InfoUser.phone`,自动取,客户不填) |
+| D5 | 「同意设置为默认」复选框 + 「选用常用载具/抬头」:复用 014 `info_invoice` 存储,加「是否默认」标记;**只对载具与统编抬头生效,捐赠不存** |
+| D6 | 发票信息双入口:① 结账时设 ②「我的发票抬头」管理页(014 后端已就绪) |
+| D7 | 纸本(PrintFlag=Y)证明联**只打一次**,骑手送餐带上;发票夹只展示结构化信息,**不补打、不重渲染码图** |
+| D8 | 载具/捐赠(PrintFlag=N)无纸本,发票夹标注「已存入载具/已捐赠」 |
+| D9 | 货到付款:出餐照开(纸本要赶骑手),送达失败/取消 → 作废(复用 010 `invalid`) |
+| D10 | 门店不可开票(`canInvoice=false`:免用发票或 ezPay 未开通/未启用)→ 结账页不展示发票选项,出餐跳过自动开票 |
+
+## 4. 结账页发票信息架构(客户端 UI 参考)
+
+```
+发票类型:
+  ○ 个人发票                      ← 默认选中
+  │    └─ 这张个人发票怎么处理?
+  │        ○ 纸本                  ← 默认,零输入,BuyerName=手机号,PrintFlag=Y
+  │        ○ 存手機條碜载具          [填条码 / 选用常用] [☐ 同意设置为默认]
+  │        ○ 存自然人凭证载具        [填凭证]
+  │        ○ 捐赠给社福机构          [选机构 / 填捐赠码],PrintFlag=N
+  │
+  ○ 公司发票
+       └─ 统一编号 [填] + 公司名称 [填],PrintFlag=Y (B2B)
+```
+
+ezPay 映射:
+
+| 结账选择 | Category | 关键字段 | PrintFlag |
+|---|---|---|---|
+| 个人→纸本(默认) | B2C | BuyerName=手机号,无载具无捐赠 | Y |
+| 个人→手機條碜 | B2C | CarrierType=0 + CarrierNum | N |
+| 个人→自然人凭证 | B2C | CarrierType=1 + CarrierNum | N |
+| 个人→捐赠 | B2C | LoveCode | N |
+| 公司发票 | B2B | BuyerUBN + BuyerName(公司名) | Y |
+
+> 010 现有 `ApplyInvoiceDto.DONATION` 独立类型可废弃:捐赠统一为 `B2C + loveCode`(代码 `buildIssueData` 已有此分支,见 010 T041)。
+
+## 5. 架构与数据流
+
+### 5.1 下单捕获(`createOrder`)
+
+- `OrderCreateInput` 新增发票意图字段:`invoiceChoice`(PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、`carrierType`、`carrierNum`、`buyerUbn`、`buyerName`(公司名,仅 COMPANY)、`loveCode`、`saveAsDefault`(boolean)。
+- `createOrder` 从 JWT 取 userId → 存订单时把发票意图写入 `pos_order`(新增列,见 §7)。
+- 门店守卫:`canInvoice(mdId)==false` → 该单不写发票意图(等价不开票);客户端结账页按 `canInvoice` 接口隐藏发票选项。
+- 载具/捐赠验真(见 §8):不通过则拦截下单。
+- `saveAsDefault=true` → 将该载具/统编抬头写入 `info_invoice` 并标记为默认(其余取消默认)。
+
+### 5.2 商家出餐自动开票(`dispatchOrder`,state 1→2)
+
+- `PosOrderShOprateController.dispatchOrder` 在 `setState(2)` 之后调用 `OrderInvoiceService.autoIssue(orderId)`。
+- `autoIssue` 流程:
+  1. 读 `pos_order` 发票意图 + 门店 ezPay 凭证(`assertInvoiceable`,复用 010)。
+  2. 门店不可开票 / 订单无发票意图 → **跳过**(不报错,不影响出餐)。
+  3. 组装 `ApplyInvoiceDto`(意图 → category/载具/统编/捐赠);**个人 BuyerName = `InfoUser.phone`**。
+  4. 复用 010 开票核心(`buildIssueData` + `ezPay.issueInvoice` + 落库)。**放宽两点**:不校验客户归属(系统触发)、不强制 `payStatus==1`(货到付款出餐时未付款也要开,见 D9)。
+  5. PrintFlag=Y → 码值(BarCode/QRcodeL/R)落库,供商家打印证明联。
+- 结果存 `pos_order_invoice`(复用 010),状态 已开 / 失败(失败由运营在后台重试,复用 010 retry)。
+
+### 5.3 会员发票夹(我的发票)
+
+- 新增 `GET /system/userOrder/myInvoices`(JWT,按 userId 分页)→ `pos_order_invoice i JOIN pos_order o ON i.order_id = o.id WHERE o.user_id = ?`。
+- 返回字段裁剪:PrintFlag=Y → 结构化信息(发票号/随机码/金额/明细/门店)+「纸本已开」,**不返回码图、无补打**;PrintFlag=N → 结构化信息 +「已存入载具/已捐赠」。
+- 客户端 UI 由客户端团队对接。
+
+### 5.4 货到付款兜底(D9)
+
+- 出餐照开(含货到付款)。若送达失败 / 订单取消(state→4)→ 调 `OrderInvoiceService.invalid(orderId)`(复用 010)作废发票。取消链路挂钩点见 §11。
+
+## 6. 关键业务规则
+
+- **零信息兜底**:个人纸本 = B2C + 无载具 + 无捐赠 → PrintFlag=Y(法定强制),BuyerName=`InfoUser.phone`。客户什么都不填也能开。**需补 010 缺的「B2C 无载具→PrintFlag=Y」分支**:现 `validateInvoiceInput` 强制 B2C 有载具(487–520 行),`buildIssueData` B2C 永远 PrintFlag=N(540–548 行)。
+- **证明联一次为限**:财政部规定证明联以输出一次为限。ezPay 技术上不锁(码值可经 `invoice_search` 重复取),但**平台不提供补打 / 不重渲染码图**,守住商家合规线。会员纸本丢失 → 凭发票号 + 随机码兑奖。
+- **PrintFlag 互斥**:载具/捐赠 PrintFlag=N,纸本/公司 PrintFlag=Y;载具不能再印纸本(防一票两份,ezPay 校验拦截)。
+- **BuyerName**:个人 = 手机号;公司 = 公司名(客户填);捐赠 = 机构名(开票时 `resolveLoveOrg` 回填)。
+- **金额**:发票金额 = `amount − freight`(不含运费,沿用 010 research D1)。
+
+## 7. 数据模型变更(SQL 写 `updatesql/sql.md`,不直接执行)
+
+- `pos_order` 新增发票意图列:`invoice_choice VARCHAR(16)`、`carrier_type VARCHAR(8)`、`carrier_num VARCHAR(64)`、`buyer_ubn VARCHAR(16)`、`invoice_buyer_name VARCHAR(100)`(公司名,仅 COMPANY 用)、`love_code VARCHAR(10)`。(均 DEFAULT NULL;`saveAsDefault` 不落订单,仅触发 `info_invoice` 写入)
+- `info_invoice` 新增 `is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头'`(014 原「无默认」FR-006,现加默认标记支持 D5)。
+
+## 8. 载具校验
+
+| 输入 | 校验方式 | 接入点 |
+|---|---|---|
+| 手機條碜 | ezPay `checkBarCode`(IsExist Y/N) | 结账填入 / 存默认时 |
+| 捐赠码 | ezPay `checkLoveCode`(010 已实现) | 结账填入(从开票前置到下单) |
+| 自然人凭证 | 仅格式正则(`^[A-Z]{2}\d{14}$`),**无 API** | 结账填入 |
+| 公司统编 | 格式正则(`^\d{8}$`) | 结账填入 |
+
+- 新增 `EzPay.checkBarCode(baseUrl, cfg, barCode)` 方法(平行 `checkLoveCode`;`EzPayEncryptUtil` 的 CheckValue/CheckCode 已就绪;`URL_CHECK_BARCODE` 常量已定义)。
+- `checkBarCode` 按门店凭证鉴权 → 结账验真用**该订单门店**的 ezPay 凭证。
+
+## 9. 复用 010 / 009 / 014 的部分(不改)
+
+- 010:`OrderInvoiceService` 开票核心(`buildIssueData`/issue/落库)、作废(`invalid`)、重试(`retry`);`PosOrderInvoice` 实体 + mapper + 码值字段(Phase 9)。
+- 工具类:`EzPay`/`EzPayConfig`/`EzPayEncryptUtil`(`checkLoveCode` 已有,加 `checkBarCode`)。
+- 009:门店 ezPay 凭证 + 免用发票标识(`canInvoice`/`assertInvoiceable`)。
+- 014:`info_invoice` 表 + `InfoInvoiceController` CRUD(复用为默认载具/抬头存储 + 管理入口)。
+
+## 10. 接口变更概览
+
+- `OrderCreateInput`:加发票意图字段。
+- `POST /system/userOrder/createOrder`:存发票意图到 `pos_order`;载具/捐赠验真;`saveAsDefault` 写 `info_invoice`。
+- `PosOrderShOprateController.dispatchOrder`:出餐 `setState(2)` 后调 `autoIssue`。
+- `OrderInvoiceService.autoIssue(orderId)`:新增(复用开票核心,放宽归属/payStatus 校验)。
+- `OrderInvoiceService.validateInvoiceInput` / `buildIssueData`:放宽 B2C(允许无载具)、补「无载具→PrintFlag=Y 纸本」分支、个人 BuyerName 取 `InfoUser.phone`。
+- `EzPay.checkBarCode`:新增。
+- `GET /system/userOrder/myInvoices`:新增(发票夹列表)。
+- `info_invoice` 默认标记:`InfoInvoiceController` 加设默认 / 取默认能力(或前端按 `is_default` 处理)。
+- i18n:结账页发票选项文案属客户端 UI,由客户端团队;后端无新增面向用户文字(沿用 010 FR-012 分工)。
+
+## 11. 开放问题 / 待确认
+
+- **autoIssue 同步 vs 异步**:出餐接口同步调 ezPay 开票会增加出餐耗时(ezPay 响应通常 <2s,可接受);若担心阻塞出餐,改异步(线程池 / MQ)+ 失败由运营后台重试。**倾向同步**(简单,失败有 `pos_order_invoice.status=失败` 可重试),实现阶段定。
+- **货到付款作废触发点**:订单取消(state→4)的入口需确认(商家取消 `PosOrderShOprateController` / 用户取消 `UserOrderController`),在该取消链路挂钩 `invalid`。实现阶段对齐现有取消入口。
+- **`/applyInvoice` 手动入口去留**:自动开票后客户无需手动申请。倾向**保留**作为「自动开票失败时客户可手动重试」的兜底入口(或仅运营后台重试即可,移除客户手动入口)。实现阶段定。
+- **多店合并下单(OrderParent)**:一次下多店,发票按**子订单(pos_order,每店一张)**各自开。`createOrder` 已拆子订单,发票意图随子订单存。实现阶段核实 parent/子订单归属与意图传递。
+
+## 12. 成功标准
+
+- 客户结账选「个人发票→纸本」(零输入)下单 → 商家出餐 → 自动开成个人纸本,码值落库,商家可打印证明联。
+- 客户选「手機條碜」填码 → `checkBarCode` 验真通过 → 出餐自动开成载具发票(PrintFlag=N)。
+- 门店免用发票 / ezPay 未开通 → 结账无发票选项,出餐不开票。
+- 货到付款订单出餐照开;送达失败取消 → 发票作废。
+- 会员在「我的发票」看到本平台开给他的所有发票(纸本看结构化信息、载具看「已存入载具」)。
+- 开发票门店每笔订单 100% 自动开出(不依赖客户手动申请)。
+
+## 13. 后续
+
+本设计审通过后,按项目「追加需求直接更新现有 spec/plan/tasks」习惯,把本设计折叠进 `specs/010-order-invoice/` 的 `spec.md`(新增自动开票 user story / FR)、`plan.md`(本设计)、`tasks.md`(新增任务),再进入实现。

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

@@ -152,3 +152,26 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 - SQL:`updatesql/sql.md` 加 pos_order_invoice ALTER 两列(pos_love_org 已在 pos_love_org.sql)
 - 前端:平台后台 `orderInvoice/index.vue` 捐赠展示 + i18n;商家端订单详情捐赠标识 + i18n
 
+## Enhancement: 全联式自动开票 (2026-07-27)
+
+**背景**:010 现为「客户订单完成后手动申请开票」。按全联福利中心(PX Mart)模式演进为「下单选发票类型(默认个人纸本)+ 出餐自动开 + 会员发票夹」。完整设计见 [`auto-invoice-design.md`](./auto-invoice-design.md)(已与需求方多轮确认通过)。
+
+**设计决策摘要**(详见 auto-invoice-design.md):
+
+1. **触发**:商家出餐 `dispatchOrder`(state 1→2)统一开所有档位(纸本赶得上骑手取餐)。
+2. **入口/IA**:下单(结账)时选,顶层 个人发票 / 公司发票 两种;个人默认子项=纸本(零输入),捐赠归在个人发票下(B2C + LoveCode)。
+3. **零信息兜底**:补 010 缺的「B2C 无载具→PrintFlag=Y 纸本」分支(现 `validateInvoiceInput` 强制 B2C 有载具);个人 BuyerName=`InfoUser.phone`。
+4. **载具校验**:新增 `EzPay.checkBarCode`(平行 checkLoveCode;EzPayEncryptUtil/`URL_CHECK_BARCODE` 已就绪),结账验真用门店凭证;捐赠码 checkLoveCode 前置到下单。
+5. **证明联一次为限**:平台不补打/不重渲染码图;发票夹纸本只展示结构化信息(发票号+随机码+明细)。
+6. **货到付款**:出餐照开,订单取消链路挂 `invalid`。
+7. **会员发票夹**:新增 `GET /system/userOrder/myInvoices`(按 user_id JOIN pos_order)。
+8. **双入口/默认**:`info_invoice` 加 `is_default`;复用 014 CRUD,全联式不建单独管理页。
+
+**数据模型**(SQL 写 `updatesql/sql.md`):`pos_order` 加发票意图列(`invoice_choice`/`carrier_type`/`carrier_num`/`buyer_ubn`/`invoice_buyer_name`/`love_code`);`info_invoice` 加 `is_default`。
+
+**文件改动**:`OrderCreateInput` + `createOrder`;`PosOrder`(+mapper);`OrderInvoiceService`(`autoIssue` + 补纸本分支 + BuyerName 取手机号 + 放宽校验);`PosOrderShOprateController.dispatchOrder` 挂 `autoIssue`;取消链路挂 `invalid`;`EzPay.checkBarCode`;`UserOrderController` 加 `myInvoices`;`InfoInvoiceController` 默认能力;SQL。
+
+**开放问题**(实现阶段定):autoIssue 同步/异步;货到付款作废触发点对齐;`/applyInvoice` 手动入口去留;多店合并下单子订单发票归属。
+
+**范围边界**:交付后端 + 管理端展示;客户端 App 的结账页/发票夹/抬头管理 UI 由客户端团队(沿用 010 分工)。POS 实体打印不做(商家自打证明联)。
+

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

@@ -174,3 +174,29 @@
 - 捐赠发票 PrintFlag=N:ezPay 不返回 BarCode/QRcode 码图,遵循 `reference-tw-einvoice-concepts` 的码图规则(仅 PrintFlag=Y 才有码图)。
 - 发票金额不含运费:运费由用户承担(付给配送方),非商家商品收入;商家发票仅就商品销售开立。订单构成 `amount = 商品 − 各项优惠 + 运费`,故发票金额 = `amount − 运费`,各类优惠已含在 amount 内无需逐项区分。
 - 四语言 i18n 覆盖所有新增面向用户文字,遵循项目既有 i18n 规范。
+
+---
+
+## 演进:全联式自动开票 (2026-07-27)
+
+> 完整设计见 [`auto-invoice-design.md`](./auto-invoice-design.md)。010 已建的 B2C/B2B/载具/捐赠/作废/重试/码值落库**全部复用**,仅改触发(手动→出餐自动)、入口(完成后申请→下单捕获),并加零信息纸本兜底 + 会员发票夹。
+
+### User Story 7 - 全联式下单选发票 + 出餐自动开 (Priority: P1)
+
+客户下单(结账)时选发票类型,顶层只有「个人发票 / 公司发票」两种,个人发票默认选中、默认子项=纸本(零输入)。商家出餐时系统按所选类型自动开票;纸本证明联商家打印、骑手送餐时一并交付。零信息客户(没设载具)也能开——默认个人纸本,BuyerName 取手机号。
+
+**Independent Test**: 客户结账选「个人发票→纸本」(不填任何信息)下单 → 商家点出餐 → 系统自动开出个人纸本发票,码值落库,商家可打印证明联。
+
+### User Story 8 - 会员发票夹 (Priority: P1)
+
+会员在「我的发票」看到本平台开给他的所有发票:纸本发票看结构化信息(发票号/随机码/金额/明细)+「纸本已开」,载具/捐赠发票看「已存入载具/已捐赠」。平台不提供纸本补打/重渲染码图(证明联一次为限)。
+
+### 新增功能需求(FR)
+
+- **FR-017**: 下单(`createOrder`)时捕获发票意图(类型 + 载具/统编/捐赠码 + saveAsDefault),存 `pos_order`。
+- **FR-018**: 商家出餐(`dispatchOrder`,state 1→2)时对开发票门店自动开票;免用发票/未开通门店跳过。
+- **FR-019**: 个人纸本零信息兜底——B2C 无载具无捐赠 → PrintFlag=Y,BuyerName=`InfoUser.phone`,客户不填任何信息也能开。
+- **FR-020**: 载具/捐赠验真前置到下单:手機條碜调 `checkBarCode`、捐赠码调 `checkLoveCode`,验真不通过拦截下单;自然人凭证仅格式校验(无 API)。
+- **FR-021**: 货到付款出餐照开;订单取消(state→4)→ 作废发票(复用 invalid)。
+- **FR-022**: 会员发票夹——按 user_id 列出发票;纸本只展示结构化信息,不返回码图、无补打。
+- **FR-023**: 发票信息双入口——结账时设 + 「我的发票抬头」管理(复用 014 `info_invoice`);`info_invoice` 加「是否默认」标记;默认/常用只对载具与统编抬头生效,捐赠不存。

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

@@ -255,3 +255,28 @@ description: "Task list for 订单 ezPay 电子发票开立"
 - 前端两个仓库均为 CRLF,编辑 lang 文件用 Python 脚本(见 CLAUDE.md「前端文件编辑注意事项」)
 - 所有前端新增文字必须四语言 i18n,key 加到正确对象层级、驼峰命名
 - SQL 不直接执行,写 `updatesql/sql.md` 由开发者手动跑
+
+---
+
+## Phase 11: 全联式自动开票 (2026-07-27)
+
+**Goal**: 下单捕获发票意图 + 出餐自动开 + 零信息纸本兜底 + 会员发票夹 + 载具校验 + 默认载具
+**Design**: [`auto-invoice-design.md`](./auto-invoice-design.md)
+
+### Implementation
+
+- [ ] T048 [P] SQL: `updatesql/sql.md` 追加 2026-07-27 段——① `pos_order` ADD COLUMN `invoice_choice`/`carrier_type`/`carrier_num`/`buyer_ubn`/`invoice_buyer_name`/`love_code`;② `info_invoice` ADD COLUMN `is_default TINYINT NOT NULL DEFAULT 0`
+- [ ] T049 [P] `OrderCreateInput.java` 加发票意图字段(invoiceChoice/carrierType/carrierNum/buyerUbn/buyerName/loveCode/saveAsDefault)
+- [ ] T050 [P] `PosOrder.java` 加对应 6 字段(+ mapper XML insert/select 如涉及则补)
+- [ ] T051 [P] `InfoInvoice.java`(014 实体)加 `isDefault` 字段 + mapper
+- [ ] T052 [P] `EzPay.checkBarCode(baseUrl, cfg, barCode)`:平行 `checkLoveCode`,封装 BDV `/Api_inv_application/checkBarCode`,回 IsExist。依赖 `EzPayEncryptUtil`(CheckValue 已就绪)、`URL_CHECK_BARCODE` 常量
+- [ ] T053 `UserOrderController.createOrder`:存发票意图到 pos_order;门店守卫(`canInvoice=false` 不存意图);`saveAsDefault=true` 写 info_invoice 标记默认;载具/捐赠验真(checkBarCode/checkLoveCode)前置、不通过拦截下单。依赖 T049/T050/T052
+- [ ] T054 `OrderInvoiceService.validateInvoiceInput`/`buildIssueData`:放宽 B2C(允许无载具)、补「B2C 无载具→PrintFlag=Y 纸本」分支、个人 BuyerName 取 `InfoUser.phone`(`infoUserService` 已注入)
+- [ ] T055 `OrderInvoiceService.autoIssue(orderId)`:新增——读 pos_order 发票意图 + `assertInvoiceable`;不可开票/无意图跳过;组装 `ApplyInvoiceDto`(意图→字段);复用开票核心(buildIssueData+issue+落库),放宽归属/payStatus 校验。依赖 T054
+- [ ] T056 `PosOrderShOprateController.dispatchOrder`:`setState(2)` 后调 `orderInvoiceService.autoIssue(orderId)`。依赖 T055
+- [x] T057 货到付款/取消兜底:**核查后无需改代码**——商家取消仅允许 state∈{0,1}、用户取消仅 state=0,都在出餐(state→2)之前;而发票出餐时才开,故标准取消路径下发票尚未开、无发票可作废,挂 invalid 是死代码。货到付款"送达被拒收"走售后/退款流程(不经这两个 cancel 入口),作废 hook 留待该流程接入时再补,本期不做。
+- [x] T058 [P] `UserOrderController` 加 `GET /myInvoices`:JWT,按 userId 分页,`pos_order_invoice` JOIN `pos_order`,字段裁剪(不返回码图)。依赖 —
+- [x] T059 [P] 默认载具:**014 list 接口已返回 `isDefault`(T051 加的字段),客户端据此选常用载具/识别默认,无需新增后端接口**。
+- [x] T060 [P] i18n:结账页发票选项文案属客户端 UI(客户端团队);**后端无新增面向用户文字**,本期后端 i18n no-op。
+
+**Checkpoint**: 已完成订单(开发票门店)→ 出餐自动开出个人纸本(零信息)→ 码值落库 → 商家可打印;选手機條碜下单 → checkBarCode 验真 → 出餐自动开载具发票;会员 `/myInvoices` 看到发票。

+ 18 - 0
updatesql/sql.md

@@ -421,3 +421,21 @@ CREATE TABLE info_invoice (
   KEY idx_user (user_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户发票抬头(发票信息管理)';
 ```
+
+## 2026-07-27 订单自动开票:发票意图字段 + 抬头默认标记(010-order-invoice Phase 11)
+
+```sql
+-- ① pos_order 加发票意图列(下单 createOrder 时捕获,出餐 dispatchOrder 时自动开票消费)
+--    均可空;NULL 表示该单不开票(门店免用发票 / ezPay 未开通时 createOrder 不写意图)
+ALTER TABLE pos_order
+  ADD COLUMN invoice_choice      VARCHAR(16)  DEFAULT NULL COMMENT '发票选择 PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司',
+  ADD COLUMN carrier_type        VARCHAR(8)   DEFAULT NULL COMMENT '载具类型 0手机条码/1自然人凭证(仅PHONE_BARCODE/CITIZEN)',
+  ADD COLUMN carrier_num         VARCHAR(64)  DEFAULT NULL COMMENT '载具号码',
+  ADD COLUMN buyer_ubn           VARCHAR(16)  DEFAULT NULL COMMENT '公司统编(8位,仅COMPANY)',
+  ADD COLUMN invoice_buyer_name  VARCHAR(100) DEFAULT NULL COMMENT '公司名(发票买方名,仅COMPANY;个人发票买方名=InfoUser.phone运行时取不落此列)',
+  ADD COLUMN love_code           VARCHAR(10)  DEFAULT NULL COMMENT '捐赠码(3-7位,仅LOVE_CODE)';
+
+-- ② info_invoice 加"是否默认"标记(014 原"无默认"FR-006,现支持全联式"同意设置为默认载具/抬头")
+ALTER TABLE info_invoice
+  ADD COLUMN is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头 0否 1是(同一用户仅一条为1)';
+```

部分文件因文件數量過多而無法顯示