文件
hl-api-changelog/changelogs-v2/2026-09/07_7203_车务显式声明整段不用车-新增接口-管理后台.md
Mimingguang 0c042ba736
changelog-filename-gate / validate (push) Failing after 2s
docs(changelog): #7203 verified 回写;#7067 记 U5 已交付
2026-09-07 19:02:13 +08:00

26 KiB

schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer author change_type backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 7203 车务显式声明「整段不用车」:声明 / 撤销 / 回显三个端点 admin wx(GIT) 新增接口 deployed verified verified mmg 7d21f35a 2026-09-07 前端已随 #7067 U5 一并交付并验证(commit 7d21f35a):新增 useNoVehicleDeclaration 承接声明/撤销/回显三端点,落向导第②步;声明四入参(orderId/requestId ≤64 幂等/expectedRequirementVersion/reason ≤200 非空)齐;声明侧 605905(触发刷新)/605916/605917/605918(换键)/605922、撤销侧 605035/605047/605919/605920(提示+GET 一次)/605921/605923/605924 分流,三码(605921 已核实未接受可重派/605923 已被订单侧接受不可重派需对账/605924 不确定需对账)文案互不相同绝不合并;REVOKED 时 syncStatus=null 不展示同步状态、撤销仅 SYNCED 可点(模板 v-if+composable 双门禁);canDeclare=assign 模式+canAssign+零非 canceled/exception 派车行+gate.declared!==true(null 不硬拦)+无生效声明。fleet+api 737 用例全绿+checkpoint 全绿(含 Vitest 全量+生产构建)。 2026-09-07 dev-v3

车务派单:显式声明「整段不用车」(管理后台)

服务: hl-fleet-service (8087) PR: #7260 Issue: #7203 日期: 2026-09-07 影响范围: 管理后台车务派单向导——需要新增「整段不用车」的声明入口、撤销入口与状态回显


⚠️ 关键变化

纯新增,不改任何既有接口的请求或响应。 但有三件事必须在做交互前先定下来,否则会做出错的 UI:

  • syncStatus 不是订单状态。SYNCED 只表示「快照送到 order-v3 了」,不代表订单车控一定是完成态—— order-v3 可能按自己的规则忽略了这次回调(订单已取消、版本落后)。要展示订单状态请读订单侧的字段。
  • declarationStatus=REVOKED 时 syncStatus 为 null,此时不要展示同步状态。
  • 撤销按钮不是任何时候都能点:只有 syncStatus=SYNCED 才可点。PENDING 会返回 605920(提示稍后重试), FAILED 会分流到三个错误码(见错误码表),对应三种应对方式。只有 605921 分支不需要先撤销,其他两码必须走运维对账。

一、背景

#7067 去槽位化后,「某天不配车 = 该日没有这条日行」,占位行整体退役。于是「车务看完订单后决定整段 一辆车都不派」这件事,在车管侧表现为该需求下零条派车行——而这个状态与「还没开始排车」在数据上 完全无法区分。快照生成门禁因此不生成任何方案快照,订单侧收不到回调,用车需求永久卡在「处理中」。

维度 「还没排车」 「决定整段不用车」
派车行数量 0 0
需求是否已被车务处理 否 是
订单车控应该流转到 保持处理中 完成

两者在库里逐字相同,不可能靠零行推断——按零行自动推断的话,任何一个刚进车务、还没人碰的订单 都会被误判成「不用车」并推成完成。所以本单新增一个需求级的显式动作来承载这个语义。


二、变更接口清单

# 接口 方法 路径 变更类型 说明
1 声明整段不用车 POST /admin/fleet/assignments/requirements/{requirementId}/no-vehicle 新增 车务显式声明本需求整段不派车
2 撤销不用车声明 DELETE /admin/fleet/assignments/requirements/{requirementId}/no-vehicle 新增 把需求拉回处理中以便重新排车
3 回显不用车声明 GET /admin/fleet/assignments/requirements/{requirementId}/no-vehicle 新增 刷新页面后回显声明状态与原因

三个端点都在 /admin/fleet/** 通配路由下,网关无需新增配置。权限与其它车务写口一致: VEHICLE_MANAGER / SUPER_ADMIN。


三、接口详情

1. 声明整段不用车 POST /admin/fleet/assignments/requirements/{requirementId}/no-vehicle

VO: NoVehicleDeclarationReqVO → Result<NoVehicleDeclarationRespVO>

使用场景

派单向导第②步。车务确认这一单整段都不需要派车(客人自驾、当地已安排车辆、行程改为不含用车), 点「整段不用车」并填写原因。声明成功后订单侧的用车需求流转到完成,订单可以继续走后续流程。

入参

字段 位置 类型 必填 约束 说明
requirementId path Long 是 雪花 ID 当前生效用车需求 ID
orderId body Long 是 与 requirementId 做归属校验 订单 ID,不匹配按需求过期拒绝(605905)
requestId body String 是 ≤64 字符,非空 幂等键。同键同参重放返回同一结果且只产生一次快照;同键异参返回 605918
expectedRequirementVersion body Integer 是 须等于当前需求版本 不匹配返回 605905
reason body String 是 ≤200 字符,非空 不用车原因,透传给订单侧作为 noVehicleReason

出参 Result<NoVehicleDeclarationRespVO>

字段 类型 说明
requirementId String 用车需求 ID(雪花,序列化为字符串)
declarationStatus String 本地声明结果:DECLARED / REVOKED
syncStatus String 与订单侧的同步结果:PENDING / SYNCED / FAILED;REVOKED 时为 null
snapshotRevision Long 本次声明产生的方案快照版本(小整数,JSON number)
reason String 不用车原因
revokeReason String 撤销原因;未撤销时为 null
declaredAt LocalDateTime 声明时间
revokedAt LocalDateTime 撤销时间;未撤销时为 null

请求示例

POST /admin/fleet/assignments/requirements/2096644145371033602/no-vehicle
{
  "orderId": 2096643451159195650,
  "requestId": "no-vehicle-20260907-001",
  "expectedRequirementVersion": 1,
  "reason": "客人自驾,全程不需要用车"
}

响应示例

{
  "code": 200,
  "success": true,
  "data": {
    "requirementId": "2096644145371033602",
    "declarationStatus": "DECLARED",
    "syncStatus": "SYNCED",
    "snapshotRevision": 15,
    "reason": "客人自驾,全程不需要用车",
    "revokeReason": null,
    "declaredAt": "2026-09-07 13:20:11",
    "revokedAt": null
  }
}

空数据 / 降级响应

本端点不存在空数据形态:要么成功返回声明记录,要么按下表报错。 订单上下文拉取失败时不降级(fail-closed)——大交通是否声明接送机这件事一旦读成 null 就会误放行, 所以拉取失败直接报错而不是当作「无声明」。

错误响应

{
  "code": 605916,
  "success": false,
  "message": "该用车需求下存在派车记录,不能声明整段不用车;只是部分日期不用车请走按天取消",
  "data": null
}
码 触发 前端处置
605905 需求版本过期 / 订单已取消 / 需求非当前生效版本 刷新页面重取需求后重试
605047 行程已结束,只读 禁用入口
605916 该需求下存在任一非 canceled 派车行(含 completed、exception) 提示「已有派车记录,只是部分日期不用车请走按天取消」
605917 大交通已声明需要接机或送机 提示「大交通声明了接送机,请先由定制师清掉接送机要求」
605918 同一 requestId 用不同参数重复提交 换新 requestId 重来
605922 该需求已有生效的不用车声明 提示「已有声明,改原因请先撤销再重新声明」

业务边界

  • 鉴权:网关对 /admin/fleet/** 强制 VEHICLE_MANAGER / SUPER_ADMIN。
  • 幂等:持久化请求指纹((requirementId, requestId) 唯一键 + 请求 sha256)。 同键同参重放返回同一 snapshotRevision 且只产生一次快照;同键异参 605918。 并发同键由数据库唯一键仲裁,落败方按指纹判定重放还是冲突——不是先查后插,没有并发窗口。
  • 零写入保证:任何一条校验不过都在写库之前抛出,不会留下半条声明记录或半条快照事件。
  • 状态:只允许在「当前生效需求 + 行程未结束 + 零非 canceled 派车行 + 大交通无接送机声明 + 当前无生效声明」时声明。
  • 原因不可原地改:要改 reason 必须先撤销再重新声明(否则返回 605922)。 这条是刻意的——原来的实现是「回显既有声明并回 200」,那会把车务改过的新原因静默丢弃, 前端却以为保存成功了。

2. 撤销不用车声明 DELETE /admin/fleet/assignments/requirements/{requirementId}/no-vehicle

VO: NoVehicleDeclarationRevokeReqVO → Result<NoVehicleDeclarationRespVO>

使用场景

车务改主意了,要给这一单重新排车。撤销把用车需求从完成拉回处理中,之后正常走第②步排车。

入参

字段 位置 类型 必填 约束 说明
requirementId path Long 是 雪花 ID 当前生效用车需求 ID
orderId body Long 是 与 requirementId 做归属校验 订单 ID
requestId body String 是 ≤64 字符,非空 撤销侧独立的幂等键,与声明侧的 requestId 互不干扰
reason body String 否 ≤200 字符 撤销原因

出参 Result<NoVehicleDeclarationRespVO>

字段 类型 说明
requirementId String 用车需求 ID(雪花,序列化为字符串)
declarationStatus String 本地声明结果:DECLARED / REVOKED
syncStatus String 与订单侧的同步结果:PENDING / SYNCED / FAILED;REVOKED 时为 null
snapshotRevision Long 声明产生的方案快照版本(小整数,JSON number);撤销不产生新版本,回显仍是声明那一版
reason String 不用车原因
revokeReason String 撤销原因;未撤销时为 null
declaredAt LocalDateTime 声明时间
revokedAt LocalDateTime 撤销时间;未撤销时为 null

撤销成功时固定为 declarationStatus=REVOKED、syncStatus=null, snapshotRevision 回显的仍是声明那一版(撤销不产生新版本)。

请求示例

DELETE /admin/fleet/assignments/requirements/2096644145371033602/no-vehicle
{
  "orderId": 2096643451159195650,
  "requestId": "no-vehicle-revoke-20260907-001",
  "reason": "客人改主意,还是要用车"
}

响应示例

{
  "code": 200,
  "success": true,
  "data": {
    "requirementId": "2096644145371033602",
    "declarationStatus": "REVOKED",
    "syncStatus": null,
    "snapshotRevision": 15,
    "reason": "客人自驾,全程不需要用车",
    "revokeReason": "客人改主意,还是要用车",
    "declaredAt": "2026-09-07 13:20:11",
    "revokedAt": "2026-09-07 13:41:02"
  }
}

空数据 / 降级响应

该需求当前没有生效声明时不返回空数据,而是报 605919。

错误响应

{
  "code": 605920,
  "success": false,
  "message": "不用车声明正在同步到订单,请稍后再撤销",
  "data": null
}
码 触发 前端处置
605035 需求级生命周期开关未启用,撤销所依赖的链路不会被消费 提示联系运维开启配置
605047 行程已结束 禁用入口
605919 该需求当前不存在生效的不用车声明 刷新回显后重试
605920 声明快照尚未成功投递(syncStatus=PENDING) 提示「同步中,请稍后再撤销」,可轮询 GET
605921 声明快照投递已隔离,但经回查确认 order-v3 侧尚未接受该声明 提示「已核实订单侧未接受,可直接重新排车」;声明记录请联系运维重投后再撤销
605923 声明快照投递已隔离,但订单侧已接受该声明(需求已 DONE 且零非取消派车行) 不能重新排车(会被 605906 拒),联系运维对账处置隔离事件后再撤销
605924 声明快照投递已隔离,且无法确认订单侧是否已接受(需求状态为空 / PENDING / PENDING_REVIEW / REJECTED_*,或需求已 DONE 但本地还有非取消派车行) 联系运维对账后再撤销

业务边界

  • 必须先同步成功才能撤销(605920 / 605921 / 605923 / 605924)。这条是 fail-closed,不是过度谨慎: 若允许在快照还在投递时撤销,会出现「接口回了 200 REVOKED,但订单侧随后仍被那条在途快照推成完成」的 终态错乱——车管侧显示已撤销、订单侧显示已完成,且此后再撤销会被 605919 拒死。
  • 幂等:撤销侧有自己的唯一键 (requirementId, revokeRequestId) 与自己的请求指纹, 指纹绑定被撤销声明的身份,因此「撤销 D1 → 重新声明 D2 → 复用同一 revokeRequestId」会返回 605918 而不是静默假成功。
  • 撤销不产生新的快照版本:靠需求重开链路把需求拉回处理中,没有可发布的方案形态。 迟到的旧版本回调会被订单侧的版本比较判为过期忽略,不会覆盖撤销后重新排出来的方案。
  • 零写入保证:门禁不过时不写声明状态、不写重开事件。

3. 回显不用车声明 GET /admin/fleet/assignments/requirements/{requirementId}/no-vehicle

VO: NoVehicleDeclarationRespVO → Result<NoVehicleDeclarationRespVO>

使用场景

进入派单向导或刷新页面时回显:这一单是不是已经声明过整段不用车、原因是什么、同步到哪一步了。

入参

字段 位置 类型 必填 约束 说明
requirementId path Long 是 雪花 ID 当前生效用车需求 ID。本端点无查询参数、无请求体

出参 Result<NoVehicleDeclarationRespVO>

字段 类型 说明
requirementId String 用车需求 ID(雪花,序列化为字符串)
declarationStatus String 本地声明结果:DECLARED / REVOKED
syncStatus String 与订单侧的同步结果:PENDING / SYNCED / FAILED;REVOKED 时为 null
snapshotRevision Long 声明产生的方案快照版本(小整数,JSON number);撤销不产生新版本,回显仍是声明那一版
reason String 不用车原因
revokeReason String 撤销原因;未撤销时为 null
declaredAt LocalDateTime 声明时间
revokedAt LocalDateTime 撤销时间;未撤销时为 null

从未声明过时 data 为 null(不是 404、不是错误码)。

请求示例

GET /admin/fleet/assignments/requirements/2096644145371033602/no-vehicle
Authorization: Bearer {token}

响应示例

已声明:

{
  "code": 200,
  "success": true,
  "data": {
    "requirementId": "2096644145371033602",
    "declarationStatus": "DECLARED",
    "syncStatus": "SYNCED",
    "snapshotRevision": 15,
    "reason": "客人自驾,全程不需要用车",
    "revokeReason": null,
    "declaredAt": "2026-09-07 13:20:11",
    "revokedAt": null
  }
}

空数据 / 降级响应

从未声明过:

{ "code": 200, "success": true, "data": null }

已撤销(syncStatus 为 null,两个原因都保留):

{
  "code": 200,
  "success": true,
  "data": {
    "requirementId": "2096644145371033602",
    "declarationStatus": "REVOKED",
    "syncStatus": null,
    "snapshotRevision": 15,
    "reason": "客人自驾,全程不需要用车",
    "revokeReason": "客人改主意,还是要用车",
    "declaredAt": "2026-09-07 13:20:11",
    "revokedAt": "2026-09-07 13:41:02"
  }
}

错误响应

本端点不产生任何业务错误码——需求不存在或从未声明一律回 data=null。 只会出现网关层对 /admin/fleet/** 的统一鉴权失败响应:

{
  "code": 401,
  "success": false,
  "message": "未登录或登录已过期",
  "data": null
}

业务边界

  • syncStatus 是读时实时重算的,不是落库快照:投递成功或被隔离都发生在写事务之外, 直接回落库列会让页面永远显示「同步中」。
  • 已撤销时 syncStatus 为 null:该维度对撤销态不适用,前端不要展示同步状态。
  • 只读,不加锁、不产生任何副作用。

四、契约约束与正确调用方式

✅ 正确 / ❌ 错误 payload 对照

场景 ❌ 错误 ✅ 正确
改不用车原因 换个 requestId 重新 POST(会 605922) 先 DELETE 撤销,再用新 requestId POST
重试超时的声明请求 换新 requestId 重发(会产生第二条声明或 605922) 原样重发同一个 requestId 和同样的参数,返回同一结果
只是某几天不用车 调本接口(会 605916) 走按天取消 / 逐日方案里不提交那几天
判断订单是否已完成 看 syncStatus=SYNCED 读订单侧的车控状态字段

切换状态时的必要动作

  • 声明成功 → 需要刷新订单侧状态展示(用车需求会流转到完成)。
  • 撤销成功 → 需求回到处理中、恢复可排车;前端应把第②步排车入口重新放开。
  • 撤销被 605920 / 605921 / 605923 / 605924 拒绝 → 三种隔离码给车务的下一步互斥,前端不得把它们合并成一句提示,应按各码说明逐项区分处理。

五、数据库行为

新增表 fleet_no_vehicle_declaration(迁移 V20260907_002):

关注点 说明
主键 declaration_id,雪花
声明幂等 唯一键 uk_no_vehicle_declaration_requirement_request(requirement_id, request_id) + request_sha256 指纹
撤销幂等 唯一键 uk_no_vehicle_declaration_revoke_request(requirement_id, revoke_request_id) + revoke_request_sha256 指纹
状态 declaration_status(DECLARED / REVOKED)、sync_status(PENDING / SYNCED / FAILED)
与快照的关联 snapshot_event_id、snapshot_revision

声明与快照事件在同一个事务内落库;投递走既有的事务外发件箱链路,事务内不做任何远程调用。


六、边界行为

  • 同步失败(FAILED)时的处置取决于订单侧是否已接受。这是本轮二轮复审推翻的核心断言: 隔离不等于订单侧没收到。远端已提交成功但回执丢失时,发送端重试耗尽后同样会进隔离,但订单侧已是 DONE。 直接排车会被 605906 拒。故撤销接口回查订单侧实况后分流三码:
    • 605921(已核实未接受)→ 可直接重新排车,声明记录联系运维重投后再撤销
    • 605923/605924(已接受或不确定)→ 必须联系运维对账处置隔离事件后再撤销。
  • 接送机与不用车互斥。大交通声明了接机或送机时不允许声明整段不用车(605917)——接送机本身就是用车, 允许的话等于给 #7067 的接送机门禁开后门。要真的整段不用车,先由定制师改大交通声明清掉接送机要求。
  • 已发生的用车事实不可被覆盖。completed(已跑完)与 exception(未处理异常)同样触发 605916, 因为订单侧写入零车方案前会先清掉旧的派车快照,放过去就会覆盖掉已发生的用车与费用记录。
  • 只读端点不产生副作用,可以放心轮询。

六.5、枚举 / 数据字典

declarationStatus(com.hulalv.fleet.assignment.novehicle.enums.NoVehicleDeclarationStatusEnum)

值 中文 说明
DECLARED 已声明 当前生效的「整段不用车」声明
REVOKED 已撤销 声明已被撤销,需求已(或正在)回到处理中

syncStatus(com.hulalv.fleet.assignment.novehicle.enums.NoVehicleSyncStatusEnum)

值 中文 说明
PENDING 投递中 快照事件在途(含首次投递与重试退避)
SYNCED 已投递 快照已成功投递——订单侧已受理,或已按其规则忽略(订单已取消、版本落后)。不代表订单车控是完成态
FAILED 投递已隔离 重试预算耗尽,需运维重投;不代表 order-v3 没收到,订单侧是否已接受以撤销接口返回码为准
null 不适用 仅出现在 declarationStatus=REVOKED 时

七、不影响范围

  • 订单侧回调契约一个字没改:本单只是让车管在新场景下也发出那份早已被支持的「无需用车」零单元格形态。
  • 常规排车链路的快照门禁判据未动:零派车行且未声明时,行为与以前逐字相同——不发任何快照、不流转订单状态。
  • #7067 的接送机门禁语义未动。
  • 派单看板、矩阵、候选查询、司机短链均未改。
  • 网关路由未改。

八、测试环境已验证

全部在测试服网关(https://api.test.1814.love:9443)真实往返,逐条核对了订单侧落库值,不是只看 HTTP 200。 后端 HEAD 02d7e59be(PR #7260 的 squash 提交),fleet 双实例滚动部署完成。

POST   /admin/fleet/assignments/requirements/{id}/no-vehicle   → 200 DECLARED + snapshotRevision ✓
DELETE /admin/fleet/assignments/requirements/{id}/no-vehicle   → 200 REVOKED + syncStatus=null   ✓
GET    /admin/fleet/assignments/requirements/{id}/no-vehicle   → 三态(null / DECLARED / REVOKED)✓

主链路

声明成功后订单侧 order_vehicle_requirement 逐字核对: status=DONE、assignment_snapshot_revision 与响应的 snapshotRevision 一致、 assignment_completion_disposition=NO_VEHICLE_REQUIRED、assignment_count=0、 assignment_no_vehicle_reason 等于提交的原因。

撤销后 status 回到 PROCESSING,随即走正常的第②步排车 POST /admin/fleet/assignments/batch 返回 finalPlanPublished=true,证明撤销后确实恢复可排车。

拒绝分支(每条都带真实响应)

验证点 结果
五种派车行状态各拒一次(assigned / unassigned / holding / completed / exception) 全部 605916 ✓
completed 那次拒绝前后,订单侧 status/revision/disposition/update_time 与车管侧车费三列逐字未变 ✓ 零写入
大交通声明 ARRIVAL 接机 → 声明 605917 ✓
大交通声明 DEPARTURE 送机 → 声明 605917 ✓
清掉接送机声明后再声明 200 DECLARED ✓(正反两个方向都有证据)
需求版本传旧值 605905 ✓
行程已结束的需求声明 605047 ✓
撤销时快照仍在投递(syncStatus=PENDING) 605920 ✓,声明状态未变、未写出重开事件
撤销时快照已隔离(旧口径) 605921 ✓ 注:该行是二轮复审前的验证记录。新版本按订单侧实况分流为 605921/605923/605924(见错误响应表)
需求级生命周期开关关闭时撤销 605035 ✓,声明状态未变

幂等(四种情形)

  • 同 requestId 同参重放声明 → 返回同一 snapshotRevision,该需求下快照事件始终只有 1 条 ✓
  • 同 requestId 异参(换 reason) → 605918 ✓
  • 已有生效声明 + 新 requestId → 605922 ✓
  • 撤销侧同样验过:同键同参重放返回完全相同结果(含 revokedAt)、异参 605918 ✓
  • 投递未完成时重启服务能自愈:构造「声明已提交、快照事件仍 PENDING、订单侧仍 PROCESSING」的切片后重启 fleet 实例, 全程不人工触发重投,事件在下一拍定时任务被自然消费,订单侧变为 status=DONE / revision=3 / disposition=NO_VEHICLE_REQUIRED / count=0,且该需求下快照事件没有因重启多产出 ✓

并发与迟到消息

  • 并发「撤销 + 重新排车」:同一秒并发发出两个请求,撤销成功(200 REVOKED)、排车被 605906 拒绝, 车管侧 0 条派车行、订单侧回到 PROCESSING——落在合法终态,没有出现「声明已撤销但订单侧仍是零车方案」 这类交叉污染。随后再排车成功,两侧 revision / disposition / count 完全对得上 ✓
  • 迟到的旧版本回调:撤销并重新排车(订单侧已到 revision=3 / disposition=ASSIGNED)之后, 把声明那一版(revision=1)的事件重投一次,订单侧 status / assignment_snapshot_revision / disposition 一个字都没回退 ✓

单测与门禁

  • mvn -pl hl-fleet-service test:4073 / 0 failures / 0 errors / 4 skipped,架构守护门禁 13/13 绿
  • mvn -pl hl-fleet-service spotless:check 单独跑绿
  • mvn -pl hl-order-service-v3 test -Dtest=com.hulalv.order.requirement.**:605/0/0 (含两条新增消费侧用例:零单元格回调正确流转、迟到旧版本被判过期忽略)
  • 建表迁移用 testcontainers 真 MySQL8 跑过,两个唯一键的行为逐条断言(多个 NULL 可共存、重复键撞唯一约束)

验证订单:2096825710311067650 / 2096827371423236098 / 2096828488429293570 / 2096829348983046145 (本次为验收新建,产品 2056944461216100353)


十、相关文档

关联 / 联系人

链接

联系人

  • 后端负责人: @wx