qmj 6 өдөр өмнө
parent
commit
110e29cb88

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 24 - 0
.claude/homunculus/observations.jsonl


+ 33 - 1
ruoyi-admin/src/main/java/com/ruoyi/app/mendian/FoodSpecController.java

@@ -11,6 +11,7 @@ import com.ruoyi.system.domain.FoodSpecsValue;
 import com.ruoyi.system.service.IFoodSpecsService;
 import com.ruoyi.system.service.IFoodSpecsValueService;
 import com.ruoyi.system.utils.Auth;
+import com.ruoyi.system.utils.JwtUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.bind.annotation.*;
@@ -60,6 +61,8 @@ public class FoodSpecController extends BaseController
         return success(result);
     }
 
+
+
     /**
      * 保存规格组(含级联规格值):id<=0 新增,否则更新
      */
@@ -79,8 +82,9 @@ public class FoodSpecController extends BaseController
                 s.setIsOpen(true);
             }
             saveOrUpdateSpecs(s);
-            // 级联规格值
+            // 级联规格值(全量替换:先删前端未保留的,再保存/新增)
             if (s.getObjects() != null) {
+                removeDeletedValues(s);
                 for (FoodSpecsValue v : s.getObjects()) {
                     v.setParentId(s.getId());
                     if (v.getIsOpen() == null) {
@@ -111,6 +115,34 @@ public class FoodSpecController extends BaseController
         }
     }
 
+    /**
+     * 规格值全量替换:删除该规格组下 DB 有、但前端 objects 未传(id>0 集合外)的规格值(物理删除)。
+     * 仅对已存在规格组(id>0)生效;新增规格组无历史数据,无需 diff。
+     */
+    private void removeDeletedValues(FoodSpecs s) {
+        if (s.getId() == null || s.getId() <= 0) {
+            return;
+        }
+        // 前端保留的规格值 id(id>0 的;id<=0 是新增,不参与 diff)
+        List<Long> keepIds = s.getObjects().stream()
+                .map(FoodSpecsValue::getId)
+                .filter(id -> id != null && id > 0)
+                .collect(Collectors.toList());
+        // DB 中该规格组的全部规格值 id
+        List<Long> dbIds = foodSpecsValueService.list(
+                new LambdaQueryWrapper<FoodSpecsValue>()
+                        .eq(FoodSpecsValue::getParentId, s.getId())
+                        .select(FoodSpecsValue::getId)
+        ).stream().map(FoodSpecsValue::getId).collect(Collectors.toList());
+        // DB 有但前端没传 → 物理删除
+        List<Long> toRemove = dbIds.stream()
+                .filter(id -> !keepIds.contains(id))
+                .collect(Collectors.toList());
+        if (!toRemove.isEmpty()) {
+            foodSpecsValueService.removeByIds(toRemove);
+        }
+    }
+
     /**
      * 规格详情(带全部规格值)
      */

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

@@ -30,6 +30,10 @@ public class PosOrderInvoiceController extends BaseController {
     @PreAuthorize("@ss.hasPermi('chanting:orderInvoice:list')")
     @GetMapping("/list")
     public TableDataInfo list(PosOrderInvoiceVo query) {
+        // 管理端发票管理只看进过开票流程的(issueTriggered=1),排除仅下单捕获意图、未触发行
+        if (query.getIssueTriggered() == null) {
+            query.setIssueTriggered(1);
+        }
         startPage();
         return getDataTable(orderInvoiceService.list(query));
     }

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

@@ -200,7 +200,21 @@ public class OrderInvoiceService {
     }
 
     /**
-     * 商家出餐自动开票(010 Phase 11):读 pos_order 发票意图 → 组装 dto(个人 BuyerName=手机号、
+     * 下单捕获发票意图(010 Phase 11):把下单选的发票类型/载具/统编/捐赠码落成 pos_order_invoice
+     * 意图行(issueTriggered=0 未触发开票、invoiceStatus=0 未开);出餐 {@link #autoIssue} 在该行
+     * 上更新开票结果并置 issueTriggered=1。仅在门店可开票时由 createOrder 调用。
+     */
+    public void captureInvoiceIntent(PosOrderInvoice intent) {
+        intent.setInvoiceStatus(STATUS_NOT_ISSUED);
+        intent.setIssueTriggered(0);
+        if (intent.getApplyTime() == null) {
+            intent.setApplyTime(new Date());
+        }
+        saveOrUpdate(intent);
+    }
+
+    /**
+     * 商家出餐自动开票(010 Phase 11):读 pos_order_invoice 发票意图行 → 组装 dto(个人 BuyerName=手机号、
      * 捐赠=机构名)→ 复用开票核心 {@link #reIssueAndSave}。系统触发,不校验客户归属;货到付款未付款也开
      * (送达失败/取消时作废兜底)。门店不可开票 / 订单无发票意图 / 已开 → 静默跳过,不影响出餐。
      *
@@ -209,8 +223,14 @@ public class OrderInvoiceService {
      */
     public int autoIssue(Long orderId) {
         PosOrder order = posOrderMapper.selectPosOrderById(orderId);
-        if (order == null || StrUtil.isBlank(order.getInvoiceChoice())) {
-            return 0; // 订单不存在或无发票意图(门店不可开票时 createOrder 未写意图)
+        if (order == null) {
+            return 0; // 订单不存在
+        }
+        // 发票意图自 Phase 11 起落 pos_order_invoice(下单捕获的意图行),不再读 pos_order
+        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
+                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
+        if (row == null || StrUtil.isBlank(row.getInvoiceChoice())) {
+            return 0; // 无发票意图(门店不可开票时 createOrder 未插意图行)
         }
         Long storeId = order.getMdId();
         if (!canInvoice(storeId)) {
@@ -222,60 +242,50 @@ public class OrderInvoiceService {
             return 0;
         }
         // 防重复:已开则跳过
-        PosOrderInvoice row = posOrderInvoiceMapper.selectOne(
-                new LambdaQueryWrapper<PosOrderInvoice>().eq(PosOrderInvoice::getOrderId, orderId));
-        if (row != null && Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
+        if (Integer.valueOf(STATUS_ISSUED).equals(row.getInvoiceStatus())) {
             return 0;
         }
-        ApplyInvoiceDto dto = buildAutoIssueDto(order);
+        ApplyInvoiceDto dto = buildAutoIssueDto(row);
         EzPayConfig cfg = new EzPayConfig(ez.getMerchantId(), ez.getHashKey(), ez.getHashIv());
         // 捐赠=机构名;个人=手机号
         String loveOrgName = null;
-        if ("LOVE_CODE".equals(order.getInvoiceChoice())) {
+        if ("LOVE_CODE".equals(row.getInvoiceChoice())) {
             loveOrgName = resolveLoveOrg(dto.getLoveCode(), cfg);
             dto.setBuyerName(loveOrgName);
-        } else if (isPersonalChoice(order.getInvoiceChoice())) {
+        } else if (isPersonalChoice(row.getInvoiceChoice())) {
             InfoUser u = infoUserService.getOne(
                     new LambdaQueryWrapper<InfoUser>().eq(InfoUser::getUserId, order.getUserId()));
             dto.setBuyerName(u != null && StrUtil.isNotBlank(u.getPhone()) ? u.getPhone() : "會員");
         }
-        PosOrderInvoice saveRow = row == null ? new PosOrderInvoice() : row;
-        saveRow.setOrderId(orderId);
-        saveRow.setOrderNo(order.getDdId());
-        saveRow.setStoreId(storeId);
-        saveRow.setInvoiceCategory(dto.getCategory());
-        saveRow.setBuyerName(dto.getBuyerName());
-        saveRow.setBuyerUbn(dto.getBuyerUbn());
-        saveRow.setBuyerEmail(dto.getBuyerEmail());
-        saveRow.setCarrierType(dto.getCarrierType());
-        saveRow.setCarrierNum(dto.getCarrierNum());
-        saveRow.setLoveCode(dto.getLoveCode());
-        saveRow.setLoveOrgName(loveOrgName);
-        saveRow.setApplyTime(new Date());
-        return reIssueAndSave(order, saveRow, dto, ez);
+        // 出餐触发开票:在意图行上落结果,并标记已触发(与 invoiceStatus 独立)
+        row.setInvoiceCategory(dto.getCategory());
+        row.setBuyerName(dto.getBuyerName());
+        row.setLoveOrgName(loveOrgName);
+        row.setIssueTriggered(1);
+        return reIssueAndSave(order, row, dto, ez);
     }
 
-    /** 出餐自动开票:pos_order 发票意图 → ApplyInvoiceDto(捐赠走 DONATION 复用既有分支)。 */
-    private ApplyInvoiceDto buildAutoIssueDto(PosOrder order) {
+    /** 出餐自动开票:pos_order_invoice 发票意图行 → ApplyInvoiceDto(捐赠走 DONATION 复用既有分支)。 */
+    private ApplyInvoiceDto buildAutoIssueDto(PosOrderInvoice intent) {
         ApplyInvoiceDto dto = new ApplyInvoiceDto();
-        dto.setOrderId(order.getId());
-        String choice = order.getInvoiceChoice();
+        dto.setOrderId(intent.getOrderId());
+        String choice = intent.getInvoiceChoice();
         switch (choice) {
             case "PAPER":
             case "PHONE_BARCODE":
             case "CITIZEN":
                 dto.setCategory("B2C");
-                dto.setCarrierType(order.getCarrierType()); // PAPER 时为 null → 个人纸本分支
-                dto.setCarrierNum(order.getCarrierNum());
+                dto.setCarrierType(intent.getCarrierType()); // PAPER 时为 null → 个人纸本分支
+                dto.setCarrierNum(intent.getCarrierNum());
                 break;
             case "LOVE_CODE":
                 dto.setCategory("DONATION");
-                dto.setLoveCode(order.getLoveCode());
+                dto.setLoveCode(intent.getLoveCode());
                 break;
             case "COMPANY":
                 dto.setCategory("B2B");
-                dto.setBuyerUbn(order.getBuyerUbn());
-                dto.setBuyerName(order.getInvoiceBuyerName());
+                dto.setBuyerUbn(intent.getBuyerUbn());
+                dto.setBuyerName(intent.getBuyerName());
                 break;
             default:
                 break;

+ 16 - 11
ruoyi-admin/src/main/java/com/ruoyi/app/order/UserOrderController.java

@@ -293,24 +293,29 @@ public class UserOrderController extends BaseController {
 
             createFootprint(userId, item.getMdId());
             setPickUpNum(posOrder);
-            // 发票意图落到子订单(010 Phase 11):仅开发票门店写,否则该子订单出餐时不自动开票
+            posOrderService.save(posOrder);
+            // 发票意图落 pos_order_invoice 意图行(010 Phase 11,方案B):仅开发票门店插,
+            // issueTriggered=0 未触发;出餐 autoIssue 在该行落结果并置 issueTriggered=1
             if (!StringUtils.isEmpty(input.getInvoiceChoice()) && orderInvoiceService.canInvoice(item.getMdId())) {
                 String choice = input.getInvoiceChoice();
-                posOrder.setInvoiceChoice(choice);
-                posOrder.setCarrierNum(input.getCarrierNum());
-                posOrder.setBuyerUbn(input.getBuyerUbn());
-                posOrder.setInvoiceBuyerName(input.getBuyerName());
-                posOrder.setLoveCode(input.getLoveCode());
-                // 载具类型由 invoiceChoice 推导(PHONE_BARCODE=0 / CITIZEN=1)
+                PosOrderInvoice intent = new PosOrderInvoice();
+                intent.setOrderId(posOrder.getId());
+                intent.setOrderNo(subddId);
+                intent.setStoreId(item.getMdId());
+                intent.setInvoiceChoice(choice);
+                intent.setCarrierNum(input.getCarrierNum());
+                intent.setBuyerUbn(input.getBuyerUbn());
+                intent.setBuyerName(input.getBuyerName());
+                intent.setLoveCode(input.getLoveCode());
                 if ("PHONE_BARCODE".equals(choice)) {
-                    posOrder.setCarrierType("0");
+                    intent.setCarrierType("0");
                 } else if ("CITIZEN".equals(choice)) {
-                    posOrder.setCarrierType("1");
+                    intent.setCarrierType("1");
                 } else {
-                    posOrder.setCarrierType(input.getCarrierType());
+                    intent.setCarrierType(input.getCarrierType());
                 }
+                orderInvoiceService.captureInvoiceIntent(intent);
             }
-            posOrderService.save(posOrder);
 
             // --- 促销:写入快照 + 标记券已使用(save之后,需要posOrder.id) ---
             if (calc != null) {

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

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

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

@@ -37,6 +37,9 @@ public class PosOrderInvoice {
     /** 冗余 pos_store.id(门店) */
     private Long storeId;
 
+    /** 发票选择(下单意图) PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司 */
+    private String invoiceChoice;
+
     /** 发票类型 B2C/B2B */
     private String invoiceCategory;
 
@@ -70,6 +73,9 @@ public class PosOrderInvoice {
     /** 0未开/1已开/2失败/3作废 */
     private Integer invoiceStatus;
 
+    /** 是否已触发开票 0否1是(出餐开票流程执行过即为1;与 invoiceStatus 独立,管理端发票管理用它筛选已进入开票流程的订单) */
+    private Integer issueTriggered;
+
     /** 含税总额(= amount - freight) */
     private Integer totalAmt;
 

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

@@ -28,6 +28,9 @@ public class PosOrderInvoiceVo {
     /** 门店名称 */
     private String posName;
 
+    /** 发票选择(下单意图) PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司 */
+    private String invoiceChoice;
+
     /** 发票类型 B2C/B2B */
     private String invoiceCategory;
 
@@ -61,6 +64,9 @@ public class PosOrderInvoiceVo {
     /** 0未开/1已开/2失败/3作废 */
     private Integer invoiceStatus;
 
+    /** 是否已触发开票 0否1是(出餐开票流程执行过即为1;与 invoiceStatus 独立,管理端发票管理用它筛选已进入开票流程的订单) */
+    private Integer issueTriggered;
+
     /** 含税总额 */
     private Integer totalAmt;
 

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

@@ -13,6 +13,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             i.order_no         AS orderNo,
             i.store_id         AS storeId,
             s.pos_name         AS posName,
+            i.invoice_choice   AS invoiceChoice,
+            i.issue_triggered   AS issueTriggered,
             i.invoice_category AS invoiceCategory,
             i.buyer_name       AS buyerName,
             i.buyer_ubn        AS buyerUbn,
@@ -35,6 +37,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <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="issueTriggered != null"> AND i.issue_triggered = #{issueTriggered} </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>
@@ -51,6 +54,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             i.order_no         AS orderNo,
             i.store_id         AS storeId,
             s.pos_name         AS posName,
+            i.invoice_choice   AS invoiceChoice,
+            i.issue_triggered   AS issueTriggered,
             i.invoice_category AS invoiceCategory,
             i.buyer_name       AS buyerName,
             i.buyer_ubn        AS buyerUbn,
@@ -88,6 +93,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             i.order_no         AS orderNo,
             i.store_id         AS storeId,
             s.pos_name         AS posName,
+            i.invoice_choice   AS invoiceChoice,
+            i.issue_triggered   AS issueTriggered,
             i.invoice_category AS invoiceCategory,
             i.buyer_name       AS buyerName,
             i.carrier_type     AS carrierType,

+ 5 - 4
specs/010-order-invoice/auto-invoice-design.md

@@ -81,7 +81,7 @@ ezPay 映射:
 ### 5.1 下单捕获(`createOrder`)
 
 - `OrderCreateInput` 新增发票意图字段:`invoiceChoice`(PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、`carrierType`、`carrierNum`、`buyerUbn`、`buyerName`(公司名,仅 COMPANY)、`loveCode`、`saveAsDefault`(boolean)。
-- `createOrder` 从 JWT 取 userId → 存订单时把发票意图写入 `pos_order`(新增列,见 §7)。
+- `createOrder` 从 JWT 取 userId → 存订单时把发票意图写入 `pos_order_invoice` 意图行(`issue_triggered=0` 未触发,见 §7;方案B,不再落 pos_order)。
 - 门店守卫:`canInvoice(mdId)==false` → 该单不写发票意图(等价不开票);客户端结账页按 `canInvoice` 接口隐藏发票选项。
 - 载具/捐赠验真(见 §8):不通过则拦截下单。
 - `saveAsDefault=true` → 将该载具/统编抬头写入 `info_invoice` 并标记为默认(其余取消默认)。
@@ -90,12 +90,12 @@ ezPay 映射:
 
 - `PosOrderShOprateController.dispatchOrder` 在 `setState(2)` 之后调用 `OrderInvoiceService.autoIssue(orderId)`。
 - `autoIssue` 流程:
-  1. 读 `pos_order` 发票意图 + 门店 ezPay 凭证(`assertInvoiceable`,复用 010)。
+  1. 读 `pos_order_invoice` 发票意图行(`issue_triggered=0`) + 门店 ezPay 凭证(`assertInvoiceable`,复用 010)。
   2. 门店不可开票 / 订单无发票意图 → **跳过**(不报错,不影响出餐)。
   3. 组装 `ApplyInvoiceDto`(意图 → category/载具/统编/捐赠);**个人 BuyerName = `InfoUser.phone`**。
   4. 复用 010 开票核心(`buildIssueData` + `ezPay.issueInvoice` + 落库)。**放宽两点**:不校验客户归属(系统触发)、不强制 `payStatus==1`(货到付款出餐时未付款也要开,见 D9)。
   5. PrintFlag=Y → 码值(BarCode/QRcodeL/R)落库,供商家打印证明联。
-- 结果存 `pos_order_invoice`(复用 010),状态 已开 / 失败(失败由运营在后台重试,复用 010 retry)。
+- 在意图行上落结果(复用 010)并置 `issue_triggered=1`,状态 已开 / 失败(失败由运营在后台重试,复用 010 retry)。
 
 ### 5.3 会员发票夹(我的发票)
 
@@ -117,7 +117,8 @@ ezPay 映射:
 
 ## 7. 数据模型变更(SQL 写 `updatesql/sql.md`,不直接执行)
 
-- `pos_order` 新增发票意图列:`invoice_choice VARCHAR(16)`、`carrier_type VARCHAR(8)`、`carrier_num VARCHAR(64)`、`buyer_ubn VARCHAR(16)`、`invoice_buyer_name VARCHAR(100)`(公司名,仅 COMPANY 用)、`love_code VARCHAR(10)`。(均 DEFAULT NULL;`saveAsDefault` 不落订单,仅触发 `info_invoice` 写入)
+- **方案B**:发票意图(下单捕获)落 `pos_order_invoice` 意图行(与开票结果同表,避免意图/结果双写);`pos_order` **不加** 发票列。
+- `pos_order_invoice` 新增:`invoice_choice VARCHAR(16)`(下单意图 PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY)、`issue_triggered TINYINT NOT NULL DEFAULT 0`(是否已触发开票,与 invoice_status 独立:下单捕获=0、出餐开票执行过=1;管理端发票管理按 `issue_triggered=1` 筛选,排除未触发的意图行)。`buyer_ubn`/`carrier_type`/`carrier_num`/`love_code` 复用已有列。
 - `info_invoice` 新增 `is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头'`(014 原「无默认」FR-006,现加默认标记支持 D5)。
 
 ## 8. 载具校验

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

@@ -13,6 +13,7 @@
 | order_no | VARCHAR(64) | 冗余 `pos_order.ddId`,便于排查 |
 | store_id | BIGINT NOT NULL | 冗余 `pos_order` 所属门店 id(关联 `pos_store.id`) |
 | invoice_category | VARCHAR(8) | 发票类型:`B2C` / `B2B` |
+| invoice_choice | VARCHAR(16) | 发票选择(下单意图) PAPER/PHONE_BARCODE/CITIZEN/LOVE_CODE/COMPANY; NULL=不开票 |
 | buyer_name | VARCHAR(100) | 买方名称(B2C=客户名/邮箱名,B2B=公司名) |
 | buyer_ubn | VARCHAR(16) | 买方统一编号(统编 8 码,B2B 必填) |
 | buyer_email | VARCHAR(200) | 接收邮箱(B2C 无载具时用) |
@@ -21,6 +22,7 @@
 | invoice_number | VARCHAR(16) | ezPay 发票号(开立成功后填,字轨号段分配) |
 | random_num | VARCHAR(8) | 防伪随机码(ezPay 返回) |
 | invoice_status | TINYINT NOT NULL DEFAULT 0 | `0`未开 / `1`已开 / `2`失败 / `3`作废 |
+| issue_triggered | TINYINT NOT NULL DEFAULT 0 | 是否已触发开票 0否1是;出餐开票执行过=1,与 invoice_status 独立(下单捕获意图=0) |
 | total_amt | INT | 含税总额(= 订单实付 amount) |
 | sales_amt | INT | 销售额(未税,= round(total/1.05)) |
 | tax_amt | INT | 税额(= total - sales) |
@@ -59,6 +61,8 @@
 - 开票前置:`invoice_status ∈ {0 未开, 2 失败, 3 作废}` 才允许发起开票;`1 已开` 拒绝(FR-005)。
 - 重开:作废(3) 后可再次开票 → 状态先置 0、再走开票成功置 1(同行,新 invoice_number 覆盖旧号)。
 
+> **issue_triggered (方案B, 与 invoice_status 独立)**: 下单捕获意图时插 `pos_order_invoice` 行, `issue_triggered=0`、`invoice_status=0`; 出餐开票流程执行过即置 `issue_triggered=1` (无论成功/失败/作废)。管理端「订单发票管理」按 `issue_triggered=1` 筛选, 排除仅下单捕获、未触发的意图行。
+
 ## 复用实体(不改)
 
 - **PosOrder**(`pos_order`):开票来源。取 `id`/`ddId`/`amount`/`food`/`state`(=3 完成)/`payStatus`(=1)/门店归属(`shId`/`mdId`)。门店 id 判定:依据 CLAUDE.md 商家类型口径(普通/夜市商家用 `shId`,摊位用 `mdId`)对齐 `pos_store`。

+ 1 - 1
specs/010-order-invoice/plan.md

@@ -167,7 +167,7 @@ src/lang/{zh,tw,en,vi}.js                    # 四语言加 key
 7. **会员发票夹**:新增 `GET /system/userOrder/myInvoices`(按 user_id JOIN pos_order)。
 8. **双入口/默认**:`info_invoice` 加 `is_default`;复用 014 CRUD,全联式不建单独管理页。
 
-**数据模型**(SQL 写 `updatesql/sql.md`):`pos_order` 加发票意图列(`invoice_choice`/`carrier_type`/`carrier_num`/`buyer_ubn`/`invoice_buyer_name`/`love_code`);`info_invoice` 加 `is_default`。
+**数据模型**(SQL 写 `updatesql/sql.md`,方案B):`pos_order_invoice` 加 `invoice_choice`(下单意图)+ `issue_triggered`(是否已触发开票,与 invoice_status 独立);`pos_order` 不再加发票列;`info_invoice` 加 `is_default`。
 
 **文件改动**:`OrderCreateInput` + `createOrder`;`PosOrder`(+mapper);`OrderInvoiceService`(`autoIssue` + 补纸本分支 + BuyerName 取手机号 + 放宽校验);`PosOrderShOprateController.dispatchOrder` 挂 `autoIssue`;取消链路挂 `invalid`;`EzPay.checkBarCode`;`UserOrderController` 加 `myInvoices`;`InfoInvoiceController` 默认能力;SQL。
 

+ 1 - 1
specs/010-order-invoice/spec.md

@@ -193,7 +193,7 @@
 
 ### 新增功能需求(FR)
 
-- **FR-017**: 下单(`createOrder`)时捕获发票意图(类型 + 载具/统编/捐赠码 + saveAsDefault),存 `pos_order`。
+- **FR-017**: 下单(`createOrder`)时捕获发票意图(类型 + 载具/统编/捐赠码 + saveAsDefault),存 `pos_order_invoice` 意图行(`issue_triggered=0` 未触发;方案B,不再落 pos_order)
 - **FR-018**: 商家出餐(`dispatchOrder`,state 1→2)时对开发票门店自动开票;免用发票/未开通门店跳过。
 - **FR-019**: 个人纸本零信息兜底——B2C 无载具无捐赠 → PrintFlag=Y,BuyerName=`InfoUser.phone`,客户不填任何信息也能开。
 - **FR-020**: 载具/捐赠验真前置到下单:手機條碜调 `checkBarCode`、捐赠码调 `checkLoveCode`,验真不通过拦截下单;自然人凭证仅格式校验(无 API)。

+ 4 - 4
specs/010-order-invoice/tasks.md

@@ -265,14 +265,14 @@ description: "Task list for 订单 ezPay 电子发票开立"
 
 ### Implementation
 
-- [ ] T048 [P] SQL: `updatesql/sql.md` 追加 2026-07-27 段——① `pos_order` ADD COLUMN `invoice_choice`/`carrier_type`/`carrier_num`/`buyer_ubn`/`invoice_buyer_name`/`love_code`;② `info_invoice` ADD COLUMN `is_default TINYINT NOT NULL DEFAULT 0`
+- [x] T048 [P] SQL: `updatesql/sql.md` 追加 2026-07-28 段(方案B)——① `pos_order_invoice` ADD COLUMN `invoice_choice` + `issue_triggered`(下单意图 + 是否已触发开票);② `pos_order` 6 发票意图列废弃(若初版已执行则 DROP 回滚);③ `info_invoice` ADD COLUMN `is_default`
 - [ ] T049 [P] `OrderCreateInput.java` 加发票意图字段(invoiceChoice/carrierType/carrierNum/buyerUbn/buyerName/loveCode/saveAsDefault)
-- [ ] T050 [P] `PosOrder.java` 加对应 6 字段(+ mapper XML insert/select 如涉及则补)
+- [x] T050 [P] 方案B:`PosOrderInvoice.java` 加 `invoiceChoice` + `issueTriggered`(+ VO + mapper SELECT/WHERE);`PosOrder.java` **不加** 发票字段(意图落 pos_order_invoice)
 - [ ] T051 [P] `InfoInvoice.java`(014 实体)加 `isDefault` 字段 + mapper
 - [ ] T052 [P] `EzPay.checkBarCode(baseUrl, cfg, barCode)`:平行 `checkLoveCode`,封装 BDV `/Api_inv_application/checkBarCode`,回 IsExist。依赖 `EzPayEncryptUtil`(CheckValue 已就绪)、`URL_CHECK_BARCODE` 常量
-- [ ] T053 `UserOrderController.createOrder`:存发票意图到 pos_order;门店守卫(`canInvoice=false` 不存意图);`saveAsDefault=true` 写 info_invoice 标记默认;载具/捐赠验真(checkBarCode/checkLoveCode)前置、不通过拦截下单。依赖 T049/T050/T052
+- [x] T053 `UserOrderController.createOrder`:存发票意图到 `pos_order_invoice` 意图行(`issue_triggered=0`,经 `OrderInvoiceService.captureInvoiceIntent`);门店守卫(`canInvoice=false` 不插意图);`saveAsDefault=true` 写 info_invoice 标记默认;载具/捐赠验真前置、不通过拦截下单。依赖 T049/T050/T052
 - [ ] T054 `OrderInvoiceService.validateInvoiceInput`/`buildIssueData`:放宽 B2C(允许无载具)、补「B2C 无载具→PrintFlag=Y 纸本」分支、个人 BuyerName 取 `InfoUser.phone`(`infoUserService` 已注入)
-- [ ] T055 `OrderInvoiceService.autoIssue(orderId)`:新增——读 pos_order 发票意图 + `assertInvoiceable`;不可开票/无意图跳过;组装 `ApplyInvoiceDto`(意图→字段);复用开票核心(buildIssueData+issue+落库),放宽归属/payStatus 校验。依赖 T054
+- [x] T055 `OrderInvoiceService.autoIssue(orderId)`:新增——读 `pos_order_invoice` 发票意图 + `assertInvoiceable`;不可开票/无意图跳过;组装 `ApplyInvoiceDto`;复用开票核心(buildIssueData+issue+落库),开票后置 `issue_triggered=1`;放宽归属/payStatus 校验。依赖 T054
 - [ ] T056 `PosOrderShOprateController.dispatchOrder`:`setState(2)` 后调 `orderInvoiceService.autoIssue(orderId)`。依赖 T055
 - [x] T057 货到付款/取消兜底:**核查后无需改代码**——商家取消仅允许 state∈{0,1}、用户取消仅 state=0,都在出餐(state→2)之前;而发票出餐时才开,故标准取消路径下发票尚未开、无发票可作废,挂 invalid 是死代码。货到付款"送达被拒收"走售后/退款流程(不经这两个 cancel 入口),作废 hook 留待该流程接入时再补,本期不做。
 - [x] T058 [P] `UserOrderController` 加 `GET /myInvoices`:JWT,按 userId 分页,`pos_order_invoice` JOIN `pos_order`,字段裁剪(不返回码图)。依赖 —

+ 19 - 11
updatesql/sql.md

@@ -422,20 +422,28 @@ CREATE TABLE info_invoice (
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户发票抬头(发票信息管理)';
 ```
 
-## 2026-07-27 订单自动开票:发票意图字段 + 抬头默认标记(010-order-invoice Phase 11)
+## 2026-07-28 订单自动开票:发票意图落点调整 + 抬头默认标记(010-order-invoice Phase 11,方案B)
+
+> 方案B:发票意图(下单捕获的 invoiceChoice/载具/统编/捐赠码)改落 `pos_order_invoice` 意图行(与开票结果同表,避免意图/结果双写),`pos_order` 不再加发票列。
+> `pos_order_invoice` 加 `invoice_choice`(下单意图)+ `issue_triggered`(是否已触发开票 0/1,与 invoice_status 独立:下单捕获意图=0、出餐开票流程执行过=1;管理端「订单发票管理」按 `issue_triggered=1` 筛选,排除未触发的意图行)。
 
 ```sql
--- ① pos_order 加发票意图列(下单 createOrder 时捕获,出餐 dispatchOrder 时自动开票消费)
---    均可空;NULL 表示该单不开票(门店免用发票 / ezPay 未开通时 createOrder 不写意图)
+-- ① pos_order 发票意图列【已废弃·方案B】:意图改落 pos_order_invoice,pos_order 不再加列。
+--    若 2026-07-27 初版 pos_order 加列已执行,用以下 DROP 回滚;未执行则跳过本段。
 ALTER TABLE pos_order
-  ADD COLUMN invoice_choice      VARCHAR(16)  DEFAULT NULL COMMENT '发票选择 PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司',
-  ADD COLUMN carrier_type        VARCHAR(8)   DEFAULT NULL COMMENT '载具类型 0手机条码/1自然人凭证(仅PHONE_BARCODE/CITIZEN)',
-  ADD COLUMN carrier_num         VARCHAR(64)  DEFAULT NULL COMMENT '载具号码',
-  ADD COLUMN buyer_ubn           VARCHAR(16)  DEFAULT NULL COMMENT '公司统编(8位,仅COMPANY)',
-  ADD COLUMN invoice_buyer_name  VARCHAR(100) DEFAULT NULL COMMENT '公司名(发票买方名,仅COMPANY;个人发票买方名=InfoUser.phone运行时取不落此列)',
-  ADD COLUMN love_code           VARCHAR(10)  DEFAULT NULL COMMENT '捐赠码(3-7位,仅LOVE_CODE)';
-
--- ② info_invoice 加"是否默认"标记(014 原"无默认"FR-006,现支持全联式"同意设置为默认载具/抬头")
+  DROP COLUMN invoice_choice,
+  DROP COLUMN carrier_type,
+  DROP COLUMN carrier_num,
+  DROP COLUMN buyer_ubn,
+  DROP COLUMN invoice_buyer_name,
+  DROP COLUMN love_code;
+
+-- ② pos_order_invoice 加发票意图列(方案B:下单捕获意图 + 是否已触发开票标记)
+ALTER TABLE pos_order_invoice
+  ADD COLUMN invoice_choice   VARCHAR(16) DEFAULT NULL COMMENT '发票选择(下单意图) PAPER个人纸本/PHONE_BARCODE手机条码/CITIZEN自然人凭证/LOVE_CODE捐赠/COMPANY公司;NULL=该单不开票',
+  ADD COLUMN issue_triggered  TINYINT     NOT NULL DEFAULT 0 COMMENT '是否已触发开票 0否1是;出餐开票流程执行过即为1,与 invoice_status 独立(下单捕获意图=0;管理端发票管理按=1筛选,排除未触发的意图行)';
+
+-- ③ info_invoice 加"是否默认"标记(014 原"无默认"FR-006,现支持全联式"同意设置为默认载具/抬头")
 ALTER TABLE info_invoice
   ADD COLUMN is_default TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认载具/抬头 0否 1是(同一用户仅一条为1)';
 ```

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно