qmj 5 дней назад
Родитель
Сommit
5ce7831529
42 измененных файлов с 3433 добавлено и 1 удалено
  1. 42 0
      .claude/homunculus/observations.jsonl
  2. 3 0
      .specify/feature.json
  3. 2 1
      CLAUDE.md
  4. 60 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosOrderInvoiceController.java
  5. 44 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java
  6. 134 0
      ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreEzpayController.java
  7. 522 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java
  8. 23 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/PosOrderShOprateController.java
  9. 22 0
      ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java
  10. 5 0
      ruoyi-admin/src/main/resources/application.yml
  11. 106 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoice.java
  12. 4 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java
  13. 82 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreEzpay.java
  14. 44 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ApplyInvoiceDto.java
  15. 16 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/InvalidInvoiceDto.java
  16. 36 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreEzpayCredentialDto.java
  17. 94 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosOrderInvoiceVo.java
  18. 72 0
      ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreEzpayVo.java
  19. 26 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoiceMapper.java
  20. 32 0
      ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreEzpayMapper.java
  21. 48 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreEzpayService.java
  22. 169 0
      ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreEzpayServiceImpl.java
  23. 75 0
      ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoiceMapper.xml
  24. 86 0
      ruoyi-system/src/main/resources/mapper/chanting/PosStoreEzpayMapper.xml
  25. 1 0
      ruoyi-system/src/main/resources/mapper/chanting/PosStoreMapper.xml
  26. 36 0
      specs/009-ezpay-invoice-onboarding/checklists/requirements.md
  27. 85 0
      specs/009-ezpay-invoice-onboarding/contracts/api.md
  28. 96 0
      specs/009-ezpay-invoice-onboarding/data-model.md
  29. 106 0
      specs/009-ezpay-invoice-onboarding/plan.md
  30. 42 0
      specs/009-ezpay-invoice-onboarding/quickstart.md
  31. 47 0
      specs/009-ezpay-invoice-onboarding/research.md
  32. 141 0
      specs/009-ezpay-invoice-onboarding/spec.md
  33. 207 0
      specs/009-ezpay-invoice-onboarding/tasks.md
  34. 36 0
      specs/010-order-invoice/checklists/requirements.md
  35. 71 0
      specs/010-order-invoice/contracts/api.md
  36. 74 0
      specs/010-order-invoice/data-model.md
  37. 107 0
      specs/010-order-invoice/plan.md
  38. 59 0
      specs/010-order-invoice/quickstart.md
  39. 120 0
      specs/010-order-invoice/research.md
  40. 145 0
      specs/010-order-invoice/spec.md
  41. 212 0
      specs/010-order-invoice/tasks.md
  42. 101 0
      updatesql/sql.md

Разница между файлами не показана из-за своего большого размера
+ 42 - 0
.claude/homunculus/observations.jsonl


+ 3 - 0
.specify/feature.json

@@ -0,0 +1,3 @@
+{
+  "feature_directory": "specs/010-order-invoice"
+}

+ 2 - 1
CLAUDE.md

@@ -152,5 +152,6 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
 
 <!-- SPECKIT START -->
 For additional context about technologies to be used, project structure,
-shell commands, and other important information, read the current plan
+shell commands, and other important information, read the current plan:
+`specs/010-order-invoice/plan.md` (订单 ezPay 电子发票开立)
 <!-- SPECKIT END -->

+ 60 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosOrderInvoiceController.java

@@ -0,0 +1,60 @@
+package com.ruoyi.app.mendian;
+
+import com.ruoyi.app.order.OrderInvoiceService;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
+import com.ruoyi.system.domain.dto.InvalidInvoiceDto;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+
+/**
+ * 平台后台 / 商家端 - 订单电子发票管理(查看 / 重新开票 / 作废)。
+ *
+ * @author ruoyi
+ * @date 2026-06-16
+ */
+@RestController
+@RequestMapping("/system/orderInvoice")
+public class PosOrderInvoiceController extends BaseController {
+
+    @Autowired
+    private OrderInvoiceService orderInvoiceService;
+
+    /** 订单发票列表(分页 + 筛选)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(PosOrderInvoiceVo query) {
+        startPage();
+        return getDataTable(orderInvoiceService.list(query));
+    }
+
+    /** 订单发票详情。 */
+    @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:query')")
+    @GetMapping("/{orderId}")
+    public AjaxResult getInfo(@PathVariable Long orderId) {
+        return success(orderInvoiceService.detail(orderId));
+    }
+
+    /** 重新开票(仅 失败/作废 单)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:retry')")
+    @Log(title = "订单发票重新开票", businessType = BusinessType.UPDATE)
+    @PutMapping("/retry/{orderId}")
+    public AjaxResult retry(@PathVariable Long orderId) {
+        return toAjax(orderInvoiceService.retry(orderId));
+    }
+
+    /** 作废发票(仅 已开 单,调 ezPay invoice_invalid)。入参可选 {invalidReason}。 */
+    @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:invalid')")
+    @Log(title = "订单发票作废", businessType = BusinessType.UPDATE)
+    @PutMapping("/invalid/{orderId}")
+    public AjaxResult invalid(@PathVariable Long orderId, @RequestBody(required = false) InvalidInvoiceDto dto) {
+        String reason = dto == null ? null : dto.getInvalidReason();
+        return toAjax(orderInvoiceService.invalid(orderId, reason));
+    }
+}

+ 44 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreController.java

@@ -78,6 +78,8 @@ public class PosStoreController extends BaseController {
     private IOrderParentService orderParentService;
     @Autowired
     private IPosOrderService posOrderService;
+    @Autowired
+    private IPosStoreEzpayService posStoreEzpayService;
 
 
 
@@ -280,6 +282,48 @@ public class PosStoreController extends BaseController {
         }
     }
 
+    /**
+     * 商家端:上传门店统一编号(统编)。供运营向 ezPay 申请发票使用。
+     * 仅校验当前商家为该门店归属人;不改 ezPay 状态。
+     */
+    @Anonymous
+    @Auth
+    @PostMapping("/saveUbn")
+    public AjaxResult saveUbn(@RequestHeader String token, @RequestParam Long storeId, @RequestParam String ubn) {
+        if (!ownsStore(token, storeId)) {
+            return error("无权操作该门店");
+        }
+        return toAjax(posStoreEzpayService.uploadUbn(storeId, ubn));
+    }
+
+    /**
+     * 商家端:读取门店已保存的统一编号(编辑时回显)。
+     */
+    @Anonymous
+    @Auth
+    @GetMapping("/getUbn")
+    public AjaxResult getUbn(@RequestHeader String token, @RequestParam Long storeId) {
+        if (!ownsStore(token, storeId)) {
+            return error("无权操作该门店");
+        }
+        return success(posStoreEzpayService.getUbn(storeId));
+    }
+
+    /** 校验当前登录商家为该门店归属人(普通商家匹配 user_id;摊位主 type=4 匹配 storeId)。 */
+    private boolean ownsStore(String token, Long storeId) {
+        JwtUtil jwtUtil = new JwtUtil();
+        String id = jwtUtil.getusid(token);
+        InfoUser loginUser = infoUserService.selectInfoUserByUserId(Long.valueOf(id));
+        PosStore store = posStoreService.getById(storeId);
+        if (store == null) {
+            return false;
+        }
+        if ("4".equals(loginUser.getUserType())) {
+            return loginUser.getStoreId() != null && loginUser.getStoreId().equals(storeId);
+        }
+        return store.getUserId() != null && store.getUserId().equals(Long.valueOf(id));
+    }
+
     /**
      * 更新门店信息处理营业时间
      * @param input

+ 134 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreEzpayController.java

@@ -0,0 +1,134 @@
+package com.ruoyi.app.mendian;
+
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.app.utils.ezPay.EzPay;
+import com.ruoyi.app.utils.ezPay.EzPayConfig;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.system.domain.dto.StoreEzpayCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreEzpayVo;
+import com.ruoyi.system.service.IPosStoreEzpayService;
+import jakarta.validation.Valid;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 平台后台 - 商家 ezPay 发票开通管理。
+ *
+ * <p>ezPay 商店注册为线下人工;本接口负责申请状态推进、凭证录入与联网验证、
+ * 启用开关、免用发票标记。ezPay 联网验证(仅用只读 invoice_search)放在本 Controller 层,
+ * Service 仅做持久化,避免 ruoyi-system 反向依赖 ruoyi-admin 的 EzPay 工具类。
+ *
+ * @author ruoyi
+ * @date 2026-06-15
+ */
+@RestController
+@RequestMapping("/system/storeEzpay")
+public class PosStoreEzpayController extends BaseController {
+
+    @Autowired
+    private IPosStoreEzpayService posStoreEzpayService;
+
+    @Autowired
+    private EzPay ezPay;
+
+    /** ezPay 根地址(测试 cinv.ezpay.com.tw / 生产 inv.ezpay.com.tw),来自 application.yml */
+    @Value("${ezpay.base-url}")
+    private String ezpayBaseUrl;
+
+    /** ezPay 开通管理列表(分页 + 筛选 + 快捷过滤)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeEzpay:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(PosStoreEzpayVo query) {
+        startPage();
+        List<PosStoreEzpayVo> list = posStoreEzpayService.selectEzpayStoreList(query);
+        return getDataTable(list);
+    }
+
+    /** 门店 ezPay 详情。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeEzpay:query')")
+    @GetMapping("/{storeId}")
+    public AjaxResult getInfo(@PathVariable Long storeId) {
+        return success(posStoreEzpayService.selectEzpayStoreDetail(storeId));
+    }
+
+    /** 发起申请(未申请 0 → 申请中 1)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeEzpay:apply')")
+    @Log(title = "ezPay发起申请", businessType = BusinessType.UPDATE)
+    @PutMapping("/apply/{storeId}")
+    public AjaxResult apply(@PathVariable Long storeId) {
+        return toAjax(posStoreEzpayService.apply(storeId));
+    }
+
+    /**
+     * 录入 ezPay 凭证并验证:调 ezPay 只读 invoice_search 验证金钥,
+     * 回应含 KEY1xxxx 视为凭证无效(拒绝、状态不变),否则视为通过并标记已开通。
+     */
+    @PreAuthorize("@ss.hasPermi('chanting:storeEzpay:saveCredentials')")
+    @Log(title = "ezPay录入凭证", businessType = BusinessType.UPDATE)
+    @PutMapping("/saveCredentials")
+    public AjaxResult saveCredentials(@RequestBody @Valid StoreEzpayCredentialDto dto) {
+        EzPayConfig cfg = new EzPayConfig(dto.getMerchantId(), dto.getHashKey(), dto.getHashIv());
+        Map<String, Object> postData = new LinkedHashMap<>();
+        postData.put("RespondType", "JSON");
+        postData.put("Version", "1.3");
+        postData.put("SearchType", "0");
+        postData.put("InvoiceNumber", "AA12345678"); // 测试用假发票号,只用于验证金钥
+        postData.put("RandomNum", "1234");
+
+        String respStr;
+        try {
+            JSONObject resp = ezPay.doPost(ezpayBaseUrl + EzPay.URL_SEARCH, cfg, postData);
+            respStr = resp == null ? "" : resp.toJSONString();
+        } catch (Exception e) {
+            posStoreEzpayService.recordVerifyResult(dto.getStoreId(), "ERROR: " + msg(e));
+            return error("ezPay 验证服务暂不可用,请稍后重试");
+        }
+
+        if (respStr.contains("KEY1")) {
+            posStoreEzpayService.recordVerifyResult(dto.getStoreId(), "FAIL: " + respStr);
+            return error("ezPay 凭证无效,请检查商店代号 / HashKey / HashIV");
+        }
+
+        posStoreEzpayService.enableWithCredentials(dto);
+        return success("凭证验证通过,已开通");
+    }
+
+    /** 停用 / 恢复(仅已开通门店)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeEzpay:toggleEnable')")
+    @Log(title = "ezPay停用恢复", businessType = BusinessType.UPDATE)
+    @PutMapping("/toggleEnable/{storeId}")
+    public AjaxResult toggleEnable(@PathVariable Long storeId) {
+        return toAjax(posStoreEzpayService.toggleEnable(storeId));
+    }
+
+    /** 标记免用发票 / 恢复需开票。入参 {invoiceExempt: 0|1}。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeEzpay:markExempt')")
+    @Log(title = "ezPay免用发票标记", businessType = BusinessType.UPDATE)
+    @PutMapping("/markExempt/{storeId}")
+    public AjaxResult markExempt(@PathVariable Long storeId, @RequestBody Map<String, Integer> body) {
+        Integer invoiceExempt = body == null ? null : body.get("invoiceExempt");
+        return toAjax(posStoreEzpayService.markExempt(storeId, invoiceExempt));
+    }
+
+    /** 重置状态(凭证作废/重新申请)。 */
+    @PreAuthorize("@ss.hasPermi('chanting:storeEzpay:query')")
+    @Log(title = "ezPay重置状态", businessType = BusinessType.UPDATE)
+    @PutMapping("/reset/{storeId}")
+    public AjaxResult reset(@PathVariable Long storeId) {
+        return toAjax(posStoreEzpayService.reset(storeId));
+    }
+
+    private static String msg(Throwable e) {
+        return e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+    }
+}

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

@@ -0,0 +1,522 @@
+package com.ruoyi.app.order;
+
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.app.utils.ezPay.EzPay;
+import com.ruoyi.app.utils.ezPay.EzPayConfig;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.system.domain.PosOrder;
+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.dto.ApplyInvoiceDto;
+import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
+import com.ruoyi.system.mapper.PosOrderInvoiceMapper;
+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 org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 订单电子发票 开票 / 作废 / 查询 业务(放 admin,因需调用 {@link EzPay})。
+ *
+ * <p>开票链路({@link #applyInvoice}):校验订单归属/状态/支付 → 校验门店可开票(009 凭证 + 免用发票)
+ * → 金额拆分(不含运费)→ 组装 ezPay issue 参数 + 逐商品明细 → 调 {@link EzPay#issueInvoice}
+ * → 判读回应 → 落库。作废走 {@link EzPay#doPost} + invoice_invalid。
+ *
+ * @author ruoyi
+ * @date 2026-06-16
+ */
+@Service
+public class OrderInvoiceService {
+
+    @Autowired
+    private EzPay ezPay;
+
+    @Autowired
+    private PosOrderMapper posOrderMapper;
+
+    @Autowired
+    private PosStoreMapper posStoreMapper;
+
+    @Autowired
+    private PosStoreEzpayMapper posStoreEzpayMapper;
+
+    @Autowired
+    private PosOrderInvoiceMapper posOrderInvoiceMapper;
+    @Autowired
+    private IInfoUserService infoUserService;
+
+    /** ezPay 接口根地址(从 application.yml 的 ezpay.base-url 注入,测试/正式在此切换) */
+    @Value("${ezpay.base-url}")
+    private String ezpayBaseUrl;
+
+    /** 发票状态:0未开/1已开/2失败/3作废 */
+    private static final int STATUS_NOT_ISSUED = 0;
+    private static final int STATUS_ISSUED = 1;
+    private static final int STATUS_FAILED = 2;
+    private static final int STATUS_INVALID = 3;
+
+    /** 订单完成态、已支付 */
+    private static final long ORDER_STATE_DONE = 3L;
+    private static final long ORDER_PAID = 1L;
+
+    // ==================== US1/US2/US3:客户开票 ====================
+
+    /**
+     * 客户申请开票。校验 → 金额拆分 → 调 ezPay 即时开立 → 落库。
+     * B2C/B2B/载具由 {@link #buildIssueData} 按 {@code category}/{@code carrierType} 分支处理。
+     */
+    public PosOrderInvoiceVo applyInvoice(ApplyInvoiceDto dto, Long currentUserId) {
+        Long orderId = dto.getOrderId();
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
+        if (order == null) {
+            throw new ServiceException("订单不存在");
+        }
+        if (!Objects.equals(order.getUserId(), currentUserId)) {
+            throw new ServiceException("无权操作该订单");
+        }
+        if (order.getState() == null || order.getState() != ORDER_STATE_DONE) {
+            throw new ServiceException("订单未完成,暂不可开票");
+        }
+        if (order.getPayStatus() == null || order.getPayStatus() != ORDER_PAID) {
+            throw new ServiceException("订单未支付,暂不可开票");
+        }
+        Long storeId = order.getMdId();
+        PosStoreEzpay ez = assertInvoiceable(storeId);
+
+        // 场景入参校验(B2B 统编 / B2C 邮箱 / 载具号码)
+        validateInvoiceInput(dto);
+
+        // 防重复开票
+        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
+                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+        if (row != null && Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
+            throw new ServiceException("该订单已开票,不可重复开票");
+        }
+
+        // 金额拆分(不含运费:invoiceTotal = amount - freight)
+        int amount = order.getAmount() == null ? 0 : order.getAmount();
+        int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
+        int invoiceTotal = amount - freight;
+        if (invoiceTotal <= 0) {
+            throw new ServiceException("订单可开票金额异常,无法开票");
+        }
+        int sales = (int) Math.round(invoiceTotal / 1.05);
+        int tax = invoiceTotal - sales;
+
+        // 组装 ezPay issue 参数 + 调用
+        Map<String, Object> inv = buildIssueData(dto, order, invoiceTotal, sales, tax);
+        EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
+
+        int newStatus;
+        String invoiceNumber = null, randomNum = null, invoiceUrl = null, failReason = null;
+        try {
+            JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv);            String status = resp == null ? "" : resp.getString("Status");
+            JSONObject result = resp == null ? null : resp.getJSONObject("Result");
+            if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) {
+                invoiceNumber = result.getString("InvoiceNumber");
+                randomNum = result.getString("RandomNum");
+                invoiceUrl = result.getString("InvoiceTransNo");
+                newStatus = STATUS_ISSUED;
+            } else {
+                failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message"));
+                newStatus = STATUS_FAILED;
+            }
+        } catch (Exception e) {
+            failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
+            newStatus = STATUS_FAILED;
+        }
+
+        // 落库(uk_order_id 兜底防并发重复)
+        Date now = new Date();
+        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.setTotalAmt(invoiceTotal);
+        saveRow.setSalesAmt(sales);
+        saveRow.setTaxAmt(tax);
+        saveRow.setApplyTime(now);
+        if (newStatus == STATUS_ISSUED) {
+            saveRow.setInvoiceNumber(invoiceNumber);
+            saveRow.setRandomNum(randomNum);
+            saveRow.setInvoiceUrl(invoiceUrl);
+            saveRow.setIssueTime(now);
+            saveRow.setFailReason(null);
+        } else {
+            saveRow.setFailReason(StrUtil.sub(failReason, 0, 480));
+        }
+        saveRow.setInvoiceStatus(newStatus);
+        try {
+            saveOrUpdate(saveRow);
+        } catch (DuplicateKeyException dup) {
+            throw new ServiceException("请勿重复提交开票申请");
+        }
+
+        return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
+    }
+
+    /** 客户查询订单发票;门店不可开票时 invoiceStatus=null(客户端据此隐藏入口)。 */
+    public PosOrderInvoiceVo getInvoice(Long orderId, Long currentUserId) {
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
+        if (order == null || !Objects.equals(order.getUserId(), currentUserId)) {
+            throw new ServiceException("订单不存在");
+        }
+        boolean can = canInvoice(order.getMdId());
+        PosOrderInvoiceVo vo = posOrderInvoiceMapper.selectInvoiceDetail(orderId);
+        if (vo == null) {
+            vo = new PosOrderInvoiceVo();
+            vo.setOrderId(orderId);
+            vo.setOrderNo(order.getDdId());
+            vo.setInvoiceStatus(can ? STATUS_NOT_ISSUED : null);
+        } else if (!can) {
+            vo.setInvoiceStatus(null);
+        }
+        return vo;
+    }
+
+    /** 商家端查询订单发票(校验商家为该订单门店归属人)。 */
+    public PosOrderInvoiceVo getInvoiceForMerchant(Long orderId, Long merchantUserId) {
+        requireOwnedOrder(orderId, merchantUserId);
+        return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
+    }
+
+    /** 商家端作废发票(校验归属后复用作废逻辑)。 */
+    public int invalidForMerchant(Long orderId, String reason, Long merchantUserId) {
+        requireOwnedOrder(orderId, merchantUserId);
+        return invalid(orderId, reason);
+    }
+
+    /** 校验订单存在且属于该商家门店,返回订单;否则抛异常。 */
+    private PosOrder requireOwnedOrder(Long orderId, Long merchantUserId) {
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
+        if (order == null) {
+            throw new ServiceException("订单不存在");
+        }
+        InfoUser user = infoUserService.getOne(
+                new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, merchantUserId));
+        if (user == null) {
+            throw new ServiceException("用户不存在");
+        }
+        String ut = user.getUserType();
+        boolean own;
+        if ("1".equals(ut) || "3".equals(ut)) {
+            // 普通/夜市商家:订单 shId 即商家 userId
+            own = merchantUserId.equals(order.getShId());
+        } else {
+            // 摊位商家:订单门店 mdId 即商家 storeId
+            own = user.getStoreId() != null && user.getStoreId().equals(order.getMdId());
+        }
+        if (!own) {
+            throw new ServiceException("无权操作该订单发票");
+        }
+        return order;
+    }
+
+    // ==================== US4/US5:管理端 ====================
+
+    /** 管理端:订单发票列表(配合 PageHelper 分页)。 */
+    public List<PosOrderInvoiceVo> list(PosOrderInvoiceVo query) {
+        if (query != null && query.getDateRange() != null && query.getDateRange().length == 2) {
+            query.setCretimStart(query.getDateRange()[0]);
+            query.setCretimEnd(query.getDateRange()[1]);
+        }
+        return posOrderInvoiceMapper.selectInvoiceList(query);
+    }
+
+    /** 管理端:订单发票详情。 */
+    public PosOrderInvoiceVo detail(Long orderId) {
+        return posOrderInvoiceMapper.selectInvoiceDetail(orderId);
+    }
+
+    /** 管理端:重新开票(仅 失败/作废 单;运营身份不校验客户归属)。 */
+    public int retry(Long orderId) {
+        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
+                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+        if (row == null) {
+            throw new ServiceException("发票记录不存在");
+        }
+        int st = row.getInvoiceStatus() == null ? STATUS_NOT_ISSUED : row.getInvoiceStatus();
+        if (st != STATUS_FAILED && st != STATUS_INVALID) {
+            throw new ServiceException("仅失败或作废的发票可重新开票");
+        }
+        // 重置为未开,复用客户开票参数重开(运营身份 currentUserId 传 null 跳过归属校验)
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
+        if (order == null) {
+            throw new ServiceException("订单不存在");
+        }
+        PosStoreEzpay ez = assertInvoiceable(order.getMdId());
+        ApplyInvoiceDto dto = new ApplyInvoiceDto();
+        dto.setOrderId(orderId);
+        dto.setCategory(row.getInvoiceCategory());
+        dto.setBuyerName(row.getBuyerName());
+        dto.setBuyerUbn(row.getBuyerUbn());
+        dto.setBuyerEmail(row.getBuyerEmail());
+        dto.setCarrierType(row.getCarrierType());
+        dto.setCarrierNum(row.getCarrierNum());
+        // 复用核心开票(不含归属校验):直接走 issue + 落库
+        return reIssueAndSave(order, row, dto, ez);
+    }
+
+    /** 管理端:作废发票(仅 已开 单,调 ezPay invoice_invalid)。 */
+    public int invalid(Long orderId, String reason) {
+        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
+                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+        if (row == null || !Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
+            throw new ServiceException("仅已开发票可作废");
+        }
+        PosOrder order = posOrderMapper.selectPosOrderById(orderId);
+        PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, order.getMdId()));
+        if (ez == null) {
+            throw new ServiceException("门店 ezPay 凭证缺失,无法作废");
+        }
+
+        Map<String, Object> postData = new LinkedHashMap<>();
+        postData.put("RespondType", "JSON");
+        postData.put("Version", "1.0");
+        postData.put("InvoiceNumber", row.getInvoiceNumber());
+        postData.put("InvalidReason", StrUtil.isBlank(reason) ? "商家作废" : reason);
+
+        try {
+            EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
+            JSONObject resp = ezPay.doPost(ezpayBaseUrl + EzPay.URL_INVALID, cfg, postData);            String status = resp == null ? "" : resp.getString("Status");
+            if ("SUCCESS".equals(status)) {
+                row.setInvoiceStatus(STATUS_INVALID);
+                row.setInvalidTime(new Date());
+                saveOrUpdate(row);
+                return 1;
+            }
+            throw new ServiceException("作废失败:" + (resp == null ? "ezPay 无回应" : resp.getString("Message")));
+        } catch (ServiceException se) {
+            throw se;
+        } catch (Exception e) {
+            throw new ServiceException("ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
+        }
+    }
+
+    // ==================== 内部辅助 ====================
+
+    /** 复用开票核心(运营重开,无归属校验)。 */
+    private int reIssueAndSave(PosOrder order, PosOrderInvoice row, ApplyInvoiceDto dto, PosStoreEzpay ez) {
+        int amount = order.getAmount() == null ? 0 : order.getAmount();
+        int freight = (int) Math.round(order.getFreight() == null ? 0d : order.getFreight());
+        int invoiceTotal = amount - freight;
+        int sales = (int) Math.round(invoiceTotal / 1.05);
+        int tax = invoiceTotal - sales;
+
+        Map<String, Object> inv = buildIssueData(dto, order, invoiceTotal, sales, tax);
+        EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
+
+        int newStatus;
+        String invoiceNumber = null, randomNum = null, invoiceUrl = null, failReason = null;
+        try {
+            JSONObject resp = ezPay.issueInvoice(ezpayBaseUrl, cfg, inv);
+            String status = resp == null ? "" : resp.getString("Status");
+            JSONObject result = resp == null ? null : resp.getJSONObject("Result");
+            if ("SUCCESS".equals(status) && result != null && StrUtil.isNotBlank(result.getString("InvoiceNumber"))) {
+                invoiceNumber = result.getString("InvoiceNumber");
+                randomNum = result.getString("RandomNum");
+                invoiceUrl = result.getString("InvoiceTransNo");
+                newStatus = STATUS_ISSUED;
+            } else {
+                failReason = resp == null ? "ezPay 无回应" : (status + ":" + resp.getString("Message"));
+                newStatus = STATUS_FAILED;
+            }
+        } catch (Exception e) {
+            failReason = "ezPay 服务暂不可用:" + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage());
+            newStatus = STATUS_FAILED;
+        }
+
+        Date now = new Date();
+        if (newStatus == STATUS_ISSUED) {
+            row.setInvoiceNumber(invoiceNumber);
+            row.setRandomNum(randomNum);
+            row.setInvoiceUrl(invoiceUrl);
+            row.setIssueTime(now);
+            row.setFailReason(null);
+        } else {
+            row.setFailReason(StrUtil.sub(failReason, 0, 480));
+        }
+        row.setInvoiceStatus(newStatus);
+        saveOrUpdate(row);
+        return newStatus == STATUS_ISSUED ? 1 : 0;
+    }
+
+    /** 校验门店可开票并返回凭证;不可开票抛异常。 */
+    private PosStoreEzpay assertInvoiceable(Long storeId) {
+        if (storeId == null) {
+            throw new ServiceException("订单未关联门店,暂不可开票");
+        }
+        PosStore store = posStoreMapper.selectPosStoreById(storeId);
+        if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) {
+            throw new ServiceException("该门店免用发票,无需开票");
+        }
+        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())) {
+            throw new ServiceException("该门店暂不支持电子发票");
+        }
+        return ez;
+    }
+
+    /** 门店是否可开票(不抛异常)。 */
+    private boolean canInvoice(Long storeId) {
+        if (storeId == null) {
+            return false;
+        }
+        PosStore store = posStoreMapper.selectPosStoreById(storeId);
+        if (store != null && Integer.valueOf(1).equals(store.getInvoiceExempt())) {
+            return false;
+        }
+        PosStoreEzpay ez = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
+        return ez != null && Integer.valueOf(2).equals(ez.getEzpayStatus())
+                && Integer.valueOf(1).equals(ez.getIsEnabled());
+    }
+
+    /** 场景入参校验(service 内强校验,拦截非法请求不调 ezPay)。 */
+    private void validateInvoiceInput(ApplyInvoiceDto dto) {
+        if ("B2B".equals(dto.getCategory())) {
+            if (StrUtil.isBlank(dto.getBuyerUbn()) || !dto.getBuyerUbn().matches("\\d{8}")) {
+                throw new ServiceException("统编格式不正确,须为 8 位数字");
+            }
+        }
+        if ("B2C".equals(dto.getCategory()) && StrUtil.isBlank(dto.getCarrierType()) && StrUtil.isBlank(dto.getBuyerEmail())) {
+            throw new ServiceException("接收邮箱不能为空");
+        }
+        if (StrUtil.isNotBlank(dto.getCarrierType()) && StrUtil.isBlank(dto.getCarrierNum())) {
+            throw new ServiceException("载具号码不能为空");
+        }
+    }
+
+    /** 组装 ezPay invoice_issue 业务参数(Category/PrintFlag/Buyer/Amt/明细)。 */
+    private Map<String, Object> buildIssueData(ApplyInvoiceDto dto, PosOrder order,
+                                               int invoiceTotal, int sales, int tax) {
+        Map<String, Object> inv = new LinkedHashMap<>();
+        inv.put("MerchantOrderNo", order.getDdId());
+        inv.put("Category", dto.getCategory());
+        inv.put("BuyerName", dto.getBuyerName());
+        if ("B2B".equals(dto.getCategory())) {
+            inv.put("BuyerUBN", dto.getBuyerUbn());
+            inv.put("PrintFlag", "Y");
+        } else if (StrUtil.isNotBlank(dto.getCarrierType())) {
+            inv.put("CarrierType", dto.getCarrierType());
+            inv.put("CarrierNum", dto.getCarrierNum());
+            inv.put("PrintFlag", "N");
+        } else {
+            inv.put("BuyerEmail", dto.getBuyerEmail());
+            inv.put("PrintFlag", "Y");
+        }
+        inv.put("TaxType", "1");
+        inv.put("TaxRate", "5");
+        inv.put("Amt", String.valueOf(sales));
+        inv.put("TaxAmt", String.valueOf(tax));
+        inv.put("TotalAmt", String.valueOf(invoiceTotal));
+        appendItems(inv, order.getFood(), invoiceTotal);
+        return inv;
+    }
+
+    /**
+     * 逐商品明细(research D2/D3):从 food JSON 解析,优惠按比例分摊到单价(方案 B)。
+     * 每行满足 ItemCount × ItemPrice = ItemAmt(单价决定金额,ezPay 逐商品校验通过)。
+     */
+    private void appendItems(Map<String, Object> inv, String foodJson, int invoiceTotal) {
+        JSONArray arr = StrUtil.isBlank(foodJson) ? null : com.alibaba.fastjson.JSON.parseArray(foodJson);
+        if (arr == null || arr.isEmpty()) {
+            inv.put("ItemName", "餐点");
+            inv.put("ItemCount", "1");
+            inv.put("ItemUnit", "份");
+            inv.put("ItemPrice", String.valueOf(invoiceTotal));
+            inv.put("ItemAmt", String.valueOf(invoiceTotal));
+            return;
+        }
+        int foodTotal = foodOriginalTotal(arr);
+        double ratio = foodTotal > 0 ? (double) invoiceTotal / foodTotal : 1.0;
+        StringBuilder name = new StringBuilder(), count = new StringBuilder(),
+                unit = new StringBuilder(), price = new StringBuilder(), amt = new StringBuilder();
+        for (int i = 0; i < arr.size(); i++) {
+            com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i);
+            int p = it.getIntValue("price") + it.getIntValue("otherPrice");
+            int c = it.getIntValue("number");
+            if (c <= 0) {
+                c = 1;
+            }
+            int itemPrice = (int) Math.round(p * ratio);
+            int itemAmt = itemPrice * c;
+            if (i > 0) {
+                name.append("|"); count.append("|"); unit.append("|"); price.append("|"); amt.append("|");
+            }
+            String nm = it.getString("name");
+            name.append(StrUtil.isBlank(nm) ? "餐点" : nm);
+            count.append(c);
+            unit.append("個");
+            price.append(itemPrice);
+            amt.append(itemAmt);
+        }
+        inv.put("ItemName", name.toString());
+        inv.put("ItemCount", count.toString());
+        inv.put("ItemUnit", unit.toString());
+        inv.put("ItemPrice", price.toString());
+        inv.put("ItemAmt", amt.toString());
+    }
+
+    /** food 商品原价总额(含税)= Σ(price + otherPrice) × number。 */
+    private int foodOriginalTotal(JSONArray arr) {
+        int sum = 0;
+        for (int i = 0; i < arr.size(); i++) {
+            com.alibaba.fastjson.JSONObject it = arr.getJSONObject(i);
+            int p = it.getIntValue("price") + it.getIntValue("otherPrice");
+            int c = it.getIntValue("number");
+            sum += p * (c <= 0 ? 1 : c);
+        }
+        return sum;
+    }
+
+    /** 新增或更新(显式设置时间与人)。 */
+    private void saveOrUpdate(PosOrderInvoice row) {
+        Date now = new Date();
+        String user = currentUser();
+        if (row.getId() == null) {
+            row.setCreateTime(now);
+            row.setUpdateTime(now);
+            row.setCreateBy(user);
+            row.setUpdateBy(user);
+            posOrderInvoiceMapper.insert(row);
+        } else {
+            row.setUpdateTime(now);
+            row.setUpdateBy(user);
+            posOrderInvoiceMapper.updateById(row);
+        }
+    }
+
+    private String currentUser() {
+        try {
+            return SecurityUtils.getUsername();
+        } catch (Exception e) {
+            return "system";
+        }
+    }
+}

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

@@ -22,6 +22,7 @@ import com.ruoyi.system.domain.*;
 import com.ruoyi.system.service.*;
 import com.ruoyi.system.utils.Auth;
 import com.ruoyi.system.utils.JwtUtil;
+import com.ruoyi.system.domain.dto.InvalidInvoiceDto;
 import com.ruoyi.system.utils.OrderLogHelper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
@@ -56,6 +57,28 @@ public class PosOrderShOprateController extends BaseController {
     private IOrderParentService orderParentService;
     @Autowired
     private OrderService orderService;
+    @Autowired
+    private OrderInvoiceService orderInvoiceService;
+
+    /**
+     * 商家端查询订单发票(状态/发票号/凭证)
+     */
+    @GetMapping("/getInvoice")
+    public AjaxResult getInvoice(@RequestHeader String token, @RequestParam Long orderId) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(orderInvoiceService.getInvoiceForMerchant(orderId, userId));
+    }
+
+    /**
+     * 商家端作废订单发票(仅已开,调 ezPay invoice_invalid)。入参可选 {invalidReason}
+     */
+    @PutMapping("/invalidInvoice/{orderId}")
+    public AjaxResult invalidInvoice(@RequestHeader String token, @PathVariable Long orderId,
+                                     @RequestBody(required = false) InvalidInvoiceDto dto) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        String reason = dto == null ? null : dto.getInvalidReason();
+        return toAjax(orderInvoiceService.invalidForMerchant(orderId, reason, userId));
+    }
 
     /**
      * 商家端下单

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

@@ -27,6 +27,8 @@ import com.ruoyi.system.utils.GetArea;
 import com.ruoyi.system.utils.JwtUtil;
 import com.ruoyi.system.utils.OrderLogHelper;
 import com.ruoyi.system.dto.PromotionCalcResponse;
+import com.ruoyi.system.domain.dto.ApplyInvoiceDto;
+import jakarta.validation.Valid;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
@@ -69,6 +71,26 @@ public class UserOrderController extends BaseController {
     private OrderPromotionHelper orderPromotionHelper;
     @Autowired
     private PosOrderPromotionMapper posOrderPromotionMapper;
+    @Autowired
+    private OrderInvoiceService orderInvoiceService;
+
+    /**
+     * 申请电子发票(订单完成后,客户主动申请:B2C 邮箱 / B2B 统编 / 载具)
+     */
+    @PostMapping("/applyInvoice")
+    public AjaxResult applyInvoice(@RequestHeader String token, @RequestBody @Valid ApplyInvoiceDto dto) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(orderInvoiceService.applyInvoice(dto, userId));
+    }
+
+    /**
+     * 查询订单电子发票(状态 / 发票号 / 凭证);门店不可开票时 invoiceStatus=null
+     */
+    @GetMapping("/getInvoice")
+    public AjaxResult getInvoice(@RequestHeader String token, @RequestParam Long orderId) {
+        Long userId = Long.valueOf(new JwtUtil().getusid(token));
+        return AjaxResult.success(orderInvoiceService.getInvoice(orderId, userId));
+    }
 
     /**
      * 创建订单

+ 5 - 0
ruoyi-admin/src/main/resources/application.yml

@@ -17,6 +17,11 @@ ruoyi:
   # 验证码类型 math 数组计算 char 字符验证
   captchaType: math
 
+# ezPay 电子发票配置
+ezpay:
+  # 开票/作废根地址(测试环境 cinv.ezpay.com.tw;上线改 https://inv.ezpay.com.tw)
+  base-url: https://cinv.ezpay.com.tw
+
 # 开发环境配置
 server:
   # 服务器的HTTP端口,默认为8080

+ 106 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoice.java

@@ -0,0 +1,106 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 订单电子发票 pos_order_invoice(与 pos_order 1:1)。
+ *
+ * <p>记录一笔订单的 ezPay 电子发票:类型/买方/载具/发票号/状态/金额等。
+ * 一笔订单至多一行当前发票(uk_order_id);作废后状态置 3,允许重开(同行覆盖)。
+ *
+ * @author ruoyi
+ * @date 2026-06-16
+ */
+@Data
+@TableName(value = "pos_order_invoice")
+public class PosOrderInvoice {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 pos_order.id */
+    private Long orderId;
+
+    /** 冗余 pos_order.dd_id(订单号,便于排查) */
+    private String orderNo;
+
+    /** 冗余 pos_store.id(门店) */
+    private Long storeId;
+
+    /** 发票类型 B2C/B2B */
+    private String invoiceCategory;
+
+    /** 买方名称(B2C 客户名 / B2B 公司名) */
+    private String buyerName;
+
+    /** 买方统一编号(统编 8 码,B2B 必填) */
+    private String buyerUbn;
+
+    /** 接收邮箱(B2C 无载具时用) */
+    private String buyerEmail;
+
+    /** 载具类型 0手机条码/1自然人凭证/2ezPay会员 */
+    private String carrierType;
+
+    /** 载具号码 */
+    private String carrierNum;
+
+    /** ezPay 发票号(开立成功后填) */
+    private String invoiceNumber;
+
+    /** 防伪随机码(ezPay 返回) */
+    private String randomNum;
+
+    /** 0未开/1已开/2失败/3作废 */
+    private Integer invoiceStatus;
+
+    /** 含税总额(= amount - freight) */
+    private Integer totalAmt;
+
+    /** 销售额(未税) */
+    private Integer salesAmt;
+
+    /** 税额 */
+    private Integer taxAmt;
+
+    /** 失败原因 */
+    private String failReason;
+
+    /** 发票查看凭证 / 链接 */
+    private String invoiceUrl;
+
+    /** 申请开票时间 */
+    private Date applyTime;
+
+    /** 开立成功时间 */
+    private Date issueTime;
+
+    /** 作废时间 */
+    private Date invalidTime;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+
+    /** 更新时间 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+
+    /** 创建人 */
+    @TableField(fill = FieldFill.INSERT)
+    private String createBy;
+
+    /** 更新人 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private String updateBy;
+}

+ 4 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java

@@ -145,6 +145,10 @@ public class PosStore
     @Excel(name = "夜市id")
     private Long nightMarketId;
 
+    /** 是否免用发票:0需开票/1免用发票 */
+    @Excel(name = "是否免用发票", readConverterExp = "0=需开票,1=免用发票")
+    private Integer invoiceExempt;
+
     /** 用户名 */
     @Excel(name = "用户名")
     private transient String userName;

+ 82 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreEzpay.java

@@ -0,0 +1,82 @@
+package com.ruoyi.system.domain;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 门店 ezPay 发票开通配置 pos_store_ezpay
+ *
+ * <p>与 pos_store 1:1,记录该门店的 ezPay 申请状态、启用开关、凭证、统编等。
+ * 门店无此行即视为「未申请」(status=0)。
+ *
+ * @author ruoyi
+ * @date 2026-06-15
+ */
+@Data
+@TableName(value = "pos_store_ezpay")
+public class PosStoreEzpay {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键 */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 pos_store.id(门店) */
+    private Long storeId;
+
+    /** 申请状态:0未申请/1申请中/2已开通 */
+    private Integer ezpayStatus;
+
+    /** 启用开关:0停用/1启用(仅 status=2 有意义) */
+    private Integer isEnabled;
+
+    /** ezPay 商店代号 MerchantID_(开票/查询接口用) */
+    private String merchantId;
+
+    /** ezPay HashKey(固定 32 字节) */
+    private String hashKey;
+
+    /** ezPay HashIV(固定 16 字节) */
+    private String hashIv;
+
+    /** ezPay 会员编号 CompanyID_(字轨接口用,可空) */
+    private String companyId;
+
+    /** 统一编号(统编,商家上传) */
+    private String ubn;
+
+    /** 提交申请时间(0→1) */
+    private Date applyTime;
+
+    /** 开通时间(→2) */
+    private Date approvedTime;
+
+    /** 最近一次凭证验证结果 */
+    private String lastVerifyResult;
+
+    /** 备注 */
+    private String remark;
+
+    /** 创建时间 */
+    @TableField(fill = FieldFill.INSERT)
+    private Date createTime;
+
+    /** 更新时间 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private Date updateTime;
+
+    /** 创建人 */
+    @TableField(fill = FieldFill.INSERT)
+    private String createBy;
+
+    /** 更新人 */
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private String updateBy;
+}

+ 44 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ApplyInvoiceDto.java

@@ -0,0 +1,44 @@
+package com.ruoyi.system.domain.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+/**
+ * 客户申请开票入参。
+ *
+ * @author ruoyi
+ * @date 2026-06-16
+ */
+@Data
+public class ApplyInvoiceDto {
+
+    /** 订单 id(须为当前登录客户的订单、state=3、payStatus=1) */
+    @NotNull(message = "订单不能为空")
+    private Long orderId;
+
+    /** 发票类型 B2C/B2B */
+    @NotBlank(message = "发票类型不能为空")
+    @Pattern(regexp = "B2C|B2B", message = "发票类型只能为 B2C 或 B2B")
+    private String category;
+
+    /** 买方名称(B2C 客户名 / B2B 公司名) */
+    @NotBlank(message = "买方名称不能为空")
+    private String buyerName;
+
+    /** 买方统一编号(统编 8 码;B2B 必填,service 内强校验;B2C 不传) */
+    @Pattern(regexp = "\\d{8}", message = "统一编号必须为 8 位数字")
+    private String buyerUbn;
+
+    /** 接收邮箱(B2C 无载具时必填,service 内校验) */
+    @Email(message = "邮箱格式不正确")
+    private String buyerEmail;
+
+    /** 载具类型 0手机条码/1自然人凭证/2ezPay会员(可空) */
+    private String carrierType;
+
+    /** 载具号码(carrierType 非空时必填,service 内校验) */
+    private String carrierNum;
+}

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

@@ -0,0 +1,16 @@
+package com.ruoyi.system.domain.dto;
+
+import lombok.Data;
+
+/**
+ * 作废发票入参。
+ *
+ * @author ruoyi
+ * @date 2026-06-16
+ */
+@Data
+public class InvalidInvoiceDto {
+
+    /** 作废原因(可空,默认「商家作废」) */
+    private String invalidReason;
+}

+ 36 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/StoreEzpayCredentialDto.java

@@ -0,0 +1,36 @@
+package com.ruoyi.system.domain.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 运营录入 ezPay 凭证入参。
+ *
+ * @author ruoyi
+ * @date 2026-06-15
+ */
+@Data
+public class StoreEzpayCredentialDto {
+
+    /** 门店 id */
+    @NotBlank(message = "门店不能为空")
+    private Long storeId;
+
+    /** ezPay 商店代号 MerchantID_(开票/查询用) */
+    @NotBlank(message = "商店代号不能为空")
+    private String merchantId;
+
+    /** ezPay HashKey(固定 32 字节) */
+    @NotBlank(message = "HashKey 不能为空")
+    @Size(min = 32, max = 32, message = "HashKey 必须为 32 位")
+    private String hashKey;
+
+    /** ezPay HashIV(固定 16 字节) */
+    @NotBlank(message = "HashIV 不能为空")
+    @Size(min = 16, max = 16, message = "HashIV 必须为 16 位")
+    private String hashIv;
+
+    /** ezPay 会员编号 CompanyID_(字轨用,可空) */
+    private String companyId;
+}

+ 94 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosOrderInvoiceVo.java

@@ -0,0 +1,94 @@
+package com.ruoyi.system.domain.vo;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 订单发票列表/详情 VO(pos_order_invoice LEFT JOIN pos_store)。
+ *
+ * @author ruoyi
+ * @date 2026-06-16
+ */
+@Data
+public class PosOrderInvoiceVo {
+
+    /** 订单发票 id */
+    private Long id;
+
+    /** 订单 id */
+    private Long orderId;
+
+    /** 订单号 */
+    private String orderNo;
+
+    /** 门店 id */
+    private Long storeId;
+
+    /** 门店名称 */
+    private String posName;
+
+    /** 发票类型 B2C/B2B */
+    private String invoiceCategory;
+
+    /** 买方名称 */
+    private String buyerName;
+
+    /** 买方统编 */
+    private String buyerUbn;
+
+    /** 接收邮箱 */
+    private String buyerEmail;
+
+    /** 载具类型 */
+    private String carrierType;
+
+    /** 载具号码 */
+    private String carrierNum;
+
+    /** 发票号 */
+    private String invoiceNumber;
+
+    /** 防伪随机码 */
+    private String randomNum;
+
+    /** 0未开/1已开/2失败/3作废 */
+    private Integer invoiceStatus;
+
+    /** 含税总额 */
+    private Integer totalAmt;
+
+    /** 销售额 */
+    private Integer salesAmt;
+
+    /** 税额 */
+    private Integer taxAmt;
+
+    /** 失败原因 */
+    private String failReason;
+
+    /** 发票查看凭证 */
+    private String invoiceUrl;
+
+    /** 申请时间 */
+    private Date applyTime;
+
+    /** 开立时间 */
+    private Date issueTime;
+
+    /** 作废时间 */
+    private Date invalidTime;
+
+    /** 创建时间 */
+    private Date createTime;
+
+    // ===== 查询筛选(不映射列) =====
+
+    /** 门店名模糊 */
+    private String posNameLike;
+
+    /** 创建时间范围 */
+    private String[] dateRange;
+    private transient String cretimStart;
+    private transient String cretimEnd;
+}

+ 72 - 0
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosStoreEzpayVo.java

@@ -0,0 +1,72 @@
+package com.ruoyi.system.domain.vo;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 门店 ezPay 开通管理列表行(pos_store LEFT JOIN pos_store_ezpay)。
+ *
+ * @author ruoyi
+ * @date 2026-06-15
+ */
+@Data
+public class PosStoreEzpayVo {
+
+    /** 门店 id */
+    private Long storeId;
+
+    /** 门店名称 */
+    private String posName;
+
+    /** 所属商家 id */
+    private Long userId;
+
+    /** 所属商家用户名 */
+    private String userName;
+
+    /** 是否摊位 0=店铺 1=摊位 */
+    private Integer isStall;
+
+    /** 是否关联夜市 */
+    private Integer isNightMarket;
+
+    /** 夜市id */
+    private Long nightMarketId;
+
+    /** 是否免用发票 0需开票/1免用发票 */
+    private Integer invoiceExempt;
+
+    /** ezPay 申请状态:0未申请/1申请中/2已开通(无行=0) */
+    private Integer ezpayStatus;
+
+    /** 启用开关:0停用/1启用 */
+    private Integer isEnabled;
+
+    /** 统一编号 */
+    private String ubn;
+
+    /** 是否已录入商店代号(用于前端展示「凭证已填」) */
+    private Integer hasMerchantId;
+
+    /** ezPay 商店代号(详情用,列表可不下发) */
+    private String merchantId;
+
+    /** ezPay 会员编号 CompanyID_(详情用,可空) */
+    private String companyId;
+
+    /** 备注(详情用) */
+    private String remark;
+
+    /** 提交申请时间 */
+    private Date applyTime;
+
+    /** 开通时间 */
+    private Date approvedTime;
+
+    /** 最近一次凭证验证结果 */
+    private String lastVerifyResult;
+
+    /** 查询用快捷过滤:needApply=还要去开通 / notEnabled=还没开通(不映射列) */
+    private transient String quickFilter;
+}

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

@@ -0,0 +1,26 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosOrderInvoice;
+import com.ruoyi.system.domain.vo.PosOrderInvoiceVo;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 订单电子发票 Mapper。
+ *
+ * @author ruoyi
+ * @date 2026-06-16
+ */
+public interface PosOrderInvoiceMapper extends BaseMapper<PosOrderInvoice> {
+
+    /**
+     * 订单发票列表(pos_order_invoice LEFT JOIN pos_store)。
+     * 配合 PageHelper startPage() 分页。
+     */
+    List<PosOrderInvoiceVo> selectInvoiceList(PosOrderInvoiceVo query);
+
+    /** 订单发票详情(单行)。 */
+    PosOrderInvoiceVo selectInvoiceDetail(@Param("orderId") Long orderId);
+}

+ 32 - 0
ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreEzpayMapper.java

@@ -0,0 +1,32 @@
+package com.ruoyi.system.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.ruoyi.system.domain.PosStoreEzpay;
+import com.ruoyi.system.domain.vo.PosStoreEzpayVo;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 门店 ezPay 开通配置 Mapper。
+ *
+ * @author ruoyi
+ * @date 2026-06-15
+ */
+public interface PosStoreEzpayMapper extends BaseMapper<PosStoreEzpay> {
+
+    /**
+     * 门店 ezPay 开通管理列表(pos_store LEFT JOIN pos_store_ezpay + info_user)。
+     * 配合 PageHelper startPage() 分页。
+     */
+    List<PosStoreEzpayVo> selectEzpayStoreList(PosStoreEzpayVo query);
+
+    /** 门店 ezPay 详情(单行,含门店基础信息)。 */
+    PosStoreEzpayVo selectEzpayStoreDetail(@Param("storeId") Long storeId);
+
+    /** 更新门店免用发票标记(写 pos_store.invoice_exempt)。 */
+    int updateInvoiceExempt(@Param("storeId") Long storeId, @Param("exempt") Integer exempt);
+
+    /** 商家上传统编:按 store_id upsert ubn(无行则建行 status=0/enabled=1)。 */
+    int upsertUbn(@Param("storeId") Long storeId, @Param("ubn") String ubn);
+}

+ 48 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/IPosStoreEzpayService.java

@@ -0,0 +1,48 @@
+package com.ruoyi.system.service;
+
+import com.ruoyi.system.domain.dto.StoreEzpayCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreEzpayVo;
+
+import java.util.List;
+
+/**
+ * 门店 ezPay 发票开通管理 Service。
+ *
+ * @author ruoyi
+ * @date 2026-06-15
+ */
+public interface IPosStoreEzpayService {
+
+    /** 门店 ezPay 开通管理列表(配合 PageHelper 分页)。 */
+    List<PosStoreEzpayVo> selectEzpayStoreList(PosStoreEzpayVo query);
+
+    /** 门店 ezPay 详情。 */
+    PosStoreEzpayVo selectEzpayStoreDetail(Long storeId);
+
+    /** 发起申请(未申请 0 → 申请中 1)。 */
+    int apply(Long storeId);
+
+    /**
+     * 持久化凭证并标记已开通(status=2、enabled=1、approvedTime)。
+     * 不含 ezPay 联网验证——由 Controller 层验证通过后再调用本方法。
+     */
+    int enableWithCredentials(StoreEzpayCredentialDto dto);
+
+    /** 记录凭证验证结果(lastVerifyResult),不改状态。 */
+    int recordVerifyResult(Long storeId, String result);
+
+    /** 停用 / 恢复(仅 status=2 有效),翻转 isEnabled。 */
+    int toggleEnable(Long storeId);
+
+    /** 标记免用发票 / 恢复需开票(写 pos_store.invoice_exempt)。 */
+    int markExempt(Long storeId, Integer invoiceExempt);
+
+    /** 重置状态(凭证作废/重新申请),清 approvedTime。 */
+    int reset(Long storeId);
+
+    /** 商家上传统一编号(按 store_id upsert ubn,不改状态)。 */
+    int uploadUbn(Long storeId, String ubn);
+
+    /** 商家端读取已保存的统一编号(无则返回 null)。 */
+    String getUbn(Long storeId);
+}

+ 169 - 0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PosStoreEzpayServiceImpl.java

@@ -0,0 +1,169 @@
+package com.ruoyi.system.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.SecurityUtils;
+import com.ruoyi.system.domain.PosStoreEzpay;
+import com.ruoyi.system.domain.dto.StoreEzpayCredentialDto;
+import com.ruoyi.system.domain.vo.PosStoreEzpayVo;
+import com.ruoyi.system.mapper.PosStoreEzpayMapper;
+import com.ruoyi.system.service.IPosStoreEzpayService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 门店 ezPay 发票开通管理 Service 实现(纯 DB 操作;ezPay 联网验证由 Controller 层完成)。
+ *
+ * @author ruoyi
+ * @date 2026-06-15
+ */
+@Service
+public class PosStoreEzpayServiceImpl implements IPosStoreEzpayService {
+
+    @Autowired
+    private PosStoreEzpayMapper posStoreEzpayMapper;
+
+    /** 申请状态 */
+    private static final int STATUS_NOT_APPLIED = 0;
+    private static final int STATUS_APPLYING = 1;
+    private static final int STATUS_ENABLED = 2;
+
+    @Override
+    public List<PosStoreEzpayVo> selectEzpayStoreList(PosStoreEzpayVo query) {
+        return posStoreEzpayMapper.selectEzpayStoreList(query);
+    }
+
+    @Override
+    public PosStoreEzpayVo selectEzpayStoreDetail(Long storeId) {
+        PosStoreEzpayVo vo = posStoreEzpayMapper.selectEzpayStoreDetail(storeId);
+        if (vo == null) {
+            throw new ServiceException("门店不存在");
+        }
+        return vo;
+    }
+
+    @Override
+    public int apply(Long storeId) {
+        PosStoreEzpay row = getOrCreateByStoreId(storeId);
+        if (!Integer.valueOf(STATUS_NOT_APPLIED).equals(row.getEzpayStatus())) {
+            throw new ServiceException("当前状态不允许发起申请");
+        }
+        row.setEzpayStatus(STATUS_APPLYING);
+        row.setApplyTime(new Date());
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int enableWithCredentials(StoreEzpayCredentialDto dto) {
+        PosStoreEzpay row = getOrCreateByStoreId(dto.getStoreId());
+        row.setMerchantId(dto.getMerchantId());
+        row.setHashKey(dto.getHashKey());
+        row.setHashIv(dto.getHashIv());
+        if (dto.getCompanyId() != null) {
+            row.setCompanyId(dto.getCompanyId());
+        }
+        row.setEzpayStatus(STATUS_ENABLED);
+        row.setIsEnabled(1);
+        row.setApprovedTime(new Date());
+        row.setLastVerifyResult("PASS");
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int recordVerifyResult(Long storeId, String result) {
+        PosStoreEzpay row = getOrCreateByStoreId(storeId);
+        row.setLastVerifyResult(truncate(result));
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int toggleEnable(Long storeId) {
+        PosStoreEzpay row = getOrCreateByStoreId(storeId);
+        if (!Integer.valueOf(STATUS_ENABLED).equals(row.getEzpayStatus())) {
+            throw new ServiceException("仅已开通门店可停用/恢复");
+        }
+        int next = Integer.valueOf(1).equals(row.getIsEnabled()) ? 0 : 1;
+        row.setIsEnabled(next);
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int markExempt(Long storeId, Integer invoiceExempt) {
+        if (invoiceExempt == null || (invoiceExempt != 0 && invoiceExempt != 1)) {
+            throw new ServiceException("免用发票标记取值非法");
+        }
+        return posStoreEzpayMapper.updateInvoiceExempt(storeId, invoiceExempt);
+    }
+
+    @Override
+    public int reset(Long storeId) {
+        PosStoreEzpay row = getOrCreateByStoreId(storeId);
+        row.setEzpayStatus(STATUS_APPLYING);
+        row.setApprovedTime(null);
+        return saveOrUpdate(row);
+    }
+
+    @Override
+    public int uploadUbn(Long storeId, String ubn) {
+        return posStoreEzpayMapper.upsertUbn(storeId, ubn);
+    }
+
+    @Override
+    public String getUbn(Long storeId) {
+        PosStoreEzpay row = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
+        return row == null ? null : row.getUbn();
+    }
+
+    // ===== 内部辅助 =====
+
+    /** 按 storeId 取行;无行则新建(status=0, enabled=1,不入库)。 */
+    private PosStoreEzpay getOrCreateByStoreId(Long storeId) {
+        if (storeId == null) {
+            throw new ServiceException("门店不能为空");
+        }
+        PosStoreEzpay row = posStoreEzpayMapper.selectOne(
+                new LambdaQueryWrapper<PosStoreEzpay>().eq(PosStoreEzpay::getStoreId, storeId));
+        if (row == null) {
+            row = new PosStoreEzpay();
+            row.setStoreId(storeId);
+            row.setEzpayStatus(STATUS_NOT_APPLIED);
+            row.setIsEnabled(1);
+        }
+        return row;
+    }
+
+    /** 新增或更新(显式设置时间与人,不依赖 MetaObjectHandler)。 */
+    private int saveOrUpdate(PosStoreEzpay row) {
+        Date now = new Date();
+        String user = currentUser();
+        if (row.getId() == null) {
+            row.setCreateTime(now);
+            row.setUpdateTime(now);
+            row.setCreateBy(user);
+            row.setUpdateBy(user);
+            return posStoreEzpayMapper.insert(row);
+        }
+        row.setUpdateTime(now);
+        row.setUpdateBy(user);
+        return posStoreEzpayMapper.updateById(row);
+    }
+
+    private String currentUser() {
+        try {
+            return SecurityUtils.getUsername();
+        } catch (Exception e) {
+            return "system";
+        }
+    }
+
+    private static String truncate(String s) {
+        if (s == null) {
+            return "";
+        }
+        return s.length() > 240 ? s.substring(0, 240) : s;
+    }
+}

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

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosOrderInvoiceMapper">
+
+    <!-- 订单发票列表:列名别名对齐 VO 属性,resultType 自动映射 -->
+    <select id="selectInvoiceList" parameterType="com.ruoyi.system.domain.vo.PosOrderInvoiceVo"
+            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.buyer_ubn        AS buyerUbn,
+            i.invoice_number   AS invoiceNumber,
+            i.invoice_status   AS invoiceStatus,
+            i.total_amt        AS totalAmt,
+            i.sales_amt        AS salesAmt,
+            i.tax_amt          AS taxAmt,
+            i.fail_reason      AS failReason,
+            i.apply_time       AS applyTime,
+            i.issue_time       AS issueTime,
+            i.invalid_time     AS invalidTime,
+            i.create_time      AS createTime
+        FROM pos_order_invoice i
+        LEFT JOIN pos_store s ON s.id = i.store_id
+        <where>
+            <if test="orderNo != null and orderNo != ''"> AND i.order_no = #{orderNo} </if>
+            <if test="storeId != null"> AND i.store_id = #{storeId} </if>
+            <if test="invoiceStatus != null"> AND i.invoice_status = #{invoiceStatus} </if>
+            <if test="invoiceCategory != null and invoiceCategory != ''"> AND i.invoice_category = #{invoiceCategory} </if>
+            <if test="posNameLike != null and posNameLike != ''"> AND s.pos_name LIKE concat('%', #{posNameLike}, '%') </if>
+            <if test="cretimStart != null and cretimStart != ''"> AND i.create_time &gt;= #{cretimStart} </if>
+            <if test="cretimEnd != null and cretimEnd != ''"> AND i.create_time &lt;= #{cretimEnd} </if>
+        </where>
+        ORDER BY i.id DESC
+    </select>
+
+    <!-- 订单发票详情(含买方/载具/金额/凭证完整字段) -->
+    <select id="selectInvoiceDetail" 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.buyer_ubn        AS buyerUbn,
+            i.buyer_email      AS buyerEmail,
+            i.carrier_type     AS carrierType,
+            i.carrier_num      AS carrierNum,
+            i.invoice_number   AS invoiceNumber,
+            i.random_num       AS randomNum,
+            i.invoice_status   AS invoiceStatus,
+            i.total_amt        AS totalAmt,
+            i.sales_amt        AS salesAmt,
+            i.tax_amt          AS taxAmt,
+            i.fail_reason      AS failReason,
+            i.invoice_url      AS invoiceUrl,
+            i.apply_time       AS applyTime,
+            i.issue_time       AS issueTime,
+            i.invalid_time     AS invalidTime,
+            i.create_time      AS createTime
+        FROM pos_order_invoice i
+        LEFT JOIN pos_store s ON s.id = i.store_id
+        WHERE i.order_id = #{orderId}
+    </select>
+
+</mapper>

+ 86 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosStoreEzpayMapper.xml

@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.PosStoreEzpayMapper">
+
+    <!-- 门店 ezPay 开通管理列表:列名别名对齐 VO 属性,resultType 自动映射 -->
+    <select id="selectEzpayStoreList" parameterType="com.ruoyi.system.domain.vo.PosStoreEzpayVo"
+            resultType="com.ruoyi.system.domain.vo.PosStoreEzpayVo">
+        SELECT
+            s.id              AS storeId,
+            s.pos_name        AS posName,
+            s.user_id         AS userId,
+            u.user_name       AS userName,
+            s.is_stall        AS isStall,
+            s.is_night_market AS isNightMarket,
+            s.night_market_id AS nightMarketId,
+            s.invoice_exempt  AS invoiceExempt,
+            IFNULL(e.ezpay_status, 0)  AS ezpayStatus,
+            IFNULL(e.is_enabled, 1)    AS isEnabled,
+            e.ubn             AS ubn,
+            CASE WHEN e.merchant_id IS NULL OR e.merchant_id = '' THEN 0 ELSE 1 END AS hasMerchantId,
+            e.merchant_id     AS merchantId,
+            e.apply_time      AS applyTime,
+            e.approved_time   AS approvedTime,
+            e.last_verify_result AS lastVerifyResult
+        FROM pos_store s
+        LEFT JOIN pos_store_ezpay e ON e.store_id = s.id
+        LEFT JOIN info_user u ON u.user_id = s.user_id
+        <where>
+            s.del_flag = '0'
+            <if test="isStall != null"> AND s.is_stall = #{isStall} </if>
+            <if test="posName != null and posName != ''"> AND s.pos_name LIKE concat('%', #{posName}, '%') </if>
+            <if test="invoiceExempt != null"> AND s.invoice_exempt = #{invoiceExempt} </if>
+            <if test="ezpayStatus != null"> AND IFNULL(e.ezpay_status, 0) = #{ezpayStatus} </if>
+            <if test="quickFilter != null and quickFilter == 'needApply'">
+                AND s.invoice_exempt = 0 AND IFNULL(e.ezpay_status, 0) = 0
+            </if>
+            <if test="quickFilter != null and quickFilter == 'notEnabled'">
+                AND s.invoice_exempt = 0 AND IFNULL(e.ezpay_status, 0) IN (0, 1)
+            </if>
+        </where>
+        ORDER BY s.id DESC
+    </select>
+
+    <!-- 门店 ezPay 详情 -->
+    <select id="selectEzpayStoreDetail" parameterType="Long"
+            resultType="com.ruoyi.system.domain.vo.PosStoreEzpayVo">
+        SELECT
+            s.id              AS storeId,
+            s.pos_name        AS posName,
+            s.user_id         AS userId,
+            u.user_name       AS userName,
+            s.is_stall        AS isStall,
+            s.is_night_market AS isNightMarket,
+            s.night_market_id AS nightMarketId,
+            s.invoice_exempt  AS invoiceExempt,
+            IFNULL(e.ezpay_status, 0)  AS ezpayStatus,
+            IFNULL(e.is_enabled, 1)    AS isEnabled,
+            e.ubn             AS ubn,
+            CASE WHEN e.merchant_id IS NULL OR e.merchant_id = '' THEN 0 ELSE 1 END AS hasMerchantId,
+            e.merchant_id     AS merchantId,
+            e.company_id      AS companyId,
+            e.apply_time      AS applyTime,
+            e.approved_time   AS approvedTime,
+            e.last_verify_result AS lastVerifyResult,
+            e.remark          AS remark
+        FROM pos_store s
+        LEFT JOIN pos_store_ezpay e ON e.store_id = s.id
+        LEFT JOIN info_user u ON u.user_id = s.user_id
+        WHERE s.id = #{storeId}
+    </select>
+
+    <!-- 更新门店免用发票标记 -->
+    <update id="updateInvoiceExempt">
+        UPDATE pos_store SET invoice_exempt = #{exempt} WHERE id = #{storeId}
+    </update>
+
+    <!-- 商家上传统编:按 store_id upsert(依赖 uk_store_id) -->
+    <insert id="upsertUbn">
+        INSERT INTO pos_store_ezpay (store_id, ubn, ezpay_status, is_enabled, create_time, update_time)
+        VALUES (#{storeId}, #{ubn}, 0, 1, NOW(), NOW())
+        ON DUPLICATE KEY UPDATE ubn = VALUES(ubn), update_time = NOW()
+    </insert>
+
+</mapper>

+ 1 - 0
ruoyi-system/src/main/resources/mapper/chanting/PosStoreMapper.xml

@@ -31,6 +31,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="isStall"    column="is_stall"    />
         <result property="isNightMarket"    column="is_night_market"    />
         <result property="nightMarketId"    column="night_market_id"    />
+        <result property="invoiceExempt"    column="invoice_exempt"    />
         <result property="delFlag"    column="del_flag"    />
     </resultMap>
 

+ 36 - 0
specs/009-ezpay-invoice-onboarding/checklists/requirements.md

@@ -0,0 +1,36 @@
+# Specification Quality Checklist: 商家 ezPay 发票开通管理
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-06-15
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs) — 仅在 Assumptions 提到复用现有工具类,属约束陈述非实现细节
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded (不含自动开票、不含字轨/CheckValue)
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- 全部检查项通过,spec 可直接进入 `/speckit-plan`。
+- 关键决策已在 brainstorming 阶段与用户确认:平台代申请(线下)、独立关联表、三态+启用开关、凭证按门店绑定、夜市不开票/摊位开票、免用发票门店排除、录入时验证凭证、统编商家上传。
+- 按用户要求未新建 git 分支,跳过了 `before_specify` 的 `speckit.git.feature` 钩子,在当前 test 分支开发。

+ 85 - 0
specs/009-ezpay-invoice-onboarding/contracts/api.md

@@ -0,0 +1,85 @@
+# API Contracts: 商家 ezPay 发票开通管理
+
+**Phase**: Phase 1 — REST 接口契约
+**Date**: 2026-06-15
+
+> 平台后台接口走若依鉴权 `@PreAuthorize("@ss.hasPermi('chanting:storeEzpay:*')")`;商家端接口走 `@Auth`+JWT。返回统一 `AjaxResult`/`TableDataInfo`。
+
+## 一、平台后台(PosStoreEzpayController,`/system/storeEzpay`)
+
+### 1. 列表(分页 + 筛选)
+
+`GET /system/storeEzpay/list`
+
+| 参数 | 类型 | 说明 |
+|------|------|------|
+| pageNum / pageSize | int | 分页 |
+| ezpayStatus | int | 可选:0未申请/1申请中/2已开通;不传=全部(含免用) |
+| invoiceExempt | int | 可选:0需开票/1免用发票 |
+| quickFilter | string | 可选:`notEnabled`=还没开通、`needApply`=还要去开通 |
+| posName | string | 可选:门店名模糊 |
+| isStall | int | 可选:0店铺/1摊位 |
+
+**返回** `TableDataInfo`,每行含:门店基础信息(id/posName/userId/userName/isStall/invoiceExempt)+ ezPay 信息(ezpayStatus/isEnabled/ubn/merchantId 是否已填/applyTime/approvedTime/lastVerifyResult)。无 ezPay 行的门店 ezpayStatus 视为 0。
+
+### 2. 详情
+
+`GET /system/storeEzpay/{storeId}` → `AjaxResult`,门店 + ezPay 配置(凭证字段是否回显由权限决定,建议列表/详情回显,敏感字段不下发商家端)。
+
+### 3. 发起申请(0→1)
+
+`PUT /system/storeEzpay/apply/{storeId}`
+- 前置:`ezpay_status=0`;建议校验 `ubn` 已填(未填则提示商家先补统编)。
+- 效果:`ezpay_status=1`、`apply_time=now`。
+
+### 4. 录入凭证并验证(→2)
+
+`PUT /system/storeEzpay/saveCredentials`
+```json
+{ "storeId": 123, "merchantId": "3482911", "hashKey": "...", "hashIv": "...", "companyId": null }
+```
+- 后端:构造 `EzPayConfig` → 调 `EzPay.doPost(BASE_TEST + URL_SEARCH, cfg, {假发票号+随机码})`。
+- 判读:回应含 `KEY1xxxx` → 凭证无效,**保持 status 不变**,`last_verify_result` 记错误码,返回 error(含中文说明)。回应业务错误或成功 → `ezpay_status=2`、`is_enabled=1`、`approved_time=now`、保存凭证、`last_verify_result` 记通过。
+- 网络异常:返回 error 可重试,不改状态。
+
+### 5. 切换启用开关
+
+`PUT /system/storeEzpay/toggleEnable/{storeId}`
+- 前置:`ezpay_status=2`。翻转 `is_enabled`(1↔0)。返回新状态。
+
+### 6. 标记/恢复免用发票
+
+`PUT /system/storeEzpay/markExempt/{storeId}`
+```json
+{ "invoiceExempt": 1 }   // 1=免用发票, 0=恢复需开票
+```
+- 效果:更新 `pos_store.invoice_exempt`。免用后该门店退出待办过滤(已有 ezPay 行保留)。
+
+### 7. 重置状态(可选)
+
+`PUT /system/storeEzpay/reset/{storeId}`
+- 将 `ezpay_status` 回退到 0 或 1(凭证作废/重新申请场景),清 `approved_time`。
+
+## 二、商家端(上传统编)
+
+挂在门店设置流程。两种实现选一(实现时定):
+
+**方案 A(推荐,最少改动)**:复用 `POST /chanting/store/addmendian`(已 `saveOrUpdate` 整个 PosStore)——前端门店表单加"统一编号"字段,提交时一并写入 `pos_store_ezpay.ubn`(后端在保存门店时 upsert ezPay 行的 ubn)。
+
+**方案 B**:新增 `POST /chanting/store/saveUbn` `{ storeId, ubn }`,仅商家本人门店(JWT 校验 storeId 归属)。
+
+> 无论哪种:商家端只能写 `ubn`,不能改 ezPay 状态/凭证/免用标记。
+
+## 三、权限菜单
+
+新增菜单/权限键(写入 `sys_menu`,SQL 进 `updatesql/sql.md`):
+- `chanting:storeEzpay:list` / `:query` / `:apply` / `:saveCredentials` / `:toggleEnable` / `:markExempt` / `:reset`
+
+## 四、错误约定
+
+| 场景 | HTTP | 业务码/消息 |
+|------|------|-------------|
+| 凭证金钥错误 | 200 | error,`last_verify_result` 记 KEY1xxxx,消息"ezPay 凭证无效,请检查 HashKey/HashIV" |
+| 验证网络超时 | 200 | error,"ezPay 验证服务暂不可用,请稍后重试",状态不变 |
+| 非法状态迁移 | 200 | error,"当前状态不允许此操作" |
+| 商家越权改门店 | 200 | error,"无权操作该门店" |

+ 96 - 0
specs/009-ezpay-invoice-onboarding/data-model.md

@@ -0,0 +1,96 @@
+# Data Model: 商家 ezPay 发票开通管理
+
+**Phase**: Phase 1 — 表结构、字段、状态机
+**Date**: 2026-06-15
+
+## 变更一:pos_store 加列(已有表)
+
+`pos_store` 新增一列,按全栈清单走(实体 + resultMap + select/insert/update + 前端 + i18n + SQL)。
+
+| 字段 | 类型 | 默认 | 说明 |
+|------|------|------|------|
+| `invoice_exempt` | `tinyint(1)` | `0` | 是否免用发票(不需开票):0需开票 / 1免用发票 |
+
+> 不在本表加任何 ezPay 凭证字段(凭证进独立表,见决策 5)。
+
+## 变更二:新增表 pos_store_ezpay(与 pos_store 1:1)
+
+```sql
+-- 2026-06-15 商家 ezPay 发票开通管理
+CREATE TABLE pos_store_ezpay (
+  id                 BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  store_id           BIGINT       NOT NULL COMMENT '关联 pos_store.id(门店)',
+  ezpay_status       TINYINT      NOT NULL DEFAULT 0 COMMENT '申请状态:0未申请/1申请中/2已开通',
+  is_enabled         TINYINT      NOT NULL DEFAULT 1 COMMENT '启用开关:0停用/1启用(仅status=2有意义)',
+  merchant_id        VARCHAR(32)  DEFAULT NULL COMMENT 'ezPay 商店代号 MerchantID_',
+  hash_key           VARCHAR(64)  DEFAULT NULL COMMENT 'ezPay HashKey(32字节)',
+  hash_iv            VARCHAR(64)  DEFAULT NULL COMMENT 'ezPay HashIV(16字节)',
+  company_id         VARCHAR(32)  DEFAULT NULL COMMENT 'ezPay 会员编号 CompanyID_(字轨用,可空)',
+  ubn                VARCHAR(16)  DEFAULT NULL COMMENT '统一编号(统编,商家上传)',
+  apply_time         DATETIME     DEFAULT NULL COMMENT '提交申请时间(0->1)',
+  approved_time      DATETIME     DEFAULT NULL COMMENT '开通时间(->2)',
+  last_verify_result VARCHAR(255) DEFAULT NULL COMMENT '最近一次凭证验证结果',
+  remark             VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  create_time        DATETIME     DEFAULT NULL COMMENT '创建时间',
+  update_time        DATETIME     DEFAULT NULL COMMENT '更新时间',
+  create_by          VARCHAR(64)  DEFAULT NULL COMMENT '创建人',
+  update_by          VARCHAR(64)  DEFAULT NULL COMMENT '更新人',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_store_id (store_id),
+  KEY idx_ezpay_status (ezpay_status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf88mb4 COMMENT='门店 ezPay 发票开通配置';
+```
+
+### 字段语义
+
+- `store_id`:唯一键,一门店一行。**门店无此行 = 未申请**(列表 LEFT JOIN,无行按 status=0 处理)。
+- `ezpay_status`:核心状态机字段(见下)。
+- `is_enabled`:运行时开关,仅 `ezpay_status=2` 时有意义;停用不影响 status。
+- `merchant_id`/`hash_key`/`hash_iv`:运营录入的 ezPay 凭证;`company_id` 字轨用可空。
+- `ubn`:商家在商家端上传的统一编号。
+- `last_verify_result`:记录最近验证的 ezPay 回应摘要,便于排查。
+
+### 实体(PosStoreEzpay.java)
+
+`ruoyi-system/.../domain/PosStoreEzpay.java`,MyBatis-Plus `@TableName("pos_store_ezpay")`、`@TableId(type=IdType.AUTO)`,字段与上表一一对应(驼峰)。
+
+## 状态机
+
+```
+                ┌─────────────────────────────┐
+                ▼                             │ 重置/重新申请
+  未申请(0) ──发起申请──▶ 申请中(1) ──录入凭证+验证通过──▶ 已开通(2)
+                              │                              │
+                              │ 验证失败/网络异常             │ 停用
+                              ▼ (保持 1)                     ▼
+                          (原地,回传错误)          已开通+停用(enabled=0)
+                                                             │ 恢复
+                                                             ▼
+                                                       已开通+启用(enabled=1)
+```
+
+**不变量**:
+- `ezpay_status=2` 的前置:必须经过一次成功的凭证验证(`merchant_id`/`hash_key`/`hash_iv` 非空且 `last_verify_result` 为通过)。
+- `is_enabled` 仅在 `ezpay_status=2` 时可切换为有效语义;其它状态下恒视为"不可开票"。
+- **免用发票(pos_store.invoice_exempt=1)优先于 ezPay 状态**:免用门店即使有 `pos_store_ezpay` 行也视为不开票,且不进入待办过滤。
+
+## 列表查询口径
+
+平台后台列表 = `pos_store` LEFT JOIN `pos_store_ezpay`,过滤"实际卖货门店":
+
+- 纳入:普通店铺(`is_stall=0`)+ 摊位(`is_stall=1`,含夜市下摊位)
+- 排除:夜市(userType=3)本身不经 pos_store 开票;纯夜市容器门店按实现时数据口径确认
+
+筛选维度:
+- `invoice_exempt=1` → 免用发票
+- `invoice_exempt=0 AND ezpay_status=0` → 未申请("还要去开通")
+- `invoice_exempt=0 AND ezpay_status=1` → 申请中
+- `invoice_exempt=0 AND ezpay_status=2` → 已开通
+- "还没开通" = `invoice_exempt=0 AND ezpay_status IN (0,1)`
+- "还要去开通" = `invoice_exempt=0 AND ezpay_status=0`
+
+## 验证规则
+
+- 录入凭证:`merchant_id` 必填、`hash_key` 必填且 32 字节、`hash_iv` 必填且 16 字节(长度校验,ezPay 固定规格)。
+- 验证调用:超时 10s;网络异常按"未通过"处理,不抛 500,回传可重试提示。
+- 统编 `ubn`:商家端非免用门店建议必填(8 位数字),免用门店不强制。

+ 106 - 0
specs/009-ezpay-invoice-onboarding/plan.md

@@ -0,0 +1,106 @@
+# Implementation Plan: 商家 ezPay 发票开通管理
+
+**Branch**: `test`(不新建分支,按用户要求在当前分支开发) | **Date**: 2026-06-15 | **Spec**: [spec.md](./spec.md)
+
+**Input**: Feature specification from `specs/009-ezpay-invoice-onboarding/spec.md`
+
+## Summary
+
+平台代商家到 ezPay(线下人工)注册申请发票,拿到凭证后由运营在平台后台录入并验证,验证通过标记"已开通"才具备开票前提。本期交付:①平台后台"ezPay 发票开通管理"列表(状态/筛选/快捷过滤)②状态推进(未申请→申请中→已开通)③凭证录入+ezPay 接口验证④启用开关⑤免用发票标记(新字段挂在 pos_store)⑥商家端上传统编。技术方案:新增独立表 `pos_store_ezpay`(1:1 门店)+ `pos_store` 加 `invoice_exempt` 列;复用已有 `EzPay`/`EzPayConfig`/`EzPayEncryptUtil` 工具类,用 `invoice_search` 只读调用验证凭证(避开 CheckValue 不确定性)。
+
+## Technical Context
+
+**Language/Version**: Java 17+(Spring Boot,若依 RuoYi 框架;代码用 `jakarta.servlet`、`HexFormat` 表明 JDK17+)
+
+**Primary Dependencies**:
+- 后端:Spring Boot、MyBatis-Plus(`@TableName`/`@TableId`/`LambdaQueryWrapper`)+ XML mapper、Apache HttpClient、fastjson2、hutool、若依通用(`AjaxResult`/`TableDataInfo`/`BaseController`/`@PreAuthorize("@ss.hasPermi(...)")`)
+- 复用:`com.ruoyi.app.utils.ezPay.EzPay`(issue/search/doPost)、`EzPayConfig`、`ezPayCrypto.EzPayEncryptUtil`
+- 前端:Vue.js + Element UI(平台后台 `foodie-admin-vue`、商家端 `foodie-store`,均 vue-i18n 四语言)
+
+**Storage**: MySQL(新增表 `pos_store_ezpay`;`pos_store` 加列 `invoice_exempt`)
+
+**Testing**: JUnit 单测(mock ezPay 回应验证判读分支)+ 手测(ezPay 测试环境 `cinv.ezpay.com.tw` 真凭证跑通录入→验证→已开通)
+
+**Target Platform**: Windows 开发 / Linux 部署的 Spring Boot 服务 + Vue 后台/商家端
+
+**Project Type**: web-service(Spring Boot 后端)+ 两个 Vue 前端
+
+**Performance Goals**: 平台后台列表分页查询 p95 < 500ms;凭证验证调用(含 ezPay 网络往返)< 10s 超时
+
+**Constraints**: ezPay 无注册 API(线下人工);凭证验证走只读接口不产生真实发票;本期不含订单自动开票
+
+**Scale/Scope**: 1 新表 + 1 新列;后端 1 controller + 1 service + 1 mapper(含XML) + 1 domain;平台后台 1 新页面;商家端 1 表单字段;4 语言 i18n ×2 前端
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未填充模板(占位符未替换),项目未定义具体宪法原则与门槛。**无实质门槛需评估**,设计遵循项目既有约定(若依分层、MyBatis-Plus、全栈字段清单、四语言 i18n、SQL 写 `updatesql/sql.md`)。Phase 1 后复核:无违反。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/009-ezpay-invoice-onboarding/
+├── spec.md              # 需求规格
+├── plan.md              # 本文件(技术方案)
+├── research.md          # Phase 0:关键技术决策与依据
+├── data-model.md        # Phase 1:表结构、字段、状态机
+├── quickstart.md        # Phase 1:跑通验证步骤
+├── contracts/
+│   └── api.md           # Phase 1:REST 接口契约
+├── checklists/
+│   └── requirements.md  # spec 质量检查
+└── tasks.md             # /speckit-tasks 生成(本期 plan 不创建)
+```
+
+### Source Code (repository root)
+
+```text
+# ===== 后端 foodie_server =====
+ruoyi-system/src/main/java/com/ruoyi/system/
+├── domain/
+│   ├── PosStore.java                 # 已有:新增 invoice_exempt 字段
+│   └── PosStoreEzpay.java            # 新增:门店 ezPay 配置实体
+├── mapper/
+│   ├── PosStoreMapper.java           # 已有:resultMap 加 invoice_exempt
+│   └── PosStoreEzpayMapper.java      # 新增
+└── service/
+    ├── IPosStoreEzpayService.java    # 新增
+    └── impl/PosStoreEzpayServiceImpl.java  # 新增
+
+ruoyi-system/src/main/resources/mapper/chanting/
+├── PosStoreMapper.xml                # 已有:resultMap + select 列加 invoice_exempt
+└── PosStoreEzpayMapper.xml           # 新增
+
+ruoyi-admin/src/main/java/com/ruoyi/app/
+├── mendian/
+│   ├── PosStoreController.java       # 已有:addmendian/edit 携带 invoice_exempt;UBN 上传接口
+│   └── PosStoreEzpayController.java  # 新增:平台后台 ezPay 开通管理 REST
+├── user/dto/ (或 mendian/dto)
+│   └── StoreEzpayCredentialDto.java  # 新增:录入凭证入参
+└── utils/ezPay/                       # 已有,不改内部:EzPay/EzPayConfig/EzPayEncryptUtil
+
+# ===== SQL =====
+updatesql/sql.md                      # 追加建表 + 加列语句(带日期注释)
+
+# ===== 平台后台前端 foodie-admin-vue =====
+src/api/.../storeEzpay.js             # 新增:接口封装
+src/views/.../storeEzpay/index.vue    # 新增:ezPay 开通管理页面
+src/lang/{zh,tw,en,vi}.js             # 四语言加 key(新对象层级 storeEzpay:{})
+
+# ===== 商家端前端 foodie-store =====
+src/lang/{zh,tw,en,vi}.js             # 四语言加统编相关 key
+门店设置表单                          # 新增"统一编号"字段
+```
+
+**Structure Decision**: 后端遵循既有若依分层(domain→mapper→service→controller),新表对应独立一套 CRUD;平台后台凭证管理用独立 `PosStoreEzpayController`(`/system/storeEzpay`,权限键 `chanting:storeEzpay:*`),与门店基础维护解耦;商家端 UBN 上传挂在门店设置流程。前端两个仓库各新增最小页面/字段,严格走四语言 i18n。
+
+## Complexity Tracking
+
+> 无宪法违反项,无需填写。
+
+| Violation | Why Needed | Simpler Alternative Rejected Because |
+|-----------|------------|-------------------------------------|
+| — | — | — |

+ 42 - 0
specs/009-ezpay-invoice-onboarding/quickstart.md

@@ -0,0 +1,42 @@
+# Quickstart: 商家 ezPay 发票开通管理
+
+**Phase**: Phase 1 — 跑通验证步骤
+**Date**: 2026-06-15
+
+## 1. 准备数据(手动执行 SQL)
+
+到数据库执行 `updatesql/sql.md` 中 2026-06-15 段落:
+- `ALTER TABLE pos_store ADD COLUMN invoice_exempt TINYINT(1) DEFAULT 0 ...`
+- `CREATE TABLE pos_store_ezpay (...)`
+- `sys_menu` 插入 `chanting:storeEzpay:*` 权限项
+
+## 2. 拿 ezPay 测试凭证
+
+在 ezPay 测试环境 `https://cinv.ezpay.com.tw/` 申请一个测试商店,记录 `MerchantID`、`HashKey`(32 字节)、`HashIV`(16 字节)。(线下,人工)
+
+## 3. 后端验证
+
+1. 启动 `foodie_server`(`ruoyi-admin`)。
+2. 平台后台登录,进入"ezPay 发票开通管理"。
+3. 选一个测试门店(`pos_store` 里普通店铺或摊位):
+   - 点"发起申请" → 状态变 `申请中`。
+   - 点"录入凭证"填入第 2 步的真凭证 → 点"验证并开通":
+     - 凭证正确 → 状态变 `已开通`、启用,`approved_time` 写入。
+     - 故意填错 HashKey → 提示"ezPay 凭证无效",状态保持 `申请中`,`last_verify_result` 记 KEY1xxxx。
+4. 点"停用"/"恢复" → 启用开关翻转,状态仍 `已开通`。
+5. 点"标记免用发票" → 该门店从"还要去开通/还没开通"过滤消失。
+
+## 4. 商家端验证
+
+1. 商家端登录,进门店设置,填"统一编号"保存。
+2. 回平台后台该门店,确认 `ubn` 已显示。
+
+## 5. 单元测试
+
+`PosStoreEzpayServiceImpl` 验证判读逻辑(mock `EzPay.doPost`):
+- 回应 `Status!=成功` 且含 `KEY1` → 返回"凭证无效",status 不变。
+- 回应业务错误(发票不存在)或成功 → 通过,status=2。
+
+## 6. 验收对照
+
+回到 spec.md 的 SC-001 ~ SC-006 与各 User Story 的 Acceptance Scenarios 逐条核对。

+ 47 - 0
specs/009-ezpay-invoice-onboarding/research.md

@@ -0,0 +1,47 @@
+# Research: 商家 ezPay 发票开通管理
+
+**Phase**: Phase 0 — 关键技术决策与依据
+**Date**: 2026-06-15
+
+> spec 无 NEEDS CLARIFICATION 标记(brainstorming 阶段已与用户确认全部决策)。本文档固化这些决策的"为什么",供 plan/tasks 与实现阶段参考。
+
+## 决策 1:ezPay 商店注册不做 API 对接,走线下人工
+
+- **Decision**: 平台不在系统内调用任何"注册新商家/申请商店"接口;运营到 ezPay 官网(测试 `cinv.ezpay.com.tw` / 正式 `inv.ezpay.com.tw`)人工完成注册申请,拿到 `MerchantID`/`HashKey`/`HashIV` 后回平台后台录入。
+- **Rationale**: ezPay 官方 API(INVI 发票 / 字轨 / BDV 验证 / 批次)**不提供注册类接口**(见 `[[reference-ezpay-invoice-api]]`)。注册涉及工商凭证、人工审核,本就不是 API 能完成的。
+- **Alternatives**: ①商家自助注册后录入凭证——被否,用户明确"平台代申请(线下)";②对接 ezPay 开放注册 API——不存在,否。
+
+## 决策 2:凭证按门店(pos_store)绑定,独立关联表存储
+
+- **Decision**: 新增表 `pos_store_ezpay`,与 `pos_store` 1:1,按门店存一组 ezPay 凭证。不把凭证字段塞进 `pos_store`。
+- **Rationale**: 用户选择"独立关联表"。凭证(HashKey/HashIV)是敏感金钥,独立表便于权限收敛与将来扩展;夜市(userType=3)本身不开票、夜市下摊位门店需开票,门店是正确的开票主体单位。
+- **Alternatives**: ①凭证字段直接加到 `pos_store`——被否,污染主表且金钥混在通用字段中;②按商家账号(InfoUser)绑定——被否,用户明确按门店。
+
+## 决策 3:状态机 = 三态 + 启用开关
+
+- **Decision**: `ezpay_status` 0未申请 / 1申请中 / 2已开通;另设 `is_enabled` 0停用 / 1启用(仅 status=2 有意义)。
+- **Rationale**: 用户选"三态+启用开关(推荐)"。"还没开通"= status∈{0,1};"还要去开通"= status=0;启用开关满足"已开通后临时停用"无需重走申请。
+- **Alternatives**: ①只两态——缺"申请中"过程态,运营无法区分已提交待审 vs 未提交;②五态含"已拒绝"——被否,用户选三态,拒绝的门店回归申请中即可。
+
+## 决策 4:凭证验证用 invoice_search 只读调用,避开 CheckValue
+
+- **Decision**: 录入凭证点"验证并开通"时,后端用 `EzPayConfig(merchantId, hashKey, hashIV)` 调 `EzPay.doPost(BASE_TEST + URL_SEARCH, ...)`,传测试用假发票号+随机码。回应含 `KEY1xxxx`(加解密/金钥错误)→ 凭证无效、拒绝;回应是业务错误(如发票不存在 `INVxxxxx`)或成功 → 加密链路通、凭证有效 → 标记已开通。
+- **Rationale**: 发票接口(issue/search/invalid)**请求只发 `MerchantID_`+`PostData_`,不带 CheckValue**(见 `[[reference-ezpay-invoice-api]]` 文档勘误)。而 BDV(checkBarCode/checkLoveCode)需 CheckValue,且其公式官方示例存疑("接入若校验失败→改 HashIV 在前重试")。用 `invoice_search` 能干净地只测金钥对错,不受 CheckValue 不确定性影响,且只读不产生真实发票。业务错误(查不到发票)恰恰证明凭证可解密=有效。
+- **Alternatives**: ①checkLoveCode 测试——需 CheckValue,公式不确定,否;②不验证——用户体验差(录错到开票才暴露),用户选"录入时验证",否。
+
+## 决策 5:免用发票属性挂 pos_store,不挂 ezPay 表
+
+- **Decision**: `pos_store` 加 `invoice_exempt`(0需开票/1免用发票)。该属性决定门店是否纳入 ezPay 流程,并供将来自动开票判断"开票 vs 只开收据"。
+- **Rationale**: "免用发票"是门店的通用税务属性(小规模商家/部分摊位),非 ezPay 专属。免用门店根本不需要 ezPay 行,放 ezPay 表语义不通。将来订单完成开票也需读此字段,pos_store 是正确归属(与"凭证放独立表"不冲突——那是 ezPay 专属数据)。
+- **Alternatives**: ①放 `pos_store_ezpay.need_invoice`——免用门店不需 ezPay 行却要建行表达"不需要",语义别扭;②新建门店税务表——过度设计,YAGNI。
+
+## 决策 6:标记免用发票由平台运营操作,非商家
+
+- **Decision**: `invoice_exempt` 由平台运营在后台切换;商家端不提供此开关,只提供上传统编。
+- **Rationale**: 免用发票是税务判定(月营业额、国税局核定),运营掌握;与"平台代申请"模型一致。商家自助误标会导致漏开票合规风险。
+
+## 依赖与集成
+
+- **复用现有**:`EzPay`/`EzPayConfig`/`EzPayEncryptUtil`(2026-06-15 已实现并经官方数据验证),本期不改其内部,仅业务层调用 `EzPay.doPost` + `EzPayConfig` 构造。
+- **若依框架**:`BaseController`/`AjaxResult`/`TableDataInfo`/`@PreAuthorize`/`startPage()` 分页、`@Log` 审计、`MessageUtils.message()` 国际化消息。
+- **前端**:Element UI 表格 + 弹窗;vue-i18n 四语言(vi/zh/tw/en),新 key 加到 `storeEzpay:{}` 对象层级(遵循 `[[feedback-i18n-key-naming]]`)。

+ 141 - 0
specs/009-ezpay-invoice-onboarding/spec.md

@@ -0,0 +1,141 @@
+# Feature Specification: 商家 ezPay 发票开通管理
+
+**Feature Branch**: 不新建分支(在当前 test 分支开发)
+
+**Created**: 2026-06-15
+
+**Status**: Draft
+
+**Input**: User description: "商家 ezPay 发票开通管理:平台代商家到 ezPay(线下人工)注册申请发票功能,申请通过拿到 HashKey/HashIV 后由运营录入并验证,验证通过标记已开通才能开发票。平台后台管理开通状态、哪些门店还没开通、哪些还要去开通;部分商家/门店免用发票不进流程。已有 EzPay 开票工具类,本功能只做管理+凭证录入,不含订单自动开票。"
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 运营查看门店 ezPay 开通全景并定位待办 (Priority: P1)
+
+平台运营在后台打开"ezPay 发票开通管理"页面,看到所有需要开票的门店(普通商家店铺 + 夜市下摊位门店)及其 ezPay 开通状态。运营能按状态筛选,并用"还没开通""还要去开通"快捷过滤,立刻知道哪些门店需要推动申请、哪些卡在审核、哪些已就绪。免用发票的门店清晰标注、不混进待办。
+
+**Why this priority**: 这是本功能的核心价值——让运营对全平台发票开通进度一目了然并采取行动。没有它,运营无法知道谁还没开通、谁要去开通,整个线下申请流程无法推进。
+
+**Independent Test**: 可通过"打开列表 → 用'还要去开通'过滤 → 得到一批待申请门店"独立验证,交付"运营知道下一步找谁"的价值。
+
+**Acceptance Scenarios**:
+
+1. **Given** 平台有若干门店(含普通店铺、夜市下摊位、免用发票门店),**When** 运营打开 ezPay 开通管理列表,**Then** 列表展示每个门店的名称、ezPay 状态(未申请/申请中/已开通/免用发票)、启用开关、统编是否已填。
+2. **Given** 列表已加载,**When** 运营点击"还要去开通"过滤,**Then** 仅显示"需开票且未申请"的门店,免用发票与已申请门店均不在结果中。
+3. **Given** 列表已加载,**When** 运营按状态筛选"申请中",**Then** 仅显示状态为申请中的需开票门店。
+
+---
+
+### User Story 2 - 运营发起申请并录入凭证验证开通 (Priority: P1)
+
+运营为某门店向 ezPay 线下提交注册申请后,在后台把该门店状态推进为"申请中";ezPay 审核通过、运营拿到专属凭证(商店代号 / HashKey / HashIV)后,回到后台录入凭证并触发验证。系统调用 ezPay 接口验证凭证可用,验证通过才标记为"已开通"并自动启用。
+
+**Why this priority**: 这是把"申请"变成"能开发票"的唯一路径,直接决定商家能否开票,与 US1 同为核心。
+
+**Independent Test**: 可通过"对某测试门店录入 ezPay 测试环境的真凭证 → 验证通过 → 状态变已开通"独立验证,交付"该门店具备开票前提"的价值。
+
+**Acceptance Scenarios**:
+
+1. **Given** 某门店处于"未申请",**When** 运营点击"发起申请",**Then** 状态变为"申请中",记录申请时间。
+2. **Given** 某门店处于"申请中",**When** 运营录入正确的 ezPay 凭证并点"验证并开通",**Then** 系统调用 ezPay 接口验证通过,状态变为"已开通"、启用开关打开、记录开通时间。
+3. **Given** 某门店处于"申请中",**When** 运营录入错误的 HashKey/HashIV 并点"验证并开通",**Then** 系统识别金钥错误,拒绝开通,状态保持"申请中",并回传可理解的错误提示。
+4. **Given** 验证调用发生网络异常,**When** 运营点"验证并开通",**Then** 不开通、不改变状态,提示运营稍后重试。
+
+---
+
+### User Story 3 - 运营标记免用发票门店 (Priority: P2)
+
+部分商家/门店是小规模商家或摊位,适用免用发票(不开统一发票、只开收据)。运营在后台把这类门店标记为"免用发票",它们从此退出 ezPay 开通待办,不被当作"还没开通/还要去开通"。
+
+**Why this priority**: 不标记会造成待办列表被本就不需要开票的门店淹没,但它是优化体验而非核心开通链路,故 P2。
+
+**Independent Test**: 可通过"把一个门店标记免用发票 → 它从'还要去开通'过滤中消失"独立验证。
+
+**Acceptance Scenarios**:
+
+1. **Given** 某门店为需开票状态,**When** 运营点击"标记为免用发票",**Then** 该门店不再出现在"还没开通""还要去开通"过滤结果中,列表中以"免用发票"标签展示。
+2. **Given** 某门店被标记为免用发票,**When** 运营点击"恢复需开票",**Then** 该门店回到需开票门店集合,按其 ezPay 状态参与筛选。
+
+---
+
+### User Story 4 - 商家在商家端上传门店统一编号 (Priority: P2)
+
+运营代商家向 ezPay 申请发票时需要商家的统一编号(统编)。商家在商家端门店设置里填写并保存统编,供运营申请时使用。
+
+**Why this priority**: 统编是申请的前置材料,但没有它运营仍可先发起申请流程;属于支持性输入,故 P2。
+
+**Independent Test**: 可通过"商家在门店设置填入统编 → 保存 → 运营后台该门店显示统编已填"独立验证。
+
+**Acceptance Scenarios**:
+
+1. **Given** 商家打开门店设置,**When** 商家填写统一编号并保存,**Then** 统编保存成功,平台后台该门店显示统编已填写。
+2. **Given** 某门店已被标记为免用发票,**When** 商家查看门店设置,**Then** 统编字段非必填/不强制(免用门店无需统编)。
+
+---
+
+### User Story 5 - 运营停用/恢复已开通门店 (Priority: P3)
+
+门店已开通 ezPay 发票后,运营可临时停用(如商家暂停营业、凭证需更换),停用期间视为不可开票;之后可一键恢复,无需重新走申请与验证。
+
+**Why this priority**: 已开通门店的运行时开关,偶发但必要;不影响开通链路本身,故 P3。
+
+**Independent Test**: 可通过"对已开通门店点停用 → 启用开关关闭 → 再点恢复 → 开关打开且状态仍为已开通"独立验证。
+
+**Acceptance Scenarios**:
+
+1. **Given** 某门店为"已开通且启用",**When** 运营点击"停用",**Then** 启用开关关闭,状态保持"已开通"。
+2. **Given** 某门店为"已开通但停用",**When** 运营点击"恢复",**Then** 启用开关打开,无需重新验证凭证。
+
+---
+
+### Edge Cases
+
+- 门店从"已开通"改为"免用发票"(罕见):保留 ezPay 凭证数据,以免用发票标识为准,不自动删除凭证。
+- 运营对未上传统编的门店发起申请:提示运营先让商家补全统编(或允许先申请、后补)。
+- 同一组 ezPay 凭证被录入到多个门店:本期不强制唯一,仅记录与提示。
+- 凭证录入后 ezPay 测试环境不可用:验证失败按"网络异常"分支处理,允许稍后重试,不锁死状态。
+- 夜市(userType=3)本身不开票:其账号不进入门店 ezPay 列表;夜市下的摊位门店(pos_store)正常参与。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 系统必须能按门店(pos_store)记录 ezPay 申请状态(未申请 / 申请中 / 已开通)与启用开关(启用 / 停用)。
+- **FR-002**: 系统必须为门店新增"是否免用发票"属性;该属性决定门店是否纳入 ezPay 开通流程。
+- **FR-003**: 平台后台必须提供门店 ezPay 开通管理列表,展示门店名称、ezPay 状态、启用开关、统编填写情况,支持按状态筛选(全部 / 免用发票 / 未申请 / 申请中 / 已开通)与门店名搜索。
+- **FR-004**: 平台后台必须提供"还没开通"(需开票且未申请或申请中)和"还要去开通"(需开票且未申请)两个快捷过滤,且二者均排除免用发票门店。
+- **FR-005**: 运营必须能将门店在"未申请 → 申请中 → 已开通"之间推进,并记录申请时间与开通时间。
+- **FR-006**: 运营必须能为门店录入 ezPay 凭证(商店代号 / HashKey / HashIV);录入时系统必须调用 ezPay 接口验证凭证可用性。
+- **FR-007**: 仅当凭证验证通过时系统才标记为"已开通"并启用;凭证金钥错误时必须拒绝开通、保留原状态并返回错误说明。
+- **FR-008**: 运营必须能对"已开通"门店切换启用开关(停用 / 恢复),切换不影响申请状态、不要求重新验证。
+- **FR-009**: 运营必须能将门店标记为"免用发票"或恢复为"需开票";标记为免用发票后该门店退出 ezPay 待办过滤。
+- **FR-010**: 商家必须在商家端能为门店上传统一编号(统编);免用发票门店的统编不强制。
+- **FR-011**: 只有"已开通且启用"的门店才被视为具备开票前提;本功能仅维护该状态,不含订单完成时的自动开票逻辑。
+- **FR-012**: 平台前端与商家前端所有新增面向用户文字必须实现四语言(vi / zh / tw / en)国际化。
+
+### Key Entities *(include if feature involves data)*
+
+- **PosStore(门店,已有)**:代表一个营业档口/店铺,是 ezPay 凭证的绑定单位。新增"是否免用发票"属性。一个门店对应一组 ezPay 凭证。
+- **门店 ezPay 配置(新增,与门店 1:1)**:记录该门店的 ezPay 申请状态、启用开关、商店代号(MerchantID)、HashKey、HashIV、会员编号(CompanyID,可选)、统一编号(统编)、申请时间、开通时间、最近一次凭证验证结果、备注。门店没有此配置行即视为"未申请"。
+- **ezPay 加值服务平台(外部)**:商家发票加值中心;本功能仅用其只读验证接口校验凭证,注册申请为线下人工。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 运营能在单一列表查看全平台需开票门店的 ezPay 开通状态,并能在 3 次操作内定位到"还要去开通"的门店清单。
+- **SC-002**: "还没开通""还要去开通"过滤结果中,免用发票门店的出现率为 0。
+- **SC-003**: 运营录入错误 ezPay 凭证时,系统 100% 拒绝开通并给出可理解的错误提示;任何错误凭证都不会进入"已开通"状态。
+- **SC-004**: 运营录入正确 ezPay 凭证时,系统验证通过并自动开通,单次操作完成。
+- **SC-005**: 商家能在商家端独立完成统编上传并保存成功;保存后运营后台可见。
+- **SC-006**: 夜市(userType=3)账号与免用发票门店均不进入 ezPay 开通待办,待办列表 100% 由需开票且未就绪的门店构成。
+
+## Assumptions
+
+- ezPay 商店注册与申请为线下人工流程(在 ezPay 官网完成),平台不做注册类 API 对接;平台仅跟踪状态与录入凭证。
+- 凭证验证使用 ezPay 测试环境(cinv.ezpay.com.tw)的只读查询接口,不产生真实发票或副作用。
+- 本功能只覆盖"申请/开通管理 + 凭证录入验证";订单完成时自动调用开票是后续独立功能,不在本期范围。
+- ezPay 凭证按门店(pos_store)绑定:普通商家店铺与夜市下摊位门店各需一组凭证;夜市(userType=3)本身不开票,不纳入。
+- 平台运营是免用发票标记与凭证录入的责任人(与"平台代申请"模型一致);商家端仅负责上传统编。
+- 现有 ezPay 开票/查询/加密工具类(EzPay、EzPayConfig、EzPayEncryptUtil)可复用,不在本期改动其内部实现。
+- 门店列表中"实际卖货门店"的判定(依据 pos_store 的 isStall / isNightMarket 字段组合)在实现阶段对齐现有数据口径。

+ 207 - 0
specs/009-ezpay-invoice-onboarding/tasks.md

@@ -0,0 +1,207 @@
+---
+
+description: "Task list for 商家 ezPay 发票开通管理"
+---
+
+# Tasks: 商家 ezPay 发票开通管理
+
+**Input**: Design documents from `/specs/009-ezpay-invoice-onboarding/`
+
+**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅, quickstart.md ✅
+
+**Tests**: 轻量。仅对最核心的「凭证验证判读逻辑」加单测(mock EzPay.doPost),其余按 quickstart.md 手测。项目无强制 TDD。
+
+**Organization**: 按 spec.md 五个 user story 组织,每个 story 可独立实现与测试。
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: 可并行(不同文件、无未完成依赖)
+- **[Story]**: 所属 user story(US1~US5)
+- 描述含精确文件路径
+
+## Path Conventions
+
+- **后端**(本仓库 `foodie_server`):
+  - 实体:`ruoyi-system/src/main/java/com/ruoyi/system/domain/`
+  - Mapper 接口:`ruoyi-system/src/main/java/com/ruoyi/system/mapper/`
+  - Mapper XML:`ruoyi-system/src/main/resources/mapper/chanting/`
+  - Service:`ruoyi-system/src/main/java/com/ruoyi/system/service/`(接口)与 `.../service/impl/`(实现)
+  - Controller:`ruoyi-admin/src/main/java/com/ruoyi/app/mendian/`
+  - ezPay 工具类(复用不改):`ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/`
+  - SQL:`updatesql/sql.md`
+- **平台后台前端**(兄弟仓库 `E:\QtwCode\foodie\foodie-admin-vue`):`src/api/`、`src/views/`、`src/lang/`(CRLF 换行,用 Python 脚本改)
+- **商家端前端**(兄弟仓库 `E:\QtwCode\foodie\foodie-store`):`src/lang/`、门店设置视图(CRLF 换行,用 Python 脚本改)
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: SQL 变更与权限菜单(交开发者手动执行)
+
+- [x] T001 在 `updatesql/sql.md` 追加 2026-06-15 段落:① `ALTER TABLE pos_store ADD COLUMN invoice_exempt TINYINT(1) DEFAULT 0 COMMENT '是否免用发票:0需开票/1免用发票'`;② 按 data-model.md 创建 `pos_store_ezpay` 表(含 `uk_store_id` 唯一键、`idx_ezpay_status` 索引);③ `sys_menu` 插入 `chanting:storeEzpay:list/query/apply/saveCredentials/toggleEnable/markExempt/reset` 权限项(参考既有门店菜单 SQL 写法)
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 数据模型地基,所有 user story 都依赖
+
+**⚠️ CRITICAL**: 本阶段完成前不得开始任何 user story
+
+- [x] T002 [P] 新建 `PosStoreEzpay` 实体于 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStoreEzpay.java`(MyBatis-Plus `@TableName("pos_store_ezpay")`、`@TableId(type=IdType.AUTO)`,字段见 data-model.md:id/storeId/ezpayStatus/isEnabled/merchantId/hashKey/hashIv/companyId/ubn/applyTime/approvedTime/lastVerifyResult/remark/createTime/updateTime/createBy/updateBy,用 lombok `@Data`)
+- [x] T003 [P] 在 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosStore.java` 新增 `invoiceExempt`(Integer)字段 + getter/setter(遵循该文件既有手写 getter 风格)
+- [x] T004 新建 `PosStoreEzpayMapper` 接口于 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosStoreEzpayMapper.java`,继承 MyBatis-Plus `BaseMapper<PosStoreEzpay>`,声明分页查询方法 `selectEzpayStoreList(...)`(参数见 contracts/api.md 列表入参)
+- [x] T005 [P] 新建 `PosStoreEzpayMapper.xml` 于 `ruoyi-system/src/main/resources/mapper/chanting/PosStoreEzpayMapper.xml`:resultMap(门店字段 + ezPay 字段)+ `selectEzpayStoreList`(`pos_store LEFT JOIN pos_store_ezpay`,过滤实际卖货门店 isStall,支持 ezpayStatus/invoiceExempt/posName/isStall + quickFilter=needApply|notEnabled,LEFT JOIN 无行时 ezpayStatus 按 0 处理)
+- [x] T006 [P] 修改 `ruoyi-system/src/main/resources/mapper/chanting/PosStoreMapper.xml`:resultMap 加 `invoice_exempt`、相关 select 列加 `invoice_exempt`(供门店基础接口携带该字段)
+- [x] T007 新建 `IPosStoreEzpayService` 接口与 `PosStoreEzpayServiceImpl` 实现于 `ruoyi-system/src/main/java/com/ruoyi/system/service/` 与 `.../impl/`,注入 `PosStoreEzpayMapper`、`PosStoreMapper`、`EzPay`,声明占位方法(list/detail/apply/saveCredentials/toggleEnable/markExempt/reset/uploadUbn),具体业务在后续 story 实现
+
+**Checkpoint**: 表结构 + 实体 + mapper + service 骨架就绪,user story 可开始
+
+---
+
+## Phase 3: User Story 1 - 运营查看门店 ezPay 开通全景并定位待办 (Priority: P1) 🎯 MVP
+
+**Goal**: 平台后台列表展示所有需开票门店的 ezPay 状态,支持按状态筛选与"还没开通/还要去开通"快捷过滤(均排除免用发票门店)
+
+**Independent Test**: 打开"ezPay 发票开通管理" → 点"还要去开通" → 仅显示需开票且未申请门店(免用发票门店不在内)
+
+### Implementation for User Story 1
+
+- [x] T008 [US1] 实现 `PosStoreEzpayServiceImpl.listEzpayStore(...)`(组装查询参数含 quickFilter→ezpayStatus 映射,调用 mapper 分页,无 ezPay 行的门店补 status=0/invoiceExempt 取自 pos_store)依赖 T005、T007
+- [x] T009 [US1] 新建 `PosStoreEzpayController` 于 `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosStoreEzpayController.java`,`@RequestMapping("/system/storeEzpay")`,实现 `GET /list`(`@PreAuthorize("@ss.hasPermi('chanting:storeEzpay:list')")` + `startPage()` + 返回 `TableDataInfo`)与 `GET /{storeId}` 详情 依赖 T008
+- [x] T010 [P] [US1] 平台前端新建 `E:\QtwCode\foodie\foodie-admin-vue\src\api\mendian\storeEzpay.js`(list/detail/apply/saveCredentials/toggleEnable/markExempt 接口封装)与页面 `src\views\mendian\storeEzpay\index.vue`(Element 表格:门店名/类型/ezPay状态/启用/统编是否已填/操作;顶部筛选:状态下拉 + 门店名搜索 + "还没开通/还要去开通"快捷按钮)依赖 T009
+- [x] T011 [P] [US1] 平台前端 i18n:在 `foodie-admin-vue\src\lang\` 的 zh.js/tw.js/en.js/vi.js 新增 `storeEzpay:{}` 对象层级(标题/表头/状态文案/筛选按钮等 key,驼峰命名,四个文件 key 一致)
+
+**Checkpoint**: US1 可独立验收——列表 + 筛选 + 快捷过滤生效
+
+---
+
+## Phase 4: User Story 2 - 运营发起申请并录入凭证验证开通 (Priority: P1)
+
+**Goal**: 未申请→发起申请→申请中;运营录入 ezPay 凭证(商店代号/HashKey/HashIV)后系统调 ezPay 验证,通过才标记已开通并启用
+
+**Independent Test**: 对测试门店录入 ezPay 测试环境真凭证→验证通过→状态变已开通;故意填错 HashKey→提示凭证无效、状态保持申请中
+
+### Implementation for User Story 2
+
+- [x] T012 [P] [US2] 新建 `StoreEzpayCredentialDto` 于 `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/dto/StoreEzpayCredentialDto.java`(字段 storeId/merchantId/hashKey/hashIv/companyId,校验注解:merchantId 必填、hashKey 长度 32、hashIv 长度 16)
+- [x] T013 [US2] 实现 `PosStoreEzpayServiceImpl.verifyAndSaveCredentials(dto)`:构造 `EzPayConfig(merchantId,hashKey,hashIv)` → `ezPay.doPost(EzPay.BASE_TEST + EzPay.URL_SEARCH, cfg, {RespondType:JSON,Version:1.3,SearchType:0,InvoiceNumber:测试号,RandomNum:测试码})` → 判读:回应含 `KEY1` → 凭证无效,记 `last_verify_result`、保持 status、抛 ServiceException;回应业务错误或成功 → `ezpayStatus=2`、`isEnabled=1`、`approvedTime=now`、存凭证、记通过。网络异常→抛可重试错误、不改状态 依赖 T007、复用 `EzPay`/`EzPayConfig`
+- [x] T014 [US2] 在 `PosStoreEzpayController` 加 `PUT /apply/{storeId}`(0→1,建议校验 ubn 已填,记 applyTime)、`PUT /saveCredentials`(调 T013)、`PUT /reset/{storeId}`,均带 `@PreAuthorize` 与 `@Log` 依赖 T012、T013
+- [x] T015 [US2] 平台前端 `index.vue` 加:行内"发起申请"按钮;"录入凭证"弹窗(表单:商店代号/HashKey/HashIV/会员编号 + "验证并开通"按钮,调用 saveCredentials,失败展示 ezPay 错误文案)依赖 T010、T014
+- [x] T016 [US2] 平台前端 i18n:在 `storeEzpay:{}` 下补充 US2 相关 key(发起申请/录入凭证/凭证无效提示/各字段名),四语言一致
+- [ ] T017 [P] [US2] 单元测试:`PosStoreEzpayServiceImplTest`(mock `EzPay.doPost`)验证两条分支——回应含 KEY1→判无效且 status 不变;回应业务错误/成功→判通过且 status=2。可选,按项目测试习惯放 `ruoyi-system/src/test/...`
+
+**Checkpoint**: US2 可独立验收——申请推进 + 凭证录入验证开通/拒绝双分支
+
+---
+
+## Phase 5: User Story 3 - 运营标记免用发票门店 (Priority: P2)
+
+**Goal**: 运营把门店标记为免用发票(或恢复需开票),标记后退出待办过滤
+
+**Independent Test**: 把一个需开票门店标记免用发票 → 它从"还要去开通/还没开通"过滤消失;恢复后回归
+
+### Implementation for User Story 3
+
+- [x] T018 [US3] 实现 `PosStoreEzpayServiceImpl.markExempt(storeId, invoiceExempt)`(更新 `pos_store.invoice_exempt`)+ `PosStoreEzpayController` `PUT /markExempt/{storeId}`(入参 `{invoiceExempt}`,`@PreAuthorize('chanting:storeEzpay:markExempt')`)依赖 T007
+- [x] T019 [US3] 平台前端 `index.vue` 加"标记免用发票/恢复需开票"操作按钮(调 markExempt,成功后刷新列表)+ 对应 i18n key 进 `storeEzpay:{}`(四语言一致)依赖 T018
+
+**Checkpoint**: US3 可独立验收——免用标记影响待办过滤
+
+---
+
+## Phase 6: User Story 4 - 商家在商家端上传门店统一编号 (Priority: P2)
+
+**Goal**: 商家在商家端门店设置填统一编号并保存,平台后台可见
+
+**Independent Test**: 商家填统编保存 → 平台后台该门店显示统编已填
+
+### Implementation for User Story 4
+
+- [x] T020 [US4] 后端 UBN 上传:在 `PosStoreController`(`/chanting/store`)扩展——`addmendian`/edit 保存门店时 upsert `pos_store_ezpay.ubn`(按 storeId,无行则建行 status=0);或新增 `POST /saveUbn {storeId,ubn}` 带 `@Auth`+JWT 校验 storeId 归属当前商家。商家端仅能写 ubn,不可改状态/凭证/免用 依赖 T007
+- [x] T021 [P] [US4] 商家端前端:`E:\QtwCode\foodie\foodie-store` 门店设置表单加"统一编号"字段(保存随门店提交);`src/lang/` 的 zh.js/tw.js/en.js/vi.js 加统编相关 key(四语言一致,CRLF 文件用 Python 脚本改)依赖 T020
+
+**Checkpoint**: US4 可独立验收——商家上传统编、后台可见
+
+---
+
+## Phase 7: User Story 5 - 运营停用/恢复已开通门店 (Priority: P3)
+
+**Goal**: 已开通门店可临时停用/恢复,不影响申请状态、无需重新验证
+
+**Independent Test**: 已开通门店点停用→启用关→点恢复→启用开,状态仍已开通
+
+### Implementation for User Story 5
+
+- [x] T022 [US5] 实现 `PosStoreEzpayServiceImpl.toggleEnable(storeId)`(前置 status=2,翻转 isEnabled 1↔0,否则抛"当前状态不允许此操作")+ `PosStoreEzpayController` `PUT /toggleEnable/{storeId}`(`@PreAuthorize('chanting:storeEzpay:toggleEnable')`)依赖 T014(需 status=2 数据)
+- [x] T023 [US5] 平台前端 `index.vue` 对已开通行显示"启用/停用"开关(调 toggleEnable)+ 对应 i18n key 进 `storeEzpay:{}`(四语言一致)依赖 T022
+
+**Checkpoint**: US5 可独立验收——已开通门店停用/恢复
+
+---
+
+## Phase 8: Polish & Cross-Cutting Concerns
+
+- [ ] T024 [P] 按 `quickstart.md` 跑通手测:执行 SQL → ezPay 测试环境拿真凭证 → 发起申请 → 录入正确/错误凭证验证双分支 → 停用/恢复 → 标记免用 → 商家上传统编,逐条核对 spec.md 的 SC-001~SC-006
+- [x] T025 [P] 更新记忆索引:在 `C:\Users\qmj\.claude\projects\E--QtwCode-foodie-foodie-server\memory\MEMORY.md` 加一行指向 `specs/009-ezpay-invoice-onboarding/spec.md`(参考 008 的写法)
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Phase 1 Setup**:无依赖,立即开始(SQL 交开发者手动执行)
+- **Phase 2 Foundational**:依赖 Phase 1(表先建好);**阻塞所有 user story**
+- **Phase 3 US1 (P1)**:依赖 Foundational
+- **Phase 4 US2 (P1)**:依赖 Foundational;US2 的前端复用 US1 的页面
+- **Phase 5 US3 (P2)**:依赖 Foundational(需 invoice_exempt 字段);与 US1/US2 独立
+- **Phase 6 US4 (P2)**:依赖 Foundational;与其它 story 独立
+- **Phase 7 US5 (P3)**:依赖 US2(需"已开通"门店数据)
+- **Phase 8 Polish**:依赖所有欲交付的 story 完成
+
+### Within Each User Story
+
+- DTO/实体(不同文件)可并行
+- Service 先于 Controller,Controller 先于前端
+- 前端页面 index.vue 跨 story 是同一文件 → **顺序进行**(US1 建页,US2/US3/US5 在其上增量),不并行
+- 四语言 i18n 文件跨 story 同文件(zh.js 等)→ **顺序追加**,不并行
+
+### Parallel Opportunities
+
+- Phase 2:T002/T003/T005/T006 不同文件可并行(T004 依赖 T002,T007 依赖 T004/T005)
+- 后端 track 与前端 track 可并行(US1 后端 T008/T009 与前端 T010/T011)
+- US4 商家端(foodie-store)与 US1~US3/US5 平台端(foodie-admin-vue)不同仓库 → 可并行
+- 单测 T017 与前端 T015/T016 不同文件可并行
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Story 1 Only)
+
+1. Phase 1 Setup:写 SQL(开发者执行)
+2. Phase 2 Foundational:建实体/mapper/service 骨架
+3. Phase 3 US1:列表 + 筛选 + 快捷过滤
+4. **STOP 验收**:运营能在列表看到全部门店状态、"还要去开通"过滤正确
+5. 此时已交付核心价值——运营知道谁要推动开通
+
+### Incremental Delivery
+
+1. Setup + Foundational → 地基就绪
+2. +US1 → 列表/筛选(MVP,可演示)
+3. +US2 → 申请推进 + 凭证验证开通(核心开通链路打通)
+4. +US3 → 免用发票标记
+5. +US4 → 商家上传统编
+6. +US5 → 停用/恢复
+7. Polish:quickstart 手测 + 记忆索引
+
+---
+
+## Notes
+
+- ezPay 工具类 `EzPay`/`EzPayConfig`/`EzPayEncryptUtil` 已实现并经官方数据验证,**本期不改其内部**,仅业务层调用
+- 凭证验证务必用 `invoice_search`(不走 CheckValue),KEY1xxxx=无效、业务错误/成功=有效
+- 前端两个仓库均为 CRLF,编辑 lang 文件用 Python 脚本(见 CLAUDE.md「前端文件编辑注意事项」)
+- 所有前端新增文字必须四语言 i18n,key 加到正确对象层级、驼峰命名
+- SQL 不直接执行,写 `updatesql/sql.md` 由开发者手动跑

+ 36 - 0
specs/010-order-invoice/checklists/requirements.md

@@ -0,0 +1,36 @@
+# Specification Quality Checklist: 订单 ezPay 电子发票开立
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-06-16
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [x] No implementation details (languages, frameworks, APIs)
+- [x] Focused on user value and business needs
+- [x] Written for non-technical stakeholders
+- [x] All mandatory sections completed
+
+## Requirement Completeness
+
+- [x] No [NEEDS CLARIFICATION] markers remain
+- [x] Requirements are testable and unambiguous
+- [x] Success criteria are measurable
+- [x] Success criteria are technology-agnostic (no implementation details)
+- [x] All acceptance scenarios are defined
+- [x] Edge cases are identified
+- [x] Scope is clearly bounded
+- [x] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [x] All functional requirements have clear acceptance criteria
+- [x] User scenarios cover primary flows
+- [x] Feature meets measurable outcomes defined in Success Criteria
+- [x] No implementation details leak into specification
+
+## Notes
+
+- spec 基于已落地的 009-ezpay-invoice-onboarding 与已验证的 ezPay API 规格撰写,业务能力边界清晰,全部采用合理默认,无 NEEDS CLARIFICATION。
+- 范围明确划界:本期不含发票折让、字轨管理、批次开立、中奖通知(见 Assumptions)。
+- 可直接进入 `/speckit-clarify`(如需进一步澄清)或 `/speckit-plan`(生成技术方案)。

+ 71 - 0
specs/010-order-invoice/contracts/api.md

@@ -0,0 +1,71 @@
+# Phase 1 API Contracts: 订单 ezPay 电子发票开立
+
+**Date**: 2026-06-16
+
+后端 REST 接口契约。客户端开票挂在 `UserOrderController`;管理端(商家+平台)查看/重试/作废用独立 `PosOrderInvoiceController`。
+
+## 一、客户端(客户为自己的订单开票)
+
+挂在 `UserOrderController`(`@RequestMapping("/system/userOrder")`,已有 JWT 鉴权)。
+
+### POST `/system/userOrder/applyInvoice`
+客户对已完成订单申请开票。
+
+**请求体** `ApplyInvoiceDto`:
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| orderId | Long | 是 | 订单 id(须为当前登录客户的订单、state=3、payStatus=1) |
+| category | String | 是 | `B2C` / `B2B` |
+| buyerName | String | 是 | 买方名称(B2C 客户名 / B2B 公司名) |
+| buyerUbn | String | B2B 必填 | 统一编号 8 码(B2B 必填且校验格式) |
+| buyerEmail | String | B2C 无载具时必填 | 接收邮箱 |
+| carrierType | String | 否 | `0`手机条码 / `1`自然人凭证 / `2`ezPay会员载具 |
+| carrierNum | String | carrierType 非空时必填 | 载具号码 |
+
+**处理**:校验订单归属与可开票(门店 ezPay 已开通启用、非免用发票)→ 金额拆分 → 组装明细 → 调 `EzPay.issueInvoice` → 落库发票号/随机码/凭证。
+
+**响应** `AjaxResult`:
+- 成功 `{code:200, msg:"开票成功", data:{invoiceNumber, invoiceUrl}}`
+- 失败 `{code:500, msg:"<可理解原因>"}`(统编格式非法 / 门店不可开票 / 已开票 / ezPay 失败原因)
+
+### GET `/system/userOrder/getInvoice?orderId=`
+客户查看自己订单的发票状态。
+
+**响应** `AjaxResult` data:`{invoiceStatus, invoiceNumber, invoiceUrl, category, ...}`(不可开票门店返回 `invoiceStatus=null` 前端隐藏入口)。
+
+---
+
+## 二、管理端(商家端 + 平台后台)
+
+`PosOrderInvoiceController`(`@RequestMapping("/system/orderInvoice")`,权限键 `chanting:orderInvoice:*`)。
+
+### GET `/system/orderInvoice/list`
+订单发票列表(分页 + 筛选)。
+
+**入参**(query):`orderId`/`orderNo`/`storeId`/`invoiceStatus`/`invoiceCategory`/日期范围 + `startPage()`。
+
+**响应** `TableDataInfo`,行 `PosOrderInvoiceVo`:订单号、门店、发票类型、买方、发票号、状态、金额、开立/作废时间。
+
+### GET `/system/orderInvoice/{orderId}`
+订单发票详情。
+
+### PUT `/system/orderInvoice/retry/{orderId}`
+重试开票(仅 `invoiceStatus ∈ {2 失败, 3 作废}`)。权限 `chanting:orderInvoice:retry`。
+
+### PUT `/system/orderInvoice/invalid/{orderId}`
+作废发票(仅 `invoiceStatus = 1 已开`)。权限 `chanting:orderInvoice:invalid`。入参可选 `{invalidReason}`。
+
+---
+
+## 三、ezPay 外部调用(复用工具类,非新接口)
+
+| 业务 | 工具类方法 | ezPay 端点 |
+|------|-----------|-----------|
+| 开立 | `EzPay.issueInvoice(baseUrl, cfg, postData)` | `/Api/invoice_issue` (v1.5) |
+| 作废 | `EzPay.doPost(baseUrl + URL_INVALID, cfg, postData)` | `/Api/invoice_invalid` (v1.0) |
+
+`EzPayConfig` 由门店 `pos_store_ezpay` 的 `merchantId`/`hashKey`/`hashIv` 构造;`baseUrl` 测试 `BASE_TEST`、正式 `BASE_PROD`(按环境配置)。
+
+## 四、菜单 / 权限(写入 updatesql/sql.md)
+
+`sys_menu` 新增「订单发票管理」(C) + 按钮权限 `chanting:orderInvoice:list/query/retry/invalid`,挂在平台后台门店/订单相关父菜单下(参考 009 菜单写法)。

+ 74 - 0
specs/010-order-invoice/data-model.md

@@ -0,0 +1,74 @@
+# Phase 1 Data Model: 订单 ezPay 电子发票开立
+
+**Date**: 2026-06-16
+
+## 新增表:pos_order_invoice(订单电子发票,与 pos_order 1:1)
+
+> DDL 写入 `updatesql/sql.md`,由开发者手动执行(项目规范)。当前态表:一笔订单至多一行;作废后状态置「作废」、允许重开(同行覆盖,MVP 不留历史明细)。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | BIGINT PK AUTO | 主键 |
+| order_id | BIGINT NOT NULL | 关联 `pos_order.id`(UNIQUE) |
+| order_no | VARCHAR(64) | 冗余 `pos_order.ddId`,便于排查 |
+| store_id | BIGINT NOT NULL | 冗余 `pos_order` 所属门店 id(关联 `pos_store.id`) |
+| invoice_category | VARCHAR(8) | 发票类型:`B2C` / `B2B` |
+| buyer_name | VARCHAR(100) | 买方名称(B2C=客户名/邮箱名,B2B=公司名) |
+| buyer_ubn | VARCHAR(16) | 买方统一编号(统编 8 码,B2B 必填) |
+| buyer_email | VARCHAR(200) | 接收邮箱(B2C 无载具时用) |
+| carrier_type | VARCHAR(8) | 载具类型:`0`手机条码/`1`自然人凭证/`2`ezPay会员载具(可空) |
+| carrier_num | VARCHAR(64) | 载具号码(carrier_type 非空时必填) |
+| invoice_number | VARCHAR(16) | ezPay 发票号(开立成功后填,字轨号段分配) |
+| random_num | VARCHAR(8) | 防伪随机码(ezPay 返回) |
+| invoice_status | TINYINT NOT NULL DEFAULT 0 | `0`未开 / `1`已开 / `2`失败 / `3`作废 |
+| total_amt | INT | 含税总额(= 订单实付 amount) |
+| sales_amt | INT | 销售额(未税,= round(total/1.05)) |
+| tax_amt | INT | 税额(= total - sales) |
+| fail_reason | VARCHAR(500) | 开票/作废失败原因 |
+| invoice_url | VARCHAR(500) | 发票查看凭证 / 链接 |
+| apply_time | DATETIME | 客户申请开票时间 |
+| issue_time | DATETIME | 开立成功时间 |
+| invalid_time | DATETIME | 作废时间 |
+| create_time | DATETIME | 创建时间 |
+| update_time | DATETIME | 更新时间 |
+| create_by | VARCHAR(64) | 创建人 |
+| update_by | VARCHAR(64) | 更新人 |
+
+**索引**:
+- `UNIQUE KEY uk_order_id (order_id)` —— 防重复开票(D5)
+- `KEY idx_status (invoice_status)`
+- `KEY idx_store (store_id)`
+
+## 状态机(invoice_status)
+
+```
+        开票成功              作废(ezPay invalid 成功)
+  ┌─────────────────┐   ┌──────────────────┐
+  ▼                 │   ▼                  │
+[0 未开] ──成功──> [1 已开] ──作废──> [3 作废]
+  │                 │
+  │ 失败            │ 失败
+  ▼                 ▼
+[2 失败] <──重试─── (失败/作废/未开 均可重试开票)
+  └──重试成功──> [1 已开]
+```
+
+- 开票前置:`invoice_status ∈ {0 未开, 2 失败, 3 作废}` 才允许发起开票;`1 已开` 拒绝(FR-005)。
+- 重开:作废(3) 后可再次开票 → 状态先置 0、再走开票成功置 1(同行,新 invoice_number 覆盖旧号)。
+
+## 复用实体(不改)
+
+- **PosOrder**(`pos_order`):开票来源。取 `id`/`ddId`/`amount`/`food`/`state`(=3 完成)/`payStatus`(=1)/门店归属(`shId`/`mdId`)。门店 id 判定:依据 CLAUDE.md 商家类型口径(普通/夜市商家用 `shId`,摊位用 `mdId`)对齐 `pos_store`。
+- **PosStoreEzpay**(009,`pos_store_ezpay`):开票凭证来源。仅 `ezpayStatus=2 且 isEnabled=1` 的门店可开票;取 `merchantId`/`hashKey`/`hashIv` 构造 `EzPayConfig`。
+- **PosStore**(`pos_store`):`invoice_exempt=1` 的门店不提供开票(009)。
+
+## 金额拆分(开票时计算,不入订单表)
+
+按 research D1(**不含运费**,订单构成 `amount = foodAmount − 优惠 + freight`):
+```
+invoice_total = amount - freight            // 商品实付(已扣优惠、不含运费)
+total_amt     = invoice_total               // 含税总额
+sales_amt     = round(invoice_total / 1.05) // 销售额未税
+tax_amt       = total_amt - sales_amt       // 税额
+```
+逐商品明细由 `food` JSON 组装(D2),优惠按比例分摊到各商品使 ΣItemAmt = total_amt(D3);运费不进发票。

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

@@ -0,0 +1,107 @@
+# Implementation Plan: 订单 ezPay 电子发票开立
+
+**Branch**: `test`(不新建分支,在当前分支开发) | **Date**: 2026-06-16 | **Spec**: [spec.md](./spec.md)
+
+**Input**: Feature specification from `specs/010-order-invoice/spec.md`
+
+## Summary
+
+订单完成后客户在客户端主动申请电子发票(B2C 个人邮箱 / B2B 公司统编 / 电子发票载具),系统读取门店 ezPay 凭证(来自 009 的 `pos_store_ezpay`),复用已实现的 `EzPay.issueInvoice` 即时开立,将发票号 / 防伪随机码落库到新表 `pos_order_invoice`(与订单 1:1),客户可查看发票;支持开票失败重试与发票作废。金额按订单商品实付(`amount − freight`,已扣全部优惠、不含运费)拆分为销售额 / 税额(台湾营业税 5% 内含),逐商品明细取自订单 `food` JSON,运费不计入发票。客户端开票/查询 API 供客户端团队对接(客户端 UI 不在本期);商家端、平台后台管理端查看/重试/作废,新增文字四语言 i18n。
+
+## Technical Context
+
+**Language/Version**: Java 17+(Spring Boot,若依 RuoYi 框架;`jakarta.servlet`、`HexFormat` 表明 JDK17+)
+
+**Primary Dependencies**:
+- 后端:Spring Boot、MyBatis-Plus(`@TableName`/`@TableId`/`LambdaQueryWrapper`)+ XML mapper、Apache HttpClient、fastjson2、hutool、若依通用(`AjaxResult`/`TableDataInfo`/`BaseController`/`@PreAuthorize`)
+- 复用(不改内部):`com.ruoyi.app.utils.ezPay.EzPay`(issueInvoice/doPost)、`EzPayConfig`、`ezPayCrypto.EzPayEncryptUtil`
+- 复用 009:`PosStoreEzpay`(门店 ezPay 凭证:merchantId/hashKey/hashIv/companyId、ezpayStatus、isEnabled)、`PosStoreEzpayMapper`
+- 复用订单:`PosOrder`(id/ddId/amount/foodAmount/state/payStatus/food 等)、`UserOrderController`(客户端订单 `/system/userOrder`)
+- 前端:Vue.js + Element UI(平台后台 `foodie-admin-vue`、商家端 `foodie-store`,vue-i18n 四语言);客户端前端 UI 不在本期,本期交付开票/查询后端 API 供客户端团队对接
+
+**Storage**: MySQL(新增表 `pos_order_invoice`,与 `pos_order` 1:1)
+
+**Testing**: 轻量单测(开票金额含税拆分 + ezPay 回应判读,mock `EzPay.issueInvoice`)+ 手测(`cinv.ezpay.com.tw` 测试环境真凭证开票 / 作废)
+
+**Target Platform**: Windows 开发 / Linux 部署的 Spring Boot 服务 + Vue 后台/商家端 + 客户端
+
+**Project Type**: web-service(Spring Boot 后端)+ 多个 Vue / 小程序前端
+
+**Performance Goals**: 开票调用(含 ezPay 网络往返)< 10s 超时;订单详情接口携带发票状态无显著额外开销
+
+**Constraints**: 复用 ezPay 工具类不改其内部;不改订单状态机(订单「完成」由现有 `state=3` 判定);发票作废以 ezPay `invoice_invalid` 接口返回为准,不在本地做时限计算;本期不含折让 / 字轨 / 批次 / 中奖通知
+
+**Scale/Scope**: 1 新表;后端 1 domain + 1 mapper(含XML) + 1 service + 2 controller 域(客户端开票 + 管理端查看/重试/作废);管理端两前端(平台后台 + 商家端)最小入口;4 语言 i18n ×2 前端
+
+## Constitution Check
+
+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
+
+`.specify/memory/constitution.md` 为未填充模板(占位符未替换),项目未定义具体宪法原则与门槛。**无实质门槛需评估**,设计遵循项目既有约定(若依分层、MyBatis-Plus、全栈字段清单、四语言 i18n、SQL 写 `updatesql/sql.md`)。Phase 1 后复核:无违反。
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/010-order-invoice/
+├── spec.md              # 需求规格
+├── plan.md              # 本文件(技术方案)
+├── research.md          # Phase 0:关键技术决策与依据
+├── data-model.md        # Phase 1:表结构、字段、状态机
+├── quickstart.md        # Phase 1:跑通验证步骤
+├── contracts/
+│   └── api.md           # Phase 1:REST 接口契约
+├── checklists/
+│   └── requirements.md  # spec 质量检查
+└── tasks.md             # /speckit-tasks 生成(本期 plan 不创建)
+```
+
+### Source Code (repository root)
+
+```text
+# ===== 后端 foodie_server =====
+ruoyi-system/src/main/java/com/ruoyi/system/
+├── domain/
+│   ├── PosOrderInvoice.java                 # 新增:订单发票实体(与 pos_order 1:1)
+│   ├── dto/ApplyInvoiceDto.java             # 新增:客户申请开票入参
+│   └── vo/PosOrderInvoiceVo.java            # 新增:发票列表/详情 VO
+├── mapper/
+│   └── PosOrderInvoiceMapper.java           # 新增
+└── resources/mapper/chanting/
+    └── PosOrderInvoiceMapper.xml            # 新增
+
+ruoyi-admin/src/main/java/com/ruoyi/app/
+├── order/
+│   ├── OrderInvoiceService.java             # 新增:开票/作废/查询业务(注入 EzPay + 订单 + 009凭证)
+│   └── UserOrderController.java             # 已有:新增客户端开票接口 /applyInvoice、/getInvoice
+├── mendian/
+│   └── PosOrderInvoiceController.java       # 新增:管理端(商家+平台)查看/重试/作废 /system/orderInvoice
+└── utils/ezPay/                              # 已有,不改内部:EzPay/EzPayConfig/EzPayEncryptUtil
+
+# ===== SQL =====
+updatesql/sql.md                             # 追加建 pos_order_invoice 表
+
+# ===== 平台后台前端 foodie-admin-vue =====
+src/api/chanting/orderInvoice.js             # 新增:接口封装
+src/views/mendian/orderInvoice/index.vue     # 新增:订单发票管理(查看/重试/作废)
+src/api/language/{zh_CN,zh_TW,en_US,vi}.js   # 四语言加 orderInvoice:{} key
+
+# ===== 商家端前端 foodie-store =====
+门店订单详情                                 # 显示发票状态/号
+src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
+```
+
+**Structure Decision**:
+- 后端遵循既有若依分层:`domain/mapper` 放 `ruoyi-system`(被各处依赖);**开票业务 service 放 `ruoyi-admin.app.order.OrderInvoiceService`**,因为它要调用 `EzPay`(在 ruoyi-admin)+ 读订单 + 读 009 凭证——与 009「ezPay 联网放 Controller、Service 仅持久化」不同,本期开票业务较重,单独 service 更内聚,调用链:Controller → OrderInvoiceService → EzPay.issueInvoice。
+- 客户端开票接口挂在既有 `UserOrderController`(`/system/userOrder`)下,与订单详情同域;管理端(商家+平台)查看/重试/作废用独立 `PosOrderInvoiceController`(`/system/orderInvoice`,权限键 `chanting:orderInvoice:*`)。
+- 发票与订单 1:1:`pos_order_invoice` 当前态表(`uk_order_id` 唯一约束防重复开票),作废后状态置「作废」允许重开(不开历史明细表,作废记录保留在同一行)。
+- 管理端两前端(平台后台 + 商家端)各自最小入口,严格四语言 i18n;客户端 UI 不在本期。
+
+## Complexity Tracking
+
+> 无宪法违反项,无需填写。
+
+| Violation | Why Needed | Simpler Alternative Rejected Because |
+|-----------|------------|-------------------------------------|
+| — | — | — |

+ 59 - 0
specs/010-order-invoice/quickstart.md

@@ -0,0 +1,59 @@
+# Quickstart: 订单 ezPay 电子发票开立
+
+**Date**: 2026-06-16
+
+**范围**:客户端前端 UI 不在本期——开票/查询通过 curl/Postman 调 API(`/system/userOrder/applyInvoice`、`/getInvoice`);查看/重试/作废通过平台后台 UI。客户端团队后续对接 UI。
+
+手测验收步骤(项目无强制 TDD,按此手测 + 可选单测)。前置:009 已上线(门店 ezPay 已开通启用 + 凭证录入)。
+
+## 0. 准备
+1. 执行 `updatesql/sql.md` 中 010 段落(建 `pos_order_invoice` 表 + 菜单权限)。
+2. 选一个**已完成订单**:`pos_order.state=3`、`pay_status=1`,其所属门店在 009 中 `ezpay_status=2 且 is_enabled=1`、`invoice_exempt=0`。
+3. ezPay 测试环境 `cinv.ezpay.com.tw` 的商店代号/HashKey/HashIV 已通过 009 录入验证。
+
+## 1. B2C 个人发票(邮箱)— US1 / SC-001 / SC-004
+- 客户端调 `POST /system/userOrder/applyInvoice`:`{orderId, category:"B2C", buyerName, buyerEmail}`。
+- **预期**:返回 `invoiceNumber` + 凭证;DB `pos_order_invoice.invoice_status=1`、`invoice_number` 已填、`total_amt/sales_amt/tax_amt` 三栏自洽(`sales+tax=total`)。
+- **重复开票**:再调一次 → 拒绝(已开票),SC-004 通过。
+
+## 2. B2B 公司发票(统编)— US2 / SC-003
+- `applyInvoice`:`{orderId, category:"B2B", buyerName:"某公司", buyerUbn:"12345678"}`。
+- **预期**:开立成功,发票记录买方统编。
+- **非法统编**:传 `buyerUbn:"12A"` → 提交前被拦截、不调 ezPay,SC-003 通过。
+
+## 3. 载具(手机条码)— US3
+- `applyInvoice`:`{orderId, category:"B2C", buyerName, carrierType:"0", carrierNum:"/AB1234+"}`。
+- **预期**:校验条码后开立,发票存入载具(PrintFlag=N)。
+
+## 4. 不可开票门店 — US1-场景2 / SC-002 / SC-006
+- 对一个**免用发票**(`invoice_exempt=1`)或 ezPay 未开通/未启用的门店订单调 `applyInvoice`。
+- **预期**:拒绝并提示门店暂不支持开票;`getInvoice` 返回 `invoiceStatus=null`,前端不显示入口。SC-002、SC-006 通过。
+
+## 5. 开票失败 + 重试 — US4 / SC-005
+- 临时把门店 HashKey 改错 → `applyInvoice` → ezPay 回 `Status≠SUCCESS`。
+- **预期**:`invoice_status=2 失败`、`fail_reason` 记录原因、订单状态不变。
+- 平台后台 `PUT /system/orderInvoice/retry/{orderId}`(先恢复正确凭证)→ 重试成功,状态变 `1 已开`。SC-005 通过。
+
+## 6. 优惠/运费对账 — D1/D3
+- 对一笔**带优惠的外送订单**开票。
+- **预期**:发票 `total_amt` = `amount − freight`(不含运费);优惠按比例分摊到各商品明细,`ΣItemAmt = total_amt`;运费不出现。
+- 自查:发票总额 < 订单 `amount`(差额 = 运费)。
+
+## 7. 作废 + 重开 — US5
+- 平台后台 `PUT /system/orderInvoice/invalid/{orderId}`(一张本月开具的发票)。
+- **预期**:ezPay `invoice_invalid` 成功 → `invoice_status=3 作废`、`invalid_time` 记录。
+- 超期作废:对一张不可作废的发票 → 提示不可作废(以 ezPay 返回为准)。
+- 重开:作废后客户重新 `applyInvoice` → 生成新 `invoice_number`。
+
+## 8. 三端一致性 — SC-006
+- 同一订单:客户端 `getInvoice`、商家端订单详情、平台后台 `list/{orderId}` 显示的发票号与状态一致。
+
+---
+
+## 验收核对(对照 spec Success Criteria)
+- [ ] SC-001 客户 1 分钟内 / 3 步完成开票拿到凭证
+- [ ] SC-002 不可开票门店 100% 无入口
+- [ ] SC-003 非法统编调用前拦截
+- [ ] SC-004 无重复发票
+- [ ] SC-005 失败可重试成功
+- [ ] SC-006 三端发票号/状态一致

+ 120 - 0
specs/010-order-invoice/research.md

@@ -0,0 +1,120 @@
+# Phase 0 Research: 订单 ezPay 电子发票开立
+
+**Date**: 2026-06-16
+
+记录本期关键技术决策与依据。所有决策基于已落地的 009、已验证的 ezPay API 规格(见记忆 `[ezPay电子发票API规格]`)与订单现状调研。
+
+---
+
+## D1. 开票金额含税拆分
+
+**Decision**: 发票金额**不含运费**(运费由用户承担、非商家商品收入)。订单金额构成(已确认):`amount = foodAmount − 各项优惠 + freight`,故商家开票口径取商品实付:
+
+- `invoiceTotal` = `amount − freight`(= 商品实付,已扣全部优惠、不含运费)
+- `TotalAmt` = `invoiceTotal`(含税总额)
+- `sales_amt`(销售额,未税)= `round(invoiceTotal / 1.05)`(四舍五入到元)
+- `tax_amt`(税额)= `invoiceTotal − sales_amt`
+
+ezPay `Amt`=销售额、`TaxAmt`=税额、`TotalAmt`=含税总额,三者满足 `Amt + TaxAmt = TotalAmt`。
+
+**Rationale**: 台湾餐饮 B2C 为含税定价(标价即含税);ezPay B2C 规则「单价含税」。`pos_order` 无独立税额字段,`amount` 是客户实付金额,开票以实付为准。
+
+**Alternatives**:
+- 视 amount 为未税、另算税额:与「客户实付 = amount」语义冲突,会导致发票总额 ≠ 实付,不采用。
+- 新增订单税额字段:改动面大且本期不改订单表,不采用。
+
+---
+
+## D2. 逐商品明细来源(无 PosOrderItem 表)
+
+**Decision**: 订单商品明细从 `pos_order.food`(JSON 数组,fastjson)解析,每个元素映射 ezPay 多品项字段(`|` 分隔):
+
+| food 元素字段 | ezPay 字段 | 说明 |
+|---------------|-----------|------|
+| 名称字段(name/title,实现时确认) | `ItemName` | 商品名,多品项用 `\|` 连接 |
+| `price` + `otherPrice` | `ItemPrice` | 含税单价 = 基础价 + 加料价(B2C 含税) |
+| `number` | `ItemCount` | 数量 |
+| (price+otherPrice)×number | `ItemAmt` | 该商品小计 |
+| 固定「個」/「份」 | `ItemUnit` | 单位 |
+
+**Rationale**: 调研确认 `pos_order` 无独立明细表,`food` 是唯一明细来源;`OrderService`/`PosOrderController` 已有 `JSONArray.parseArray(getFood())` 解析模式可复用。商品名称字段在调研样本(佣金计算、列表)中未取,实现时确认(预期 `name`/`title`/`foodName` 之一)。
+
+**Alternatives**: 新建 `pos_order_item` 明细表并迁移 food:改动面过大、与现有大量 food 解析代码冲突,本期不做。
+
+---
+
+## D3. 优惠在发票商品明细上的体现(运费已排除)
+
+**Decision**: 发票 `TotalAmt = amount − freight`(见 D1,已扣全部优惠、不含运费)。但逐商品明细按 `food` 原价列出时 `Σ 商品小计 = foodAmount > TotalAmt`,差额 = 各项优惠之和。为使发票商品明细与总额自洽,二选一(实现时定,**优先方案 B**):
+
+- **方案 A(折扣负项)**:商品按原价列,追加一条 `ItemName="折扣"`、`ItemAmt=-(优惠之和)` 的负项 → `Σ = TotalAmt`。需 ezPay 接受负金额。
+- **方案 B(按比例分摊,推荐)**:把优惠按各商品金额占比分摊、调低各商品 `ItemPrice`/`ItemAmt`,使 `ΣItemAmt` 自然 = `TotalAmt`,无负项、ezPay 必接受;末项兜底吸收舍入差。
+
+运费**不**出现在发票任何明细。
+
+**Rationale**: ezPay 校验「商品小计 = 数量 × 单价」(逐商品)+ `Amt/TaxAmt/TotalAmt` 三栏自洽。方案 B 无负金额风险、最稳妥。
+
+**⚠️ 待测试确认**: 方案 A 的负金额 ItemAmt 是否被 ezPay 接受(memory 规格未明确)。即便如此,方案 B 已足够,方案 A 仅作备选。
+
+---
+
+## D4. ezPay 开票参数映射(按场景)
+
+| 场景 | Category | PrintFlag | BuyerUBN | CarrierType/Num | LoveCode |
+|------|----------|-----------|----------|-----------------|----------|
+| B2C 个人-邮箱 | B2C | Y | 空 | 空 | 空 |
+| B2C 个人-载具 | B2C | N | 空 | 0/1/2 + 号码 | 空 |
+| B2B 公司 | B2B | Y | 统编(8码) | 空 | 空 |
+
+固定项:`Status=1`(即时开立,由 `EzPay.issueInvoice` 自动补)、`TaxType=1`(应税)、`TaxRate=5`、`MerchantOrderNo=订单 ddId`(同商店唯一)。
+
+**Rationale**: 来自 ezPay INVI 规格(记忆)。PrintFlag 规则:无载具/捐赠时必 Y;B2B 必 Y。本期不做捐赠 → LoveCode 恒空。
+
+---
+
+## D5. 防重复开票(并发安全)
+
+**Decision**: 
+1. `pos_order_invoice` 设 `UNIQUE KEY uk_order_id (order_id)` —— 物理保证一笔订单至多一行当前发票。
+2. 开票流程在事务内:`SELECT 当前行 → 校验状态(仅 未开/失败/作废 可开) → 调 ezPay → 写结果`。
+3. 状态为「已开(1)」时接口直接拒绝(FR-005)。
+
+**Rationale**: 客户可能并发点击「申请发票」;DB 唯一约束兜底 + 状态前置校验,避免产生重复发票(SC-004)。
+
+**Alternatives**: 分布式锁:过度设计,单库唯一约束足够。
+
+---
+
+## D6. 作废与重开
+
+**Decision**:
+- 作废:状态为「已开」时调 `EzPay` 走 `invoice_invalid`(`URL_INVALID`),入参 `InvoiceNumber` + `InvalidReason`;以 ezPay 返回为准判定成功/不可作废(SC 不做本地时限计算)。
+- 重开:作废成功后,同一行 `invoice_status` 置「作废(3)」;客户/运营再次开票时,先校验当前状态 ∈ {未开,失败,作废},将该行重置为未开后重新开立 → 写入新 `invoice_number`(旧号覆盖,不保留历史明细,MVP 范围)。
+
+**Rationale**: spec「一笔订单对应一张有效发票(作废后可重开)」。1:1 当前态表 + 状态机即可满足,无需历史明细表。
+
+---
+
+## D7. 开票业务层位置(架构)
+
+**Decision**: 开票 service 放 `ruoyi-admin.app.order.OrderInvoiceService`(非 ruoyi-system),domain/mapper 放 ruoyi-system。
+
+**Rationale**: 开票需调用 `EzPay`(位于 ruoyi-admin),若 service 在 ruoyi-system 会反向依赖 ruoyi-admin(与 009 让 ezPay 验证留在 Controller 同理)。本期开票业务(金额拆分+明细组装+ezPay 调用+落库)较重,单独 service 比 009「Controller 直接调」更内聚、可单测。
+
+---
+
+## D8. 客户端开票入口归属校验
+
+**Decision**: 客户端 `/applyInvoice` 校验 `PosOrder.userId == 当前登录客户 id`,否则拒绝;门店不可开票(免用发票 / ezPay 未开通未启用)时返回明确提示、不显示入口。
+
+**Rationale**: FR-006 + 安全(客户只能为自己订单开票)。可开票判定复用 009 的 `pos_store_ezpay` 状态。
+
+---
+
+## D9. ezPay 开票回应判读
+
+**Decision**: `EzPay.issueInvoice` 回应 JSON:
+- `Status == "SUCCESS"` 且 `Result.InvoiceNumber` 非空 → 成功,取 `InvoiceNumber`/`RandomNum`/`BarCode`/`QRcodeL`/`QRcodeR` 落库;
+- 否则(`Status != SUCCESS` 或含 KEY1/INV/LIB 错误前缀)→ 失败,记 `Message` 到 `fail_reason`,状态置失败,订单不变。
+
+**Rationale**: 与 009 凭证验证(看 KEY1)不同,开立以 `Status=SUCCESS` 为准。错误前缀见记忆规格(KEY1 加解密 / INV 发票业务 / LIB 重复状态 / NOR 网络)。

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

@@ -0,0 +1,145 @@
+# Feature Specification: 订单 ezPay 电子发票开立
+
+**Feature Branch**: 不新建分支(在当前 test 分支开发)
+
+**Created**: 2026-06-16
+
+**Status**: Draft
+
+**Input**: User description: "订单完成后客户要开发票(ezPay 电子发票开票)。基于已完成的 009-ezpay-invoice-onboarding(门店 ezPay 开通管理 + 凭证录入),复用已有 EzPay 开票工具类,在订单完成时为已开通且启用的门店开立电子发票。不创建分支,在当前 test 分支开发。"
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - 客户在订单完成后申请个人电子发票 (Priority: P1)
+
+客户下单并在订单完成后,可在订单详情发起开票,选择开立个人(B2C)电子发票并填写接收邮箱;系统通过门店已配置的 ezPay 凭证即时开立电子发票,返回发票号码与发票查看凭证,客户可查看/下载。仅「已开通且启用」的门店能为客户开票;免用发票门店不提供开票入口。
+
+**Why this priority**: 这是「客户能开到发票」的核心价值,也是 ezPay 开通管理(009)真正落地的出口。没有它,门店开通 ezPay 没有任何意义。
+
+**Independent Test**: 对一笔已完成订单(所属门店已开通且启用 ezPay)申请个人发票 → 系统开立成功 → 订单显示发票号、客户可查看发票。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单已完成且所属门店 ezPay 已开通且启用,**When** 客户在订单详情点「申请发票」、选个人发票并填接收邮箱,**Then** 系统调 ezPay 即时开立,订单记录发票号与防伪随机码,客户拿到发票查看凭证。
+2. **Given** 订单所属门店为「免用发票」或 ezPay 未开通/未启用,**When** 客户查看订单,**Then** 不显示开票入口(或提示该门店暂不支持开票)。
+3. **Given** 订单已成功开票,**When** 客户再次查看订单,**Then** 显示发票号与发票链接,且无法再次申请开票。
+
+---
+
+### User Story 2 - 客户申请公司(B2B)电子发票并填写统一编号 (Priority: P1)
+
+客户可选择开立公司(B2B)发票,填写公司名称与统一编号(统编,8 码);系统校验统编格式后调 ezPay 开立三联式发票,发票上印有买方统编。
+
+**Why this priority**: 外卖/餐饮场景下 B2B 报账是刚需(统编为标配),与个人开票同为核心开票路径。
+
+**Independent Test**: 对一笔已完成订单选公司发票、填公司名 + 合法统编 → 开立成功 → 发票记录买方统编。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单已完成且门店可开票,**When** 客户选公司发票并填合法统编(8 码)与公司名,**Then** 开立成功,发票记录买方统编。
+2. **Given** 客户填了格式非法的统编,**When** 提交申请,**Then** 校验失败并提示,不调用开票接口。
+
+---
+
+### User Story 3 - 客户选择电子发票载具 (Priority: P2)
+
+客户开立个人发票时,可选择将发票存入载具(手机条码 / 自然人凭证 / ezPay 会员载具);不选则默认以邮箱接收。
+
+**Why this priority**: 台湾电子发票的载具是常态体验,但并非「开到发票」的必要前提,属体验增强,故 P2。
+
+**Independent Test**: 选手机条码载具开票 → 发票存入该载具,不另行印发邮箱 PDF。
+
+**Acceptance Scenarios**:
+
+1. **Given** 客户开个人发票,**When** 选手机条码载具并填条码,**Then** 校验条码合法后开立,发票存入该载具。
+2. **Given** 客户开个人发票,**When** 选自然人凭证载具并填凭证,**Then** 校验后开立,发票存入该载具。
+
+---
+
+### User Story 4 - 商家与平台查看订单发票、失败重试 (Priority: P2)
+
+商家在商家端、运营在平台后台可查看订单的开票状态(未开 / 已开 / 失败 / 作废)与发票号;开票失败的订单可由运营重新触发开票。
+
+**Why this priority**: 运营与商家需要掌握开票情况、处理失败单,但依赖开票主链路先打通,故 P2。
+
+**Independent Test**: 一笔开票失败的订单,运营在后台点「重新开票」 → 成功后状态变已开。
+
+**Acceptance Scenarios**:
+
+1. **Given** 订单曾开票失败,**When** 运营/商家查看,**Then** 显示失败状态与失败原因。
+2. **Given** 一笔开票失败的订单,**When** 运营点重新开票,**Then** 系统再次调 ezPay 开立,成功则更新发票号与状态。
+
+---
+
+### User Story 5 - 商家作废已开立的发票 (Priority: P3)
+
+商家/运营可对已开立的发票发起作废(受台湾法规「奇数月 14 日前可作废前两月发票」限制),作废成功后订单可重新开票。
+
+**Why this priority**: 作废属售后补救,频率低且有严格时点限制,属辅助功能,故 P3。
+
+**Independent Test**: 对一张本月开具的发票点作废 → 调 ezPay 作废成功 → 发票状态变作废、订单可重开。
+
+**Acceptance Scenarios**:
+
+1. **Given** 一张开立且在可作废期限内的发票,**When** 商家发起作废,**Then** 系统调 ezPay 作废,发票标记作废。
+2. **Given** 已超过可作废期限的发票,**When** 商家发起作废,**Then** 提示不可作废并说明原因(以 ezPay 作废接口返回为准)。
+
+---
+
+### Edge Cases
+
+- **同一订单重复申请开票**:已成功开票的订单不允许再次开票(除非先作废);需防止并发点击产生重复发票。
+- **ezPay 开票接口网络异常**:不开立、订单标记失败、不产生半成品发票;提示稍后重试。
+- **ezPay 回应业务错误**(金额不平、字轨用尽等):记录错误码与信息,订单标记失败、可重试。
+- **客户邮箱/载具填错导致发票无法送达**:发票在 ezPay 侧已开立成功,记录发票号,提示客户联系商家;本期不做载体自动更正。
+- **订单退款/取消后已开发票**:本期不自动作废,由商家按需手动作废(US5)。
+- **夜市(userType=3)门店与免用发票门店**:不提供开票入口(与 009 一致)。
+- **门店 ezPay 被停用(009 toggleEnable 关)**:开票时识别为不可开票,提示门店发票功能暂停。
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: 系统必须能在订单完成后,允许客户为该订单申请电子发票(仅当订单所属门店 ezPay 已开通且启用)。
+- **FR-002**: 系统必须支持客户开立个人(B2C)电子发票,含接收邮箱,并通过 ezPay 即时开立接口完成开立。
+- **FR-003**: 系统必须支持客户开立公司(B2B)电子发票,含买方名称与统一编号(统编,8 码),并在提交前校验统编格式。
+- **FR-004**: 系统必须在开票成功后记录发票号码、防伪随机码等 ezPay 回应信息并关联到订单,供客户与商家查看。
+- **FR-005**: 系统必须避免同一订单重复开票——已成功开票的订单不允许再次开票,除非该发票已作废。
+- **FR-006**: 系统必须在门店为「免用发票」或 ezPay 未开通/未启用时,不向客户提供开票入口。
+- **FR-007**: 开票失败(网络异常或 ezPay 业务错误)时,系统必须标记订单开票失败并记录原因,不改变订单本身状态,并允许后续重新开票。
+- **FR-008**: 系统必须支持客户选择电子发票载具(手机条码 / 自然人凭证 / ezPay 会员载具);不选则以邮箱接收。
+- **FR-009**: 商家端与平台后台必须能查看订单开票状态(未开 / 已开 / 失败 / 作废)与发票号;运营可对失败订单重新开票。
+- **FR-010**: 商家/运营必须能对已开立且在可作废期限内的发票发起作废,作废成功后订单可重新开票。
+- **FR-011**: 系统必须复用已实现的 ezPay 开票工具类(开立 / 查询 / 作废),不在本期改动其内部实现。
+- **FR-012**: 商家端、平台后台新增的面向用户文字必须实现四语言(vi / zh / tw / en)国际化;客户端 UI 的 i18n 由客户端团队另行实现,不在本期。
+
+### Key Entities *(include if feature involves data)*
+
+- **订单发票(新增,与订单 1:1)**:记录一笔订单的电子发票信息:发票类型(B2C/B2B)、买方名称、买方统编、接收邮箱、载具类型与号码、发票号码、防伪随机码、发票查看凭证、开票状态(未开 / 已开 / 失败 / 作废)、失败原因、申请时间、开立时间、作废时间。
+- **订单(已有)**:开票的来源;订单完成后可发起开票;一笔订单对应一张有效发票(作废后可重开)。
+- **门店 ezPay 配置(009 已有)**:开票时读取门店的 ezPay 凭证(商店代号 / HashKey / HashIV),并判断门店是否可开票(已开通且启用)。
+- **ezPay 加值服务平台(外部)**:实际开立 / 作废发票的外部系统,本期通过其开立 / 作废接口与之交互。
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: 客户对一笔已完成订单,能在 1 分钟内、3 次操作内完成发票申请并拿到发票查看凭证。
+- **SC-002**: 门店为「免用发票」或 ezPay 未开通/未启用时,客户 100% 看不到开票入口(无法发起开票)。
+- **SC-003**: 客户填错统编格式时,系统 100% 在调用开票接口前拦截并提示。
+- **SC-004**: 同一订单不会因重复点击或并发申请产生 2 张及以上发票。
+- **SC-005**: 开票失败(网络或业务错误)的订单,客户/运营可在不改动订单状态的前提下重新开票成功。
+- **SC-006**: 客户、商家、运营三方都能在各自入口查看到同一订单的发票号与开票状态,且一致。
+
+## Assumptions
+
+- 「订单完成」由现有订单状态机判定(订单到达可开票的终态),本期不改订单状态机;具体终态口径在实现阶段对齐现有逻辑。
+- 开票凭证(商店代号 / HashKey / HashIV)由 009 的门店 ezPay 配置提供;仅「已开通(status=2)且启用(enabled=1)」的门店可开票。
+- 复用已实现的 ezPay 工具类(EzPay / EzPayConfig / EzPayEncryptUtil),本期不改其内部。
+- 餐饮主走 B2C 即时开立(Status=1);B2B 需统编;载具为 P2 体验增强。
+- 发票作废受台湾法规时点限制,系统以 ezPay 作废接口返回为准判定是否可作废,不在本地做时限计算。
+- 客户端前端 UI(订单详情开票入口、发票查看)不在本期实现范围:本期交付后端开票/查询 API(供客户端调用)+ 管理端(商家/平台后台)查看/重试/作废;客户端 UI 及其 i18n 由客户端团队后续对接。
+- 本期不含:发票折让、字轨管理、批次开立、发票中奖通知——这些属后续独立功能。
+- 捐赠码(LoveCode)不在本期范围:客户开票仅支持载具或邮箱接收,不做捐赠。
+- 发票金额不含运费:运费由用户承担(付给配送方),非商家商品收入;商家发票仅就商品销售开立。订单构成 `amount = 商品 − 各项优惠 + 运费`,故发票金额 = `amount − 运费`,各类优惠已含在 amount 内无需逐项区分。
+- 四语言 i18n 覆盖所有新增面向用户文字,遵循项目既有 i18n 规范。

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

@@ -0,0 +1,212 @@
+---
+
+description: "Task list for 订单 ezPay 电子发票开立"
+---
+
+# Tasks: 订单 ezPay 电子发票开立
+
+**Input**: Design documents from `/specs/010-order-invoice/`
+
+**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/api.md ✅, quickstart.md ✅
+
+**Tests**: 轻量。仅对「开票金额拆分 + ezPay 回应判读」加可选单测(mock `EzPay.issueInvoice`),其余按 quickstart.md 手测。项目无强制 TDD。
+
+**范围说明**: **客户端前端 UI 不在本期**——本期交付后端开票 API(`applyInvoice`/`getInvoice`,供客户端团队后续对接)+ 管理端(商家端 + 平台后台)查看/重试/作废。客户端 UI 与其 i18n 由客户端团队另行实现,不在本 tasks 内。
+
+**Organization**: 按 spec.md 五个 user story 组织;US1–US3 为后端开票链路,US4–US5 为管理端。
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: 可并行(不同文件、无未完成依赖)
+- **[Story]**: 所属 user story(US1~US5)
+- 描述含精确文件路径
+
+## Path Conventions
+
+- **后端**(本仓库 `foodie_server`):
+  - 实体/DTO/VO:`ruoyi-system/src/main/java/com/ruoyi/system/domain/`(含 `dto/`、`vo/`)
+  - Mapper 接口:`ruoyi-system/src/main/java/com/ruoyi/system/mapper/`
+  - Mapper XML:`ruoyi-system/src/main/resources/mapper/chanting/`
+  - 开票 Service:`ruoyi-admin/src/main/java/com/ruoyi/app/order/`(本期放 admin,因需调 EzPay)
+  - 客户端订单 Controller:`ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java`(已有,新增接口)
+  - 管理端 Controller:`ruoyi-admin/src/main/java/com/ruoyi/app/mendian/`(新增 `PosOrderInvoiceController`)
+  - ezPay 工具类(复用不改):`ruoyi-admin/src/main/java/com/ruoyi/app/utils/ezPay/`
+  - 009 凭证(复用):`PosStoreEzpay` / `PosStoreEzpayMapper`
+  - SQL:`updatesql/sql.md`
+- **平台后台前端**(兄弟仓库 `E:\QtwCode\foodie\foodie-admin-vue`):`src/api/chanting/`、`src/views/mendian/`、`src/api/language/`(CRLF 换行,用 Python 脚本改)
+- **商家端前端**(兄弟仓库 `E:\QtwCode\foodie\foodie-store`):`src/lang/`、订单详情视图(CRLF 换行,用 Python 脚本改)
+
+---
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: SQL 变更与权限菜单(交开发者手动执行)
+
+- [x] T001 在 `updatesql/sql.md` 追加 2026-06-16 段落:① 按 data-model.md 创建 `pos_order_invoice` 表(含 `uk_order_id` 唯一键、`idx_status`、`idx_store` 索引);② `sys_menu` 插入「订单发票管理」(C, `chanting:orderInvoice:list`) + 按钮权限 `chanting:orderInvoice:query/retry/invalid`(参考 009 菜单 SQL 写法,挂在门店/订单相关父菜单下)
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: 数据模型地基,所有 user story 都依赖
+
+**⚠️ CRITICAL**: 本阶段完成前不得开始任何 user story
+
+- [x] T002 [P] 新建 `PosOrderInvoice` 实体于 `ruoyi-system/src/main/java/com/ruoyi/system/domain/PosOrderInvoice.java`(MyBatis-Plus `@TableName("pos_order_invoice")`、`@TableId(type=IdType.AUTO)`,字段见 data-model.md:id/orderId/orderNo/storeId/invoiceCategory/buyerName/buyerUbn/buyerEmail/carrierType/carrierNum/invoiceNumber/randomNum/invoiceStatus/totalAmt/salesAmt/taxAmt/failReason/invoiceUrl/applyTime/issueTime/invalidTime/createTime/updateTime/createBy/updateBy,lombok `@Data`)
+- [x] T003 [P] 新建 `ApplyInvoiceDto` 于 `ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ApplyInvoiceDto.java`(字段:orderId/category/buyerName/buyerUbn/buyerEmail/carrierType/carrierNum;校验注解:orderId 必填、category 必填∈{B2C,B2B}、buyerName 必填、buyerEmail 邮箱格式、buyerUbn 长度 8 数字)
+- [x] T004 [P] 新建 `PosOrderInvoiceVo` 于 `ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/PosOrderInvoiceVo.java`(列表/详情字段:订单号/门店名/发票类型/买方/发票号/状态/金额/各时间 + 查询筛选字段 invoiceStatus/orderNo/storeId/invoiceCategory/日期范围)
+- [x] T005 [P] 新建 `PosOrderInvoiceMapper` 接口于 `ruoyi-system/src/main/java/com/ruoyi/system/mapper/PosOrderInvoiceMapper.java`,继承 MyBatis-Plus `BaseMapper<PosOrderInvoice>`,声明 `selectInvoiceList(PosOrderInvoiceVo query)` 分页查询 + `selectInvoiceDetail(@Param("orderId") Long)` 单行
+- [x] T006 [P] 新建 `PosOrderInvoiceMapper.xml` 于 `ruoyi-system/src/main/resources/mapper/chanting/PosOrderInvoiceMapper.xml`:`selectInvoiceList`(`pos_order_invoice i LEFT JOIN pos_order o LEFT JOIN pos_store s`,取订单号/门店名/发票字段,支持 invoiceStatus/orderNo/storeId/invoiceCategory/日期范围筛选)、`selectInvoiceDetail`(单行,含买方/载具/金额/凭证详情)
+- [x] T007 新建 `OrderInvoiceService` 于 `ruoyi-admin/src/main/java/com/ruoyi/app/order/OrderInvoiceService.java`(`@Service`,注入 `EzPay`、`PosOrderMapper`(或 PosOrderService)、`PosStoreEzpayMapper`、`PosStoreMapper`、`PosOrderInvoiceMapper`),声明占位方法 `applyInvoice/ getInvoice/ list/ detail/ retry/ invalid`,具体业务在后续 story 实现。**说明**:本期 service 放 admin(需调 EzPay),与 009 不同
+
+**Checkpoint**: 表结构 + 实体 + DTO/VO + mapper + service 骨架就绪,user story 可开始
+
+---
+
+## Phase 3: User Story 1 - 客户端开票 API(B2C 个人发票)(Priority: P1) 🎯 MVP
+
+**Goal**: 提供客户端可调用的开票 API——客户对已完成订单申请 B2C 个人发票(邮箱),系统调 ezPay 即时开立、记录发票号,并可查询
+
+**Independent Test**: 已完成订单(所属门店 ezPay 已开通启用)→ 调 `POST /system/userOrder/applyInvoice`(B2C+邮箱)→ 开立成功 → 调 `GET /getInvoice` 返回发票号
+
+### Implementation for User Story 1
+
+- [x] T008 [US1] 实现 `OrderInvoiceService.applyInvoice(ApplyInvoiceDto dto, Long currentUserId)`(核心开票链路):① 校验订单归属(`pos_order.user_id == currentUserId`)、`state==3`(已完成)、`pay_status==1`(已支付);② 校验门店可开票:`pos_store_ezpay.ezpay_status==2 && is_enabled==1` 且 `pos_store.invoice_exempt==0`,否则抛「门店暂不支持开票」;③ 校验订单未开票(`invoice_status != 1`);④ 金额拆分(research D1):`invoiceTotal = amount - freight`、`sales = round(invoiceTotal/1.05)`、`tax = invoiceTotal - sales`;⑤ 组装 ezPay issue 参数:`Category=B2C`、`PrintFlag=Y`、`BuyerName`、`BuyerEmail`、`TaxType=1`、`TaxRate=5`、`Amt/TaxAmt/TotalAmt`、`MerchantOrderNo=ddId`,逐商品明细从 `food` JSON 解析(`ItemName`/`ItemPrice=price+otherPrice` 含税/`ItemCount=number`/`ItemAmt`),优惠按比例分摊到各商品(research D3 方案 B);⑥ `EzPayConfig` 由门店 `pos_store_ezpay` 凭证构造 → `ezPay.issueInvoice(baseUrl, cfg, postData)`;⑦ 判读(research D9):`Status==SUCCESS && InvoiceNumber 非空` → 落库 `invoice_number/random_num/invoice_url/total/sales/tax/invoice_status=1/issue_time`;否则 `invoice_status=2 + fail_reason`;⑧ 网络异常 → `invoice_status=2 + "ezPay 服务暂不可用"`、订单状态不变;⑨ `uk_order_id` 防重复(catch DuplicateKeyException)。依赖 T002、T005、T007
+- [x] T009 [US1] 实现 `OrderInvoiceService.getInvoice(Long orderId, Long currentUserId)`:校验订单归属,返回发票 VO(`invoiceStatus/invoiceNumber/invoiceUrl/category`);门店不可开票时 `invoiceStatus=null`(客户端据此隐藏入口)。依赖 T008
+- [x] T010 [US1] 在 `UserOrderController`(`/system/userOrder`)新增:`POST /applyInvoice`(`@RequestBody ApplyInvoiceDto`,从 JWT/当前用户取 userId 调 `applyInvoice`)、`GET /getInvoice?orderId=`(调 `getInvoice`)。依赖 T008、T009
+
+**Checkpoint**: US1 可独立验收——开票/查询 API 可用,curl/Postman 跑通 B2C 开票
+
+---
+
+## Phase 4: User Story 2 - 客户端开票 API(B2B 公司发票)(Priority: P1)
+
+**Goal**: 开票 API 支持 B2B 公司发票(统编),提交前校验统编格式
+
+**Independent Test**: `applyInvoice` B2B + 合法统编 → 开立含买方统编;非法统编 → 调 ezPay 前被拦截
+
+### Implementation for User Story 2
+
+- [x] T011 [US2] 在 `applyInvoice` 增加 B2B 分支:`Category=B2B`、`BuyerUBN=统编(8码)`、`PrintFlag=Y`、`BuyerName=公司名`;B2B 时 `buyerUbn` 必填且 8 位数字校验(service 内强校验,非法抛「统编格式不正确」不调 ezPay)。依赖 T008
+
+**Checkpoint**: US2 可独立验收——B2B 开票 API + 统编校验双分支
+
+---
+
+## Phase 5: User Story 3 - 客户端开票 API(电子发票载具)(Priority: P2)
+
+**Goal**: 开票 API 支持选择载具(手机条码/自然人凭证/会员载具),发票存入载具
+
+**Independent Test**: `applyInvoice` 选手机条码载具 → 校验后开立、PrintFlag=N、发票存入载具
+
+### Implementation for User Story 3
+
+- [x] T012 [US3] 在 `applyInvoice` 增加载具分支:`carrierType` 非空时传 `CarrierType`(0/1/2)+`CarrierNum`、`PrintFlag=N`、`LoveCode` 空;校验载具号非空(手机条码格式 `/XXXX+/` 可选校验)。依赖 T008
+
+**Checkpoint**: US3 可独立验收——载具开票 API
+
+---
+
+## Phase 6: User Story 4 - 商家与平台查看订单发票、失败重试 (Priority: P2)
+
+**Goal**: 商家端/平台后台查看订单开票状态与发票号;运营对失败单重新开票
+
+**Independent Test**: 一笔开票失败订单 → 平台后台点「重新开票」 → 成功、状态变已开
+
+### Implementation for User Story 4
+
+- [x] T013 [US4] 实现 `OrderInvoiceService` 的管理端方法:`list(query)`(分页,带门店/状态筛选)、`detail(orderId)`、`retry(orderId)`(校验 `invoice_status ∈ {2 失败, 3 作废}` → 重置为未开 → 复用 T008 开票逻辑重开,运营身份不校验客户归属)。依赖 T008
+- [x] T014 [US4] 新建 `PosOrderInvoiceController` 于 `ruoyi-admin/src/main/java/com/ruoyi/app/mendian/PosOrderInvoiceController.java`,`@RequestMapping("/system/orderInvoice")`:`GET /list`(`@PreAuthorize("@ss.hasPermi('chanting:orderInvoice:list')")` + `startPage()` + `TableDataInfo`)、`GET /{orderId}`(`chanting:orderInvoice:query`)、`PUT /retry/{orderId}`(`chanting:orderInvoice:retry` + `@Log`)。依赖 T013
+- [x] T015 [P] [US4] 平台后台前端:新建 `E:\QtwCode\foodie\foodie-admin-vue\src\api\chanting\orderInvoice.js`(list/detail/retry 封装,走 `/system/orderInvoice/*`)与页面 `src\views\mendian\orderInvoice\index.vue`(Element 表格:订单号/门店/类型/买方/发票号/状态/金额/开立时间 + 顶部筛选 + 失败/作废行的「重新开票」按钮,`v-hasPermi` 权限控制)。依赖 T014
+- [x] T016 [P] [US4] 平台后台 i18n:在 `foodie-admin-vue\src\api\language\` 的 zh_CN/zh_TW/en_US/vi.js 新增 `orderInvoice:{}` 对象层级(标题/表头/状态文案/按钮,四文件 key 一致)。依赖 T015
+- [x] T017 [P] [US4] 商家端前端 `foodie-store`:订单详情显示发票状态/发票号(复用现有商家订单详情视图,加展示)。依赖 T014(接口)
+- [x] T018 [P] [US4] 商家端 i18n:`foodie-store\src\lang\` 的 zh/tw/en/vi.js 加发票状态相关 key(四语言一致,CRLF 用 Python 脚本改)。依赖 T017
+
+**Checkpoint**: US4 可独立验收——三端查看 + 失败重试
+
+---
+
+## Phase 7: User Story 5 - 商家作废已开立的发票 (Priority: P3)
+
+**Goal**: 已开立发票可作废(ezPay invalid),作废后订单可重开
+
+**Independent Test**: 作废一张本月开具的发票 → ezPay invalid 成功 → 状态作废 → 重新开票生成新号
+
+### Implementation for User Story 5
+
+- [x] T019 [US5] 实现 `OrderInvoiceService.invalid(Long orderId, String reason)`:校验 `invoice_status==1`(已开),构造 `{InvoiceNumber, InvalidReason, RespondType:JSON, Version:1.0}` 调 `ezPay.doPost(baseUrl + EzPay.URL_INVALID, cfg, postData)`;判读成功 → `invoice_status=3/invalid_time`;失败 → 记 `fail_reason`;不可作废以 ezPay 返回为准(不做本地时限计算)。依赖 T014
+- [x] T020 [US5] 在 `PosOrderInvoiceController` 加 `PUT /invalid/{orderId}`(入参可选 `{invalidReason}`,`@PreAuthorize('chanting:orderInvoice:invalid')` + `@Log`)。依赖 T019
+- [x] T021 [P] [US5] 平台后台前端 `index.vue` 对「已开」行加「作废」按钮 + 作废原因输入弹窗(调 invalid,成功刷新)。依赖 T015、T020
+- [x] T022 [P] [US5] 平台后台 i18n:在 `orderInvoice:{}` 下补充作废相关 key(作废/作废原因/不可作废提示)。依赖 T021
+
+**Checkpoint**: US5 可独立验收——作废 + 重开
+
+---
+
+## Phase 8: Polish & Cross-Cutting Concerns
+
+- [ ] T023 [P] 按 `quickstart.md` 跑通手测(通过 curl/Postman 调开票 API + 平台后台 UI):执行 SQL → ezPay 测试环境真凭证 → B2C 邮箱 / B2B 统编 / 载具开票 → 失败重试 → 作废重开 → 优惠对账(方案 B 分摊),逐条核对 spec.md 的 SC-001~SC-006
+- [ ] T024 [P] 单元测试(可选):`OrderInvoiceServiceTest`(mock `EzPay.issueInvoice`)验证 ① 金额拆分(`invoiceTotal=amount-freight`、`sales+tax=total`);② 回应判读两条分支(SUCCESS→已开 / 其它→失败 status 不变)。按项目测试习惯放 `ruoyi-admin/src/test/...`
+- [x] T025 [P] 更新记忆索引:在 `C:\Users\qmj\.claude\projects\E--QtwCode-foodie-foodie-server\memory\MEMORY.md` 加一行指向 `specs/010-order-invoice/spec.md`(参考 008/009 的写法)
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Phase 1 Setup**:无依赖,立即开始(SQL 交开发者手动执行)
+- **Phase 2 Foundational**:依赖 Phase 1(表先建好);**阻塞所有 user story**
+- **Phase 3 US1 (P1)**:依赖 Foundational
+- **Phase 4 US2 (P1)**:依赖 US1(复用 applyInvoice,加 B2B 分支)
+- **Phase 5 US3 (P2)**:依赖 US1(复用 applyInvoice,加载具分支)
+- **Phase 6 US4 (P2)**:依赖 US1(retry 复用开票逻辑);与 US2/US3 独立
+- **Phase 7 US5 (P3)**:依赖 US4(复用管理端 controller,加 invalid)
+- **Phase 8 Polish**:依赖所有欲交付的 story 完成
+
+### Within Each User Story
+
+- 后端 service 先于 controller
+- 平台后台 `orderInvoice/index.vue` 跨 US4/US5 同文件 → **顺序追加**
+- 四语言 i18n 文件跨 story 同文件 → **顺序追加**,不并行
+
+### Parallel Opportunities
+
+- Phase 2:T002/T003/T004/T005/T006 不同文件可并行(T007 依赖前述)
+- US4 平台后台(foodie-admin-vue)与商家端(foodie-store)不同仓库 → 可并行
+- 单测 T024 与前端不同文件可并行
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Story 1 Only)
+
+1. Phase 1 Setup:写 SQL(开发者执行)
+2. Phase 2 Foundational:建实体/DTO/VO/mapper/service 骨架
+3. Phase 3 US1:B2C 开票 API 主链路(后端)
+4. **STOP 验收**:curl/Postman 对已完成订单调 `applyInvoice`(B2C 邮箱)→ 开票成功、`getInvoice` 返回发票号
+5. 此时已交付核心价值——开票能力就绪,客户端团队可对接
+
+### Incremental Delivery
+
+1. Setup + Foundational → 地基就绪
+2. +US1 → B2C 开票 API(MVP,可演示)
+3. +US2 → B2B 开票 API + 统编校验
+4. +US3 → 载具开票 API
+5. +US4 → 管理端三端查看 + 失败重试
+6. +US5 → 作废 + 重开
+7. Polish:quickstart 手测 + 记忆索引
+
+---
+
+## Notes
+
+- **客户端前端 UI 不在本期**:本期交付开票/查询后端 API(`/system/userOrder/applyInvoice`、`/getInvoice`),客户端团队后续对接 UI;US1–US3 验收以 API 手测(curl/Postman)为准
+- ezPay 工具类 `EzPay`/`EzPayConfig`/`EzPayEncryptUtil` 已实现并经官方数据验证,**本期不改其内部**,仅业务层调用(issue 开票 / doPost 作废)
+- 开票金额**不含运费**:`invoiceTotal = amount - freight`(research D1),各类优惠已含在 amount 内无需逐项区分
+- 优惠在商品明细的体现用方案 B(按比例分摊,research D3),实现时若需切方案 A(折扣负项)需在测试环境验证 ezPay 是否接受负金额
+- 开票 service 放 `ruoyi-admin.app.order`(非 ruoyi-system),因需调 EzPay;domain/mapper 放 ruoyi-system
+- 前端两个仓库均为 CRLF,编辑 lang 文件用 Python 脚本(见 CLAUDE.md「前端文件编辑注意事项」)
+- 所有前端新增文字必须四语言 i18n,key 加到正确对象层级、驼峰命名
+- SQL 不直接执行,写 `updatesql/sql.md` 由开发者手动跑

+ 101 - 0
updatesql/sql.md

@@ -148,3 +148,104 @@ CREATE TABLE pos_order_promotion (
 ```
 -- 2026-06-01 优惠券类型扩展:coupon_type COMMENT 更新为 '1=满减券 2=商品券 3=免配送费券'(无需实际 ALTER,仅记录)
 ```
+
+## 2026-06-15 商家 ezPay 发票开通管理(009-ezpay-invoice-onboarding)
+
+```sql
+-- 1. pos_store 加列:是否免用发票
+ALTER TABLE pos_store ADD COLUMN invoice_exempt TINYINT(1) DEFAULT 0 COMMENT '是否免用发票:0需开票/1免用发票';
+
+-- 2. 新增门店 ezPay 开通配置表(与 pos_store 1:1)
+CREATE TABLE pos_store_ezpay (
+  id                 BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  store_id           BIGINT       NOT NULL COMMENT '关联 pos_store.id(门店)',
+  ezpay_status       TINYINT      NOT NULL DEFAULT 0 COMMENT '申请状态:0未申请/1申请中/2已开通',
+  is_enabled         TINYINT      NOT NULL DEFAULT 1 COMMENT '启用开关:0停用/1启用(仅status=2有意义)',
+  merchant_id        VARCHAR(32)  DEFAULT NULL COMMENT 'ezPay 商店代号 MerchantID_',
+  hash_key           VARCHAR(64)  DEFAULT NULL COMMENT 'ezPay HashKey(32字节)',
+  hash_iv            VARCHAR(64)  DEFAULT NULL COMMENT 'ezPay HashIV(16字节)',
+  company_id         VARCHAR(32)  DEFAULT NULL COMMENT 'ezPay 会员编号 CompanyID_(字轨用,可空)',
+  ubn                VARCHAR(16)  DEFAULT NULL COMMENT '统一编号(统编,商家上传)',
+  apply_time         DATETIME     DEFAULT NULL COMMENT '提交申请时间(0->1)',
+  approved_time      DATETIME     DEFAULT NULL COMMENT '开通时间(->2)',
+  last_verify_result VARCHAR(255) DEFAULT NULL COMMENT '最近一次凭证验证结果',
+  remark             VARCHAR(500) DEFAULT NULL COMMENT '备注',
+  create_time        DATETIME     DEFAULT NULL COMMENT '创建时间',
+  update_time        DATETIME     DEFAULT NULL COMMENT '更新时间',
+  create_by          VARCHAR(64)  DEFAULT NULL COMMENT '创建人',
+  update_by          VARCHAR(64)  DEFAULT NULL COMMENT '更新人',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_store_id (store_id),
+  KEY idx_ezpay_status (ezpay_status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店 ezPay 发票开通配置';
+
+-- 3. 平台后台菜单:ezPay 发票开通管理(挂在门店菜单同级父节点下)
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, remark)
+SELECT 'ezPay发票管理', parent_id, 7, 'storeEzpay', 'mendian/storeEzpay/index', 'C', '0', '0', 'chanting:storeEzpay:list', 'money', 'admin', NOW(), '商家 ezPay 发票开通管理'
+FROM sys_menu WHERE perms = 'chanting:store:list' LIMIT 1;
+
+-- 按钮权限
+SET @ezpayMenuId = (SELECT menu_id FROM sys_menu WHERE perms = 'chanting:storeEzpay:list' LIMIT 1);
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('ezPay详情', @ezpayMenuId, 1, '#', '', 'F', '0', '0', 'chanting:storeEzpay:query', '#', 'admin', NOW());
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('发起申请', @ezpayMenuId, 2, '#', '', 'F', '0', '0', 'chanting:storeEzpay:apply', '#', 'admin', NOW());
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('录入凭证', @ezpayMenuId, 3, '#', '', 'F', '0', '0', 'chanting:storeEzpay:saveCredentials', '#', 'admin', NOW());
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('停用/恢复', @ezpayMenuId, 4, '#', '', 'F', '0', '0', 'chanting:storeEzpay:toggleEnable', '#', 'admin', NOW());
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('免用发票标记', @ezpayMenuId, 5, '#', '', 'F', '0', '0', 'chanting:storeEzpay:markExempt', '#', 'admin', NOW());
+```
+
+
+## 2026-06-16 订单 ezPay 电子发票开立(010-order-invoice)
+
+```sql
+-- 1. 新增订单电子发票表(与 pos_order 1:1,当前态:作废后可重开同行覆盖)
+CREATE TABLE pos_order_invoice (
+  id                 BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
+  order_id           BIGINT       NOT NULL COMMENT '关联 pos_order.id',
+  order_no           VARCHAR(64)  DEFAULT NULL COMMENT '冗余 pos_order.dd_id',
+  store_id           BIGINT       NOT NULL COMMENT '冗余 pos_store.id(门店)',
+  invoice_category   VARCHAR(8)   DEFAULT NULL COMMENT '发票类型 B2C/B2B',
+  buyer_name         VARCHAR(100) DEFAULT NULL COMMENT '买方名称',
+  buyer_ubn          VARCHAR(16)  DEFAULT NULL COMMENT '买方统一编号(统编8码)',
+  buyer_email        VARCHAR(200) DEFAULT NULL COMMENT '接收邮箱',
+  carrier_type       VARCHAR(8)   DEFAULT NULL COMMENT '载具类型 0手机条码/1自然人凭证/2ezPay会员',
+  carrier_num        VARCHAR(64)  DEFAULT NULL COMMENT '载具号码',
+  invoice_number     VARCHAR(16)  DEFAULT NULL COMMENT 'ezPay 发票号',
+  random_num         VARCHAR(8)   DEFAULT NULL COMMENT '防伪随机码',
+  invoice_status     TINYINT      NOT NULL DEFAULT 0 COMMENT '0未开/1已开/2失败/3作废',
+  total_amt          INT          DEFAULT NULL COMMENT '含税总额(=amount-freight)',
+  sales_amt          INT          DEFAULT NULL COMMENT '销售额未税',
+  tax_amt            INT          DEFAULT NULL COMMENT '税额',
+  fail_reason        VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
+  invoice_url        VARCHAR(500) DEFAULT NULL COMMENT '发票查看凭证/链接',
+  apply_time         DATETIME     DEFAULT NULL COMMENT '申请开票时间',
+  issue_time         DATETIME     DEFAULT NULL COMMENT '开立成功时间',
+  invalid_time       DATETIME     DEFAULT NULL COMMENT '作废时间',
+  create_time        DATETIME     DEFAULT NULL COMMENT '创建时间',
+  update_time        DATETIME     DEFAULT NULL COMMENT '更新时间',
+  create_by          VARCHAR(64)  DEFAULT NULL COMMENT '创建人',
+  update_by          VARCHAR(64)  DEFAULT NULL COMMENT '更新人',
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_order_id (order_id),
+  KEY idx_status (invoice_status),
+  KEY idx_store (store_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单电子发票(与pos_order 1:1)';
+
+-- 2. 平台后台菜单:订单发票管理(挂在门店菜单同级父节点下,参考 009 写法)
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time, remark)
+SELECT '订单发票管理', parent_id, 8, 'orderInvoice', 'mendian/orderInvoice/index', 'C', '0', '0', 'chanting:orderInvoice:list', 'documentation', 'admin', NOW(), '订单 ezPay 电子发票查看/重试/作废'
+FROM sys_menu WHERE perms = 'chanting:store:list' LIMIT 1;
+
+-- 按钮权限
+SET @invMenuId = (SELECT menu_id FROM sys_menu WHERE perms = 'chanting:orderInvoice:list' LIMIT 1);
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('发票详情', @invMenuId, 1, '#', '', 'F', '0', '0', 'chanting:orderInvoice:query',   '#', 'admin', NOW());
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('重新开票', @invMenuId, 2, '#', '', 'F', '0', '0', 'chanting:orderInvoice:retry',   '#', 'admin', NOW());
+INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_by, create_time)
+VALUES ('作废发票', @invMenuId, 3, '#', '', 'F', '0', '0', 'chanting:orderInvoice:invalid', '#', 'admin', NOW());
+```

Некоторые файлы не были показаны из-за большого количества измененных файлов