From b4f1b3c85686f6a2542b5e592ca1ce0c71c580a0 Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 11:56:12 +0700 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=20V4=20=E5=A4=8D=E6=A0=B8?= =?UTF-8?q?=E8=A7=A3=E9=98=BB=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 2 +- .../backend-to-frontend-notes.md | 2 +- ...002-v4-order-task-card-domain-model-cp2.md | 8 +- .../security-access-control-boundary.md | 2 +- .../impl/ReservationV4CommandServiceImpl.java | 86 ++++++++++++++++++- .../ReservationV4CommandControllerTest.java | 67 +++++++++++++++ 6 files changed, 160 insertions(+), 7 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 9a459e5..66d72d4 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -12,7 +12,7 @@ - 名称:`M002-V4-CP7-v4-review-resolution-and-order-ownership-confirmation` - 状态:Done,已实现 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`。 - 目标:让 V4 `REVIEW_REQUIRED` 卡可以在原卡片上提交字段修正、复核说明和复核场景订单归属确认,通过后直接 `CONFIRMED` 并写当前登录用户审计。 -- 边界:CP7 不实现普通任务任意切换订单、真实 OPERA / OHIP、前端页面和历史数据迁移。 +- 边界:CP7 不实现普通任务任意切换订单、真实 OPERA / OHIP、前端页面和历史数据迁移;已 `RESOLVED` 到具体订单的 V4 订单任务不能通过复核接口换绑到其它订单,复核字段覆盖按显式 `missing_fields[]` 或未解决叶子值收口。 ## 2. 当前优先级 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index f1a3237..53eaeb0 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -472,7 +472,7 @@ RESERVATION_ROOMING_LIST_GENERATE - M002 V4 CP2 订单任务与多卡领域模型设计已落到 `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md`:后续前端 V4 页面应围绕 `order_task + source_message_card + basic_information_card + business_cards[]` 设计;V4 工作台统一列表、业务订单任务列表 / 详情和 S10/S99 来源通知详情已实现。 - M002 V4 CP3 已新增 V4 订单任务、任务卡、S10/S99 来源通知三张表和 Repository 基线;M002 V4 CP4 已把正式 V4 回调写入这些表;M002 V4 CP5 已开放查询;M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack;M002 V4 CP7 已开放复核解阻与复核场景订单归属确认。 - V4 订单任务和卡片 `availability` 已新增 `reviewable`。当前语义:`REVIEW_REQUIRED` 卡如果未被 Basic Information 或前置订单任务阻塞,会返回 `read_only=false`、`editable=true`、`confirmable=false`、`reviewable=true`、`readonly_reason_code=PROCESSABLE`;前端应调用 `review-resolution`,不要调用普通 `confirm`。 -- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/business_fields/room_items/0/pms_room_type_code","value":"RM2"}]}`。`confirmed_order_id` 在订单任务归属未解决时必填;`field_pointer` 必须来自当前卡允许编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段,不允许提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence 或校验诊断字段,也不能替换整个对象 / 数组。 +- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/business_fields/room_items/0/pms_room_type_code","value":"RM2"}]}`。`confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID,不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡允许编辑的 `basic_information.*` 或 `business_fields.*` 叶子字段;展示 payload 有 `missing_fields[]` 时只提交清单里的 pointer,没有显式清单时只提交当前值为 `null` / 空字符串的未解决叶子字段。前端不要提交来源邮件、路由、`target_order`、`order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段,也不能替换整个对象 / 数组。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft`、`confirm`、`manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 - V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT`、`adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index d90065b..352d6d7 100644 --- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md +++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md @@ -173,9 +173,10 @@ V4 新数据不再提供后端草稿保存。前端可以在页面本地维护 - 用户修正写入 `review_resolution_json` 和 `confirmed_payload_json`。 - CP7 第一版只校验 JSON Pointer 是否指向当前卡允许编辑字段;目录值必须来自信息系统受控目录的校验放到受控目录 checkpoint。 - 通过校验后卡片直接进入 `CONFIRMED`,不再进入 V3 `READY` 状态。 -- `field_overrides[].field_pointer` 必须是当前卡 `display_payload_json` 中允许编辑的 RFC 6901 JSON Pointer;第一版只允许 `basic_information.*` 或 `business_fields.*` 下已经存在的叶子标量字段,不允许替换对象或数组。 +- `field_overrides[].field_pointer` 必须是当前卡 `display_payload_json` 中允许编辑的 RFC 6901 JSON Pointer;如果当前卡展示 payload 中存在显式 `missing_fields[]`,只允许提交该清单内的 pointer;如果没有显式清单,第一版只允许 `basic_information.*` 或 `business_fields.*` 下已经存在且值为 `null` / 空字符串的未解决叶子字段,不允许替换对象或数组。 - 来源消息、路由、订单定位关系、诊断、缺失字段清单、`manual_review`、raw evidence 等只读字段不得提交。 - 如果订单任务归属未解决,复核请求必须提交 `confirmed_order_id`;后端按当前订单任务酒店校验该订单存在、非逻辑删除且不是系统隐藏订单。 +- 如果订单任务已经有 `order_id` 且 `target_resolution_status=RESOLVED`,复核请求不能提交不同的 `confirmed_order_id`,否则返回 `V4_ORDER_REBIND_NOT_ALLOWED`;普通任务任意切换订单继续后置。 - `availability.reviewable=true` 且 `card_status=REVIEW_REQUIRED` 时,前端可以展示复核提交入口;普通确认接口仍拒绝 `REVIEW_REQUIRED` 卡。 ### 7.4 Basic Information 目录规则 @@ -226,7 +227,7 @@ V4 CP2 只设计复核场景下的订单归属确认,不开放普通任务任 - 订单归属确认必须写业务审计。 - 若确认到其他订单,原临时订单可在无其它任务引用时逻辑删除。 - 已确认锁定卡片不能通过该接口二次迁移订单。 -- CP7 第一版只支持在复核解阻请求中提交 `confirmed_order_id` 完成一次性归属确认;不提供普通任务任意切换订单接口。 +- CP7 第一版只支持在复核解阻请求中提交 `confirmed_order_id` 完成未解决归属的一次性确认;如果订单任务已解析到某个订单且状态为 `RESOLVED`,只能确认同一订单,不能借复核接口切换到其它订单。 - 如果确认到的订单下存在更早未完成 V4 订单任务,后端拒绝当前复核提交,避免绕过同订单阻塞规则。 ## 9. 同订单阻塞规则 @@ -620,7 +621,8 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution - 请求 JSON 必须携带卡片 `version`;可选 `reason` 写入业务审计摘要;`confirmed_order_id` 表示复核场景确认后的本地订单 ID。 - 如果订单任务当前 `order_id=null` 或 `target_resolution_status!=RESOLVED`,`confirmed_order_id` 必填;后端会按订单任务所属酒店查询并校验真实可见订单。 - `field_overrides[]` 每项包含 `field_pointer` 和 `value`;`field_pointer` 只允许指向当前卡展示 payload 中的可编辑业务字段,不允许指向 `source_message`、`route_code`、`card_type`、`target_order`、`order_ref`、`missing_fields`、`manual_review`、`raw_evidence`、`validation_errors` 等只读诊断字段。 -- 第一版允许的写入容器是 `basic_information` 和 `business_fields`;只能改已存在的叶子标量或 `null` 字段,不能替换整个对象、数组或新增未知字段。 +- 第一版允许的写入容器是 `basic_information` 和 `business_fields`;如果展示 payload 中存在显式 `missing_fields[]`,只允许提交清单中的 pointer;否则只能改已存在且值为 `null` / 空字符串的叶子字段,不能修改已有有效值、替换整个对象 / 数组或新增未知字段。 +- 如果订单任务已经有 `order_id` 且 `target_resolution_status=RESOLVED`,`confirmed_order_id` 只能为空或等于当前订单 ID;提交其它订单 ID 会返回 `V4_ORDER_REBIND_NOT_ALLOWED`。 - Basic Information 必须先确认;如果 Basic Information 自身是 `REVIEW_REQUIRED`,允许通过本接口先复核并确认 Basic。 - 解阻过程不改写 `ai_payload_json`;用户修正写入 `review_resolution_json`,最终确认快照写入 `confirmed_payload_json`。 - 校验通过后直接 `CONFIRMED`。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index e868fe3..fc6a706 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -54,7 +54,7 @@ | `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 | | `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 | | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload | 必须写业务审计,actor 使用当前登录用户 | -| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡可编辑业务字段;订单归属未解决时必须提交当前酒店下真实可见订单 ID | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 AI 原始 payload | +| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡可编辑业务字段,优先按显式 `missing_fields[]` 或未解决叶子值收口;订单归属未解决时必须提交当前酒店下真实可见订单 ID,已 `RESOLVED` 的订单任务不得换绑不同订单 | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 AI 原始 payload | | `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 `route_code=S10/S99` 的 V4 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞;重复 ack 幂等返回当前状态且不新增审计 | 首次确认必须写业务审计,记录已读 / 已处理确认,actor 使用当前登录用户 | | `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计和原因 | | `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计 | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java index d82a28e..360f004 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java @@ -161,6 +161,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ ReservationV4TaskCardSnapshot card = requireTaskCard(orderTask.hotelId(), cardId, orderTask.id()); validateCardReviewable(orderTask, card); Long confirmedOrderId = parseConfirmedOrderId(request == null ? null : request.confirmedOrderId()); + ensureReviewOrderRebindAllowed(orderTask, confirmedOrderId); Long targetOrderId = resolveReviewTargetOrderId(orderTask, confirmedOrderId); ensureNoPriorOrderTaskBlocking(orderTask, targetOrderId); ensureBasicInformationConfirmed(orderTask, card); @@ -495,7 +496,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ String pointer = trimToNull(override == null ? null : override.fieldPointer()); List segments = decodeJsonPointer(pointer); JsonNode value = override == null || override.value() == null ? objectMapper.nullNode() : override.value(); - ensureReviewPointerWritable(card, confirmedPayload, segments, value); + ensureReviewPointerWritable(card, confirmedPayload, pointer, segments, value); if (!seenPointers.add(pointer)) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_DUPLICATED", "复核字段不能重复提交。"); } @@ -546,6 +547,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ private void ensureReviewPointerWritable( ReservationV4TaskCardSnapshot card, ObjectNode confirmedPayload, + String pointer, List segments, JsonNode value) { if (segments.isEmpty()) { @@ -565,6 +567,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ if (current.isContainerNode()) { throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。"); } + if (!isReviewPointerAllowedForResolution(confirmedPayload, pointer, current)) { + throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。"); + } } private void ensureReviewPointerInsideEditableContainer( @@ -655,6 +660,69 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + /** + * 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。 + */ + private boolean isReviewPointerAllowedForResolution( + ObjectNode confirmedPayload, + String pointer, + JsonNode current) { + Set explicitPointers = collectExplicitReviewFieldPointers(confirmedPayload); + if (!explicitPointers.isEmpty()) { + return explicitPointers.contains(pointer); + } + return isUnresolvedReviewLeaf(current); + } + + /** + * 从当前卡展示 payload 中收集兼容用的 missing_fields 指针清单。 + */ + private Set collectExplicitReviewFieldPointers(JsonNode node) { + Set pointers = new HashSet<>(); + collectExplicitReviewFieldPointers(node, pointers); + return pointers; + } + + /** + * 递归收集嵌套对象里的 missing_fields,避免前端只能处理某一种历史摆放位置。 + */ + private void collectExplicitReviewFieldPointers(JsonNode node, Set pointers) { + if (node == null || node.isMissingNode()) { + return; + } + if (node.isObject()) { + JsonNode missingFields = node.get("missing_fields"); + if (missingFields != null && missingFields.isArray()) { + for (JsonNode missingField : missingFields) { + String pointer = trimToNull(missingField == null ? null : missingField.asText()); + if (pointer != null && pointer.startsWith("/")) { + pointers.add(pointer); + } + } + } + Iterator> fields = node.fields(); + while (fields.hasNext()) { + collectExplicitReviewFieldPointers(fields.next().getValue(), pointers); + } + return; + } + if (node.isArray()) { + for (JsonNode item : node) { + collectExplicitReviewFieldPointers(item, pointers); + } + } + } + + /** + * 判断当前叶子字段是否仍是未解决值,第一版只允许修正 null 或空字符串。 + */ + private boolean isUnresolvedReviewLeaf(JsonNode current) { + if (current == null || current.isNull()) { + return true; + } + return current.isTextual() && !hasText(current.asText()); + } + private Long parseConfirmedOrderId(String confirmedOrderId) { String normalized = trimToNull(confirmedOrderId); if (normalized == null) { @@ -671,6 +739,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ } } + /** + * 阻止已确认归属的 V4 订单任务借复核接口切换到其它订单。 + */ + private void ensureReviewOrderRebindAllowed( + ReservationV4OrderTaskSnapshot orderTask, + Long confirmedOrderId) { + if (confirmedOrderId == null) { + return; + } + boolean alreadyResolvedToOrder = orderTask.orderId() != null + && ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus()); + if (alreadyResolvedToOrder && !Objects.equals(orderTask.orderId(), confirmedOrderId)) { + throw error(HttpStatus.CONFLICT, "V4_ORDER_REBIND_NOT_ALLOWED", "已确认归属的 V4 订单任务不能通过复核接口切换到其他订单。"); + } + } + private Long resolveReviewTargetOrderId(ReservationV4OrderTaskSnapshot orderTask, Long confirmedOrderId) { boolean ownershipUnresolved = orderTask.orderId() == null || !ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus()); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java index 6c01608..adf18a2 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java @@ -319,6 +319,73 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射")); } + @Test + void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception { + Long currentOrderId = 990000000000070111L; + Long otherOrderId = 990000000000070112L; + seedReservationOrder(HOTEL_ID, currentOrderId); + seedReservationOrder(HOTEL_ID, otherOrderId); + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-rebind-001", + Instant.parse("2026-07-19T01:22:10Z"), + currentOrderId, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name()); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_order_id": "990000000000070112", + "field_overrides": [ + {"field_pointer": "/business_fields/room_items/0/pms_room_type_code", "value": "RM2"} + ] + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("V4_ORDER_REBIND_NOT_ALLOWED")); + + Long persistedOrderId = jdbcTemplate.queryForObject( + "SELECT order_id FROM workflow_reservation_v4_order_task WHERE id = ?", + Long.class, + seeded.orderTask().id()); + org.assertj.core.api.Assertions.assertThat(persistedOrderId).isEqualTo(currentOrderId); + } + + @Test + void shouldRejectReviewResolutionForFieldNotMarkedUnresolved() throws Exception { + SeededOrderTask seeded = seedReviewOrderTask( + HOTEL_ID, + "mail-v4-command-review-resolved-field-001", + Instant.parse("2026-07-19T01:22:20Z"), + 990000000000070113L, + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name()); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "field_overrides": [ + {"field_pointer": "/business_fields/room_items/0/room_count", "value": 3} + ] + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_NOT_ALLOWED")); + } + @Test void shouldRejectReviewResolutionWhenOrderOwnershipUnresolvedAndConfirmedOrderMissing() throws Exception { SeededOrderTask seeded = seedReviewOrderTask(