2 Commits a4caea8c66 ... 75250afa78

Author SHA1 Message Date
  qmj 75250afa78 记录商家代客下单推送与小票打印规则 6 days ago
  qmj a3ef8ce3a9 修复LINE Pay无扫码记录时状态查询报错 6 days ago

+ 9 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/pay/LinePayOfflineService.java

@@ -169,8 +169,15 @@ public class LinePayOfflineService {
                                 Comparator.nullsFirst(Date::compareTo))
                         .thenComparing(PosOrderLinePayment::getId,
                                 Comparator.nullsFirst(Long::compareTo)))
-                .orElseThrow(() -> new ServiceException(
-                        MessageUtils.message("line.pay.offline.attempt.not.found")));
+                .orElse(null);
+        if (selected == null) {
+            // 详情页可在首次扫码前查状态;无 Offline 流水是正常查询结果,不创建流水或调用网关。
+            // NOT_STARTED 只表示尚无本地扫码记录,不代表整单未付款,也不能据此自动重发扣款。
+            LinePayOfflineResult notStarted = new LinePayOfflineResult();
+            notStarted.setDdId(String.valueOf(order.getDdId()));
+            notStarted.setStatus("NOT_STARTED");
+            return notStarted;
+        }
         return result(selected, null);
     }
 

+ 1 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/pay/dto/LinePayOfflineResult.java

@@ -10,6 +10,7 @@ public class LinePayOfflineResult {
     private Long paymentId;
     private String lineOrderId;
     private String transactionId;
+    /** NOT_STARTED 表示尚无 Offline 流水,仅用于状态查询;其他值沿用付款结果。 */
     private String status;
     private Boolean reusedAttempt;
     private Date updatedAt;

+ 1 - 2
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineMerchantAccessTest.java

@@ -109,8 +109,7 @@ class LinePayOfflineMerchantAccessTest {
     @Test
     void authorizedOrderWithoutAttemptReportsNotStartedInsteadOfForbidden() {
         when(payments.getByDdId(order.getDdId())).thenReturn(List.of());
-        assertEquals("line.pay.offline.attempt.not.found",
-                assertThrows(ServiceException.class, () -> service.status(936L, order.getDdId())).getMessage());
+        assertEquals("NOT_STARTED", service.status(936L, order.getDdId()).getStatus());
         verifyNoInteractions(client);
     }
 

+ 41 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/pay/LinePayOfflineServiceTest.java

@@ -69,6 +69,47 @@ class LinePayOfflineServiceTest {
                 eq("TWD"), eq("OFFLINE"), any())).thenReturn(intent());
     }
 
+    @Test
+    void noOfflineAttemptIsNormalNotStartedWithoutGatewayOrWrites() {
+        when(payments.getByDdId("DD-100")).thenReturn(java.util.List.of());
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            LinePayOfflineResult result = service.status(101L, " DD-100 ");
+            assertEquals("DD-100", result.getDdId());
+            assertEquals("NOT_STARTED", result.getStatus());
+            org.junit.jupiter.api.Assertions.assertNull(result.getPaymentId());
+            org.junit.jupiter.api.Assertions.assertNull(result.getTransactionId());
+            org.mockito.Mockito.verifyNoInteractions(client, facts, credentials);
+            verify(payments).getByDdId("DD-100");
+            org.mockito.Mockito.verifyNoMoreInteractions(payments);
+        }
+    }
+
+    @Test
+    void onlineAttemptIsNotExposedAsOfflinePayment() {
+        PosOrderLinePayment online = intent();
+        online.setPaymentMode("ONLINE");
+        online.setStatus("PAID");
+        when(payments.getByDdId("DD-100")).thenReturn(java.util.List.of(online));
+        try (var messages = mockStatic(MessageUtils.class, call -> call.getArgument(0))) {
+            LinePayOfflineResult result = service.status(101L, "DD-100");
+            assertEquals("NOT_STARTED", result.getStatus());
+            org.junit.jupiter.api.Assertions.assertNull(result.getLineOrderId());
+            org.mockito.Mockito.verifyNoInteractions(client, facts, credentials);
+        }
+    }
+
+    @Test
+    void existingOfflineAttemptStillReturnsItsActualStatus() {
+        PosOrderLinePayment offline = intent();
+        offline.setPaymentMode("OFFLINE");
+        offline.setStatus("REQUEST_UNKNOWN");
+        when(payments.getByDdId("DD-100")).thenReturn(java.util.List.of(offline));
+        LinePayOfflineResult result = service.status(101L, "DD-100");
+        assertEquals("PROCESSING", result.getStatus());
+        assertEquals(offline.getId(), result.getPaymentId());
+        org.mockito.Mockito.verifyNoInteractions(client, facts, credentials);
+    }
+
     @Test
     void successfulPayRequiresStrictFactsAndPersistsProvider() throws Exception {
         JSONObject info = new JSONObject();

+ 2 - 1
specs/019-line-pay/contracts/api.md

@@ -119,9 +119,10 @@ Annotations: `@Anonymous`, `@Auth`, `@RequestHeader String token`, `@RequestPara
 
 该接口验证 `MERCHANT` 来源和当前商家归属后只读本地状态,不在请求线程调用 LINE。Success `data` 与付款接口相同,但 `reusedAttempt` 可省略。规范状态:
 
-若订单尚无 Offline 尝试(包括只有 Online 历史/活跃尝试),返回业务错误 `line.pay.offline.attempt.not.found`,不得回传 Online 支付上下文。
+若订单尚无 Offline 尝试(包括只有 Online 历史/活跃尝试),返回 `code=200`、`data.status=NOT_STARTED` 和 `ddId`,支付流水字段为空;不得回传 Online 支付上下文,也不表示整单未支付或可自动重扫。
 
 ```text
+NOT_STARTED    尚无本地 Offline 尝试;刷新订单后由商家明确操作,禁止自动重发付款
 AUTH_REQUIRED  1145/1169/AUTH_READY/WAITING_AUTH,客户仍需在 LINE Pay 操作
 PROCESSING     Pay 结果未知或重复待核实,后端只读查单中
 PAID           已严格核实订单号、交易号、金额和 paymentProvider

+ 11 - 0
specs/019-line-pay/merchant-offline-app-integration.md

@@ -130,6 +130,7 @@ Header: token
 
 | `status` | 含义 | 前端操作 |
 |---|---|---|
+| `NOT_STARTED` | 查询时尚无本地扫码付款记录 | 不显示服务器错误;刷新订单及付款方式,禁止自动重发付款或自动重扫 |
 | `AUTH_REQUIRED` | 客户需要在 LINE Pay 完成选择付款方式或认证 | 提示客户在 LINE Pay 完成操作,继续查询状态,不允许重新扫码 |
 | `PROCESSING` | 后端正在确认付款结果 | 显示“支付结果确认中”,继续查询状态,不允许重新扫码 |
 | `PAID` | 付款成功 | 停止查询,提示商家收款成功,刷新订单 |
@@ -224,3 +225,13 @@ Sandbox 环境不能使用客户真实 LINE App 生成的 My Code。
 | 商家创建订单 | POST | `/system/orderShOprate/createOrder` |
 | 提交扫码付款 | POST | `/system/orderShOprate/linePay/offline/pay` |
 | 查询付款状态 | GET | `/system/orderShOprate/linePay/offline/status?ddId={ddId}` |
+
+## 2026-11-04 未扫码状态兼容
+
+`GET /offline/status` 在授权通过但没有 Offline 流水时,改为返回:
+
+```json
+{"code":200,"msg":"操作成功","data":{"ddId":"991793699529652","status":"NOT_STARTED"}}
+```
+
+其他支付流水字段为空或省略。NOT_STARTED只说明查询时尚无本地扫码记录,即使存在Online付款也不会在此接口返回Online流水。不能因此把订单标记未付款/失败,更不能自动重发`/offline/pay`。初次打开页面可显示尚未扫码;如果之前付款请求超时,应先刷新订单和状态,避免原请求仍在处理时直接重新扫码。实际付款仍由服务端支付锁、订单支付状态和活跃流水再次校验。

+ 8 - 0
specs/019-line-pay/plan.md

@@ -248,3 +248,11 @@ foodie-admin-vue/
 1. 使用真实 MerchantStoreAccessService 和模拟 Mapper 构建回归,覆盖查询、付款复用、无流水、撤权/停用/门店转移及用户单拒绝;先证明旧实现拒绝有效子账号。
 2. 在 LinePayOfflineService 共用归属门禁中注入现有授权服务,增加子账号分支,以实时授权集合、主账号及订单门店归属校验;保持其他用户类型原逻辑。
 3. 运行 JDK21 定向权限、Offline 和商家订单回归及模块构建,检查增量差异。只读线上复现与数据库取证不执行付款、数据库写入或部署;发布后需复测现场接口。
+
+## 2026-11-04 无扫码记录的状态查询
+
+只读核对订单991793699529652:MERCHANT、堂食、payType=3、payStatus=0,查询时无LINE Pay流水。当前报错源于status的orElseThrow,与已修复的分管账号权限不同。
+
+1. 在现有服务/权限测试增加无流水、仅Online流水和已有Offline流水场景,先复现报错。
+2. 仅修改status无记录分支返回NOT_STARTED;保留鉴权、对账异常与支付入口,添加注释和DTO说明。
+3. 同步规格、API及App接入说明,执行JDK21 Offline服务/权限/持久化/控制器测试及模块构建。不执行支付、数据库变更或部署。

+ 8 - 1
specs/019-line-pay/spec.md

@@ -670,5 +670,12 @@ Header: token
 - 商家分管账号(userType=5)的 Offline 付款及状态查询,必须通过022现有门店授权服务解析实时权限;不能使用可为空的 InfoUser.storeId 判断归属。
 - 有效分管账号仅可访问已授权且仍属于有效主账号的门店订单;订单 shId、门店 userId 须与解析出的主账号一致。撤权、分管账号/主账号停用、门店删除或转移后拒绝付款及状态读取。
 - 保留普通商家、夜市商家和摊主原归属规则,以及 MERCHANT 来源、单门店、付款资格和重复支付保护。
-- 授权校验通过但不存在 OFFLINE 支付流水时,沿用 line.pay.offline.attempt.not.found;不得推断为已支付或为了查询创建支付流水。
+- 授权校验通过但不存在 OFFLINE 支付流水时,按 2026-11-04 修正规则返回正常 NOT_STARTED 状态;不得推断为已支付或为了查询创建支付流水。
 - 验收:有效分管账号 storeId 为空时可查已存在的 OFFLINE 状态、可复用处理中流水;无流水返回明确的未发起提示;权限失效时在访问支付流水或网关前拒绝。
+
+## 2026-11-04 Offline 无流水状态查询修复
+
+- 授权商家可在扫码前查询状态。尚无 OFFLINE 流水(包括仅有 ONLINE 流水)时返回成功包装、`ddId`、`status=NOT_STARTED`,`paymentId/lineOrderId/transactionId/updatedAt/reusedAttempt` 为空或省略。
+- NOT_STARTED 不是支付失败、已支付或允许重扫的凭证;只表达查询时刻未查到本地扫码记录,不覆盖订单本身及 Online 的支付事实。
+- 原有来源、门店归属、分管账号实时授权与单店校验保持;无权限仍拒绝。已有 Offline 流水沿用原状态映射。对账任务按 paymentId 找不到记录仍保留异常。
+- 查询只读,不调用支付网关、不新增支付流水、不修改订单。App 不得因 NOT_STARTED 自动重发付款;需刷新订单并由商家明确操作,付款入口继续执行锁及活跃流水防重。

+ 8 - 0
specs/019-line-pay/tasks.md

@@ -196,3 +196,11 @@
 - 日志:`ruoyi-admin/target/linepay-subaccount-red.log`、`ruoyi-admin/target/linepay-subaccount-green.log`。差异检查通过,生产文件保留CRLF;未改动此前010发票和039小票文档的工作区修改。
 - 现场仅GET查询与只读事务取证;未执行付款、退款、数据库写入或迁移。未运行全项目回归、真实网关付款和发布后App联调;上述验证阶段未提交或部署;后续提交信息以 Git 记录为准,发布后仍需复测。
 - 现场订单查询时尚无LINE Pay流水,发布后权限校验通过仍应提示尚未发起扫码付款,不能把此状态当成PAID。
+
+## 2026-11-04 无扫码流水查询
+
+- [x] T069 只读核对现场订单;回归37项中3项因无记录报错,复现问题。
+- [x] T070 status无Offline流水返回NOT_STARTED,保留权限和原支付状态语义;同步API和App文档。
+- [x] T071 执行定向测试、模块构建及差异检查,记录未执行的网关/App联调。
+
+验证记录:JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=LinePayOffline*Test,LinePayOrderGuardTest -Dsurefire.failIfNoSpecifiedTests=false package` 成功,42项测试,0失败/错误/跳过,模块已打包;`git diff --check`通过。日志保存在忽略目录target/line-offline-empty-red.log及line-offline-empty-green.log。未执行真实网关扣款、App联调或部署;无需SQL。提交和推送状态以Git记录为准。

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

@@ -67,3 +67,21 @@
 - 商品或地址快照损坏会返回业务错误,App 提示错误,不打印空白小票。
 - 本轮未接入自动打印、设备绑定、打印状态回执、去重或补打记录。App 不应把每一次推送都直接当作可重复出纸的命令;新订单触发、重试与设备能力需要后续单独完成。
 - 这里是订单小票,不是电子发票证明联;不返回发票防伪码或打印发票凭证。
+
+## 商家代客下单自动打印触发(2026-11-03 已确认,后端待实现)
+
+| 代客订单支付方式 | 新订单通知与打印时机 |
+|---|---|
+| 现金 `payType=4` | 订单创建事务提交成功后;不用等待确认收款 |
+| 信用卡 `2`、LINE Pay `3`、Apple Pay `5` | 服务端确认支付成功后;创建时不打印 |
+
+以上是本轮新增规则,不代表后端或 App 已上线。到付 `1` 和线下转账 `6` 不纳入本轮现金/线上规则变更。
+
+接入步骤:
+
+1. 监听商家新订单通知。沿用业务内容中的 `ddId`、`type=0`、`pushType=1`;原生新单通知按订单类型附带 `NEW_ORDER_DELIVERY`、`NEW_ORDER_PICKUP` 或 `NEW_ORDER_DINE_IN` 的 `eventScene`。不要把所有 `type=0` 消息都当新单,应结合商家新单消息场景识别,支付成功提示等其他消息不能再次打印。
+2. 用完整 `ddId` 请求本页小票接口,按返回的订单类型、真实付款状态、商品、金额和备注排版。
+3. 按完整订单号协调打印任务并持久化已打印记录;推送重复、消息列表刷新、通知点击,以及创建页本地触发与推送同时到达,都不能重复出纸。多设备需明确由哪个打印终端执行,单设备本地去重不能保证跨设备只打印一次。
+4. 打印失败保留可重试任务,不把收到通知或取到小票数据当作打印成功。用户明确补打与首次自动打印分开处理。
+
+现金创建时 `payStatus=0` 仍可能尚未收款,不能在小票上自动标为“已付款”。通知可能延迟或丢失,App 应保留订单页手动打印入口;本轮后端不提供物理打印成功回执或跨设备打印锁。

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

@@ -16,3 +16,15 @@
 ## 2026-10-30 支付方式文字
 
 小票响应保留支付方式原始码值,新增 `payTypeName`,根据订单已保存的 `payType` 映射为台湾繁体名称。未知或空码值给出「未知付款方式」,不改变支付流程。
+
+## 2026-11-03 商家代客新单推送与小票触发计划
+
+状态:未开始(规则与代码现状已核对,生产代码尚未修改)。规格以同目录 `spec.md` 的本日追加规则为准。
+
+1. `PosOrderShOprateController.createOrder/createOrderChild`:仅对成功保存的商家现金单注册事务提交后通知,复用 `runNotificationAfterCommit` 和 `MerchantNotificationRouter.sendStoreNewOrder`。通知异常不得把已提交订单返回为创建失败;不改变支付或履约状态。
+2. 核对 `LinePayOrderNotificationService`、`OmgPaymentNotifyService` 的首次支付成功路径:商家代客外送、自取、堂食均能收到一次新单;创建未付款不提前通知。现有外送分流优先按 `type=0` 处理,而商家创建单初始 `state=1`,需避免因此漏掉商家提醒。自配送已有商家通知的路径不得再重复发送。
+3. 保持普通用户外送单在骑手接单后通知商家的规则,保留多店批次的统一通知入口;不改骑手派单、取消、退款或开票逻辑。
+4. 先补可复现测试,再完成最小代码改动及注释;覆盖现金提交/回滚、线上创建无推送、首次支付/重复支付和普通订单路由。统一执行 JDK21 定向测试、受影响模块构建和差异检查。
+5. 更新 App 接入说明:通知中的完整 `ddId` 用于读取小票,App 管理打印去重与失败恢复;后端测试不宣称已验证真机打印。
+
+不新增分支、数据库字段、SQL、字典或用户可见文案;复用现有消息国际化和声音场景。当前工作区 `specs/010-order-invoice` 的既有变更与本任务分开保留。

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

@@ -20,3 +20,24 @@
 ## 验收
 
 小票功能无需开关;有效商家可读取授权订单;跨店、普通用户和骑手被拒;普通/多店快照规格保留;无地址、旧运费、免运、积分及其他优惠可正确展示;不产生订单写入或真实打印。
+
+## 2026-11-03 商家代客下单的新单通知与小票触发(已确认,待实现)
+
+本节追加商家代客下单的新单通知范围;原小票数据接口、权限及字段契约保持不变。
+
+### 已确认规则
+
+- 商家代客现金单(`orderSource=MERCHANT`、`payType=4`):订单创建事务提交成功后发送商家新订单通知,App 收到后获取小票数据并打印。不等待确认现金收款;小票仍按真实 `payStatus` 展示,不把创建成功当成已经收款。
+- 商家代客线上支付单(信用卡 `2`、LINE Pay `3`、Apple Pay `5`):创建时不发送可触发打印的新单通知;服务端确认支付成功并提交事务后,再通知商家并由 App 打印。不能依据前端支付页面返回认定付款成功。
+- 现金确认收款及线上重复支付回调不重复触发新单打印;事务回滚、支付失败或已取消且进入退款处理的订单不触发新单打印。
+- 通知复用现有门店收件人路由和新单声音规则,覆盖店主、摊主与有权限的分管账号;携带完整子订单号 `ddId`,不使用父单号代替。
+- 外送、自取、堂食按订单类型使用现有新单场景;普通用户创建订单的支付、骑手接单和通知时机不变,多店批次通知规则不变。
+- 本次不新增或改写到付 `payType=1`、线下转账 `payType=6` 的触发规则,不把它们混同于现金 `payType=4`。
+
+### 后端与 App 边界
+
+后端负责符合条件的新单通知及现有 `/getOrderPrintInfo?ddId=...` 小票数据。App 负责接收通知、按订单号防重复、获取数据及驱动已绑定的打印机。仅改后端不代表已经实现真机自动出纸;不修改 App,不新增打印状态表或打印机绑定功能。
+
+### 验收
+
+现金创建提交后通知、提交前不通知、回滚不通知;三类线上支付创建不通知,付款成功后通知;现金确认及重复支付不重复发新单;商家代客外送单不能被普通用户单“等骑手接单”的通知分支漏掉;普通订单和多店通知无回归。App 需另行验证重复通知、创建页本地打印与推送同时到达、打印失败重试及实际出纸。

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

@@ -28,3 +28,14 @@
 - [x] 小票保留 `payType`,新增台湾繁体 `payTypeName`,覆盖现有六种码值及未知码值。
 - [x] 更新 App 接入说明与规格。
 - [x] JDK21 定向运行 12 项测试,0 失败/错误/跳过;`ruoyi-admin` 模块打包成功。
+
+## 2026-11-03 商家代客下单通知与打印触发
+
+状态:未开始(本轮已记录规格与实现计划,代码及验证待后续执行)。
+
+- [x] T005 记录用户确认的现金创建后打印、线上支付成功后打印规则,并核对当前创建及支付通知入口。
+- [x] T006 更新 App 接入说明,明确后端推送、App 获取小票/去重/驱动打印机的职责。
+- [ ] T007 补充现金创建成功提交后通知;回滚不通知、通知异常不影响已提交订单,添加代码注释。
+- [ ] T008 核对并补齐 LINE Pay/OMG 商家代客订单支付成功的新单通知,避免外送分流漏通知或自配送重复通知,保留普通用户/多店路径。
+- [ ] T009 补充现金提交/回滚、线上创建无通知、付款成功及重复回调、普通订单路由回归测试;运行 JDK21 定向测试、模块构建与差异检查。
+- [ ] T010 前端人员接入 App 自动打印和去重;使用商家 App 与打印设备完成联调和实际出纸验收(非后端代码交付范围)。