Files
docs/海外仓业务/自有业务后端接口设计.md
T
2026-10-20 15:48:19 +08:00

161 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自有业务后端接口设计
2026-10-20 总控已冻结v1,进入独占包实现;尚未执行Maven或环境验收。业务唯一依据为[首版开发契约](自有业务首版开发契约.md),已纳入其第11/12条:计划发车日期筛选、四项统计同一完整筛选,以及独立居中的运营登记弹窗。所有编号以 `dispatchNo` 为用户入口。
用户最新定位已收敛:本页是**原生排车列表装车完成后的自有车辆运营视图**。不创建第二个任务/车次主单,不保存第二份排车货物、预约、单据或审核/装车/发车/签收状态机;外配后续也消费同一排车事实。
### 原排车查询能否直接复用
- `DispatchViews.Query` 当前没有车辆ownership、计划发车日期范围和目的仓关键词过滤;`ListRow` 没有完整目的地/预约和附加运营信息。因此不能靠浏览器取一页后筛OWNED、求统计或逐行GET补齐,列表需要本包新增**只读数据库投影**。
- 原主单详情可以在服务端直接复用 `DispatchAggregateService.detail(Actor, dispatchNo)` 的 `DispatchViews.Detail`,再组合本页附加信息;已核实其真实来源,不重写主单查询协议或状态推算。新详情响应建议保留一个明确的 `dispatch: DispatchViews.Detail` 字段;独立三页签由现有事实和附加信息映射展示,不改变排车四页签。
- 新API是有自身list/edit/upload权限的投影视图与附加信息入口,**不是第二套排车API**;原排车查询/Controller/DTO/Mapper全部保持只读,不要求额外公开主流程写动作。
## 1. 已核实的事实与一个提供者缺口
| 事实 | 当前权威代码/数据 | 本页处理 |
| --- | --- | --- |
| 主单、三个阶段、计划/实际发车、装车时间 | `dispatch/entity/DispatchAggregate.java`;`ll_dispatch_order` | 只读 `ACTIVE` 且 `AFTER_LOADING/DELIVERING/DELIVERED`,必须有真实装车确认;不以 `DEPART` 历史作为入选前置 |
| 实际托数 | `DispatchContracts.ConfirmedLoad.pallets`;`loading_confirmation_json` | 按 `warehousePalletId` 去重数,不取逻辑明细数,不合计 `palletSpaces`;缺少可信确认时不伪造托数 |
| 车辆、司机、容量 | `resources_json` 的 `DispatchContracts.Resources` | 读取本趟快照;`VehicleSnapshot` 没有 ownership 字段,`MASTER` 也不等于自有 |
| 当前车辆归属 | `masterdata/vehicle/.../VehicleRepository.VehicleSnapshot.ownership`;`ll_vehicle.ownership` | 有真实 `OWNED/DISPATCHED`,但车辆可在未占用时改归属;当前表不能独自证明历史归属 |
| 历史车辆归属 | 原快照未保存ownership | 已冻结在装车完成同事务捕获真实OWNED/DISPATCHED;根接唯一hook,旧已装车TEST无快照不回填、不按当前主档降级 |
| 多目的仓、货物、各客户 | `ll_dispatch_item`、真实来源及其 destinationKey | 服务端批量汇总;同车一行,各客户来源保留 |
| 预约号、时间和分配数量 | `ll_dispatch_appointment_allocation` + `ll_inbound_fba_appointment`;查询规则见 `DispatchReadMapper.batches` | 按 destinationKey/实际分配关联返回数组,不取首个预约,也不按客户重复整车 |
| 到达、里程、燃油、运营金额 | 当前主单无此页独立权威登记 | 新增附加记录;不推导到达、不改签收、不写Finance、不改排车版本 |
`fleet/reporting/TripOperationsRepository` 仅查实际发车且按货源分类,并使用旧 JdbcTemplate;本模块不复用它的业务过滤或持久化实现。新模块严格 `Controller → Service → Repository → @Mapper`。
## 2. API、权限与入口
统一前缀 `/api/platform/v1/last-leg/fleet/owned-business`。全部要求可信平台身份 `appCode=PLATFORM, tenantId=0`,服务端重复核对真实归属、阶段和仓权限,不接受浏览器声明 ownership/客户归属/托数。
| 方法与相对路径 | 权限 | 作用 |
| --- | --- | --- |
| `GET /` | `platform:fleet:owned-business:list` | 分页与完整筛选四项统计同一只读快照返回;浏览器不逐行GET |
| `GET /{dispatchNo}` | 同上 | 独立三页签所需任务车辆、路线货物与凭证、附加信息及版本 |
| `GET /{dispatchNo}/history?page=1&size=20` | 同上 | 真实操作记录分页;无模拟初始操作 |
| `PUT /{dispatchNo}/operations` | `platform:fleet:owned-business:edit` | 运营弹窗保存;整车金额、逐目的地到达、里程与燃油同一附加版本 |
| `POST /{dispatchNo}/photos/{slot}/uploads` | `platform:fleet:owned-business:upload` | multipart `file` + `idempotencyKey`,只准备一个真实文件,不假称已经保存照片 |
| `PUT /{dispatchNo}/photos/{slot}` | 同上 | 将已上传的 fileId 绑定/替换到该分类框;独立于运营编辑权限 |
| `GET /{dispatchNo}/photos/{slot}/files/{fileId}/content?revisionVersion=` | list | 当前或明确历史版本中已绑定文件的授权代理读取,不返回File内部地址 |
| `GET /{dispatchNo}/commands/{idempotencyKey}` | list | 按当前操作者恢复本页写命令;不允许读取别人的命令载荷 |
没有创建主单、改变运输状态、删除运输或费用发布接口;不公开内部 dispatchId/tripId 作为业务编号。
## 3. 精确 DTO 草案
以下为已冻结v1结构,源码与OpenAPI按本节同步。所有 Long ID/版本通过现有Jackson策略无损输出;Money/数量测量用规范十进制字符串传输并在服务端BigDecimal校验,金额/单价不交前端重新计算。
```ts
type Stage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
type PhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK'
| 'DRIVER_LICENSE' | 'RECEIPT' | 'FUEL_START' | 'FUEL_END'
type Money = { currency: string; amount: string }
type Arrival = { destinationKey: string; actualArrivedAt: string | null }
type Photo = { slot: PhotoSlot; category: 'LOADING'|'FUEL'; fileId: string;
fileName: string; contentType: 'image/jpeg'|'image/png'; sizeBytes: string;
uploadedAt: string; savedAt: string | null }
type Operations = { money: Money | null; actualDistanceMiles: string | null;
fuelConsumptionLitresPer100Km: string | null; arrivals: Arrival[] }
type Gate = { allowed: boolean; blockReason: string | null }
type Destination = { destinationKey: string; warehouseCode: string | null;
warehouseName: string | null; address: string | null;
appointments: { appointmentNo: string; confirmationNo: string | null;
scheduledAt: string | null; allocatedPackages: string }[];
actualArrivedAt: string | null }
type Row = { dispatchNo: string; stage: Stage; plannedDepartureAt: string;
loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null;
origin: { warehouseId: string; warehouseCode: string; warehouseName: string };
vehicle: { vehicleId: string; plate: string; ownership: 'OWNED' };
driver: { name: string | null; phone: string | null };
destinations: Destination[]; actualPalletCount: number | null;
operations: Operations; unitPrice: string | null; recordVersion: string;
dispatchRecordVersion: string;
photoSummary: { loadingCount: number; fuelCount: number };
actions: { edit: Gate; upload: Gate }; sourceIssues: string[] }
type Query = { page?: number; size?: number; plannedDepartureFrom?: string;
plannedDepartureTo?: string; keyword?: string; stage?: Stage; warehouseId?: string }
type ListResult = { records: Row[]; page: number; size: number; total: number;
summary: { dispatchCount: number; afterLoadingCount: number;
deliveringCount: number; deliveredCount: number } }
type Detail = { row: Row; dispatch: DispatchViews.Detail;
cargo: { sourceKey: string; customerId: string; customerName: string | null;
orderNo: string | null; inboundCode: string | null; palletNo: string;
destinationKey: string; packages: string }[]; photos: Photo[] }
type SaveOperations = { recordVersion: string; idempotencyKey: string;
operations: Operations; reason: string | null }
type BindPhoto = { recordVersion: string; idempotencyKey: string; fileId: string }
type Command = { dispatchNo: string; action: 'SAVE_OPERATIONS'|'UPLOAD_PHOTO'|'BIND_PHOTO';
idempotencyKey: string; state: 'SUCCEEDED'|'PENDING'|'UNKNOWN_RESULT'|'REJECTED';
recordVersion: string | null; replayed: boolean; result: Detail | Photo | null;
errorCode: string | null; retryAllowed: boolean }
type History = { eventSource: 'DISPATCH'|'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null;
occurredAt: string; reason: string | null; operations: Operations; photos: Photo[] }
```
- 日期筛选明确对应 `planned_departure_at`;接口接收带时区的ISO时间边界,`[from,to)`,前端按最终选定显示时区将日期范围转换,不能靠数据库会话时区猜测。
- page默认1、size默认20且1–200,keyword最长128;匹配dispatchNo/车牌/实际目的仓,不匹配内部ID。
- 四项统计与列表使用完全相同过滤条件,按dispatch去重,统计不带LIMIT;stage筛选也同时作用于统计。标题为“排车数”,没有“固定今天”分支。
- `recordVersion` 是本页附加版本,未登记时只读返回`0`,不因GET建记录;第一笔成功绑定/登记返回`1`。不占用、不回写主单recordVersion。
- `dispatchRecordVersion`仅用于原排车事实的只读定位;运营保存和照片绑定提交本页`recordVersion`,不可混用这两个版本。
- 0金额表示明确登记零;`money=null`表示未登记。Money存在时currency和amount必须成对完整,金额`DECIMAL(18,2)`非负;单价`amount/actualPalletCount`按HALF_UP两位,只读返回;未知/零托数返回null。
- 币种仅CNY/USD,不预选、不换汇;同一dispatch只有一份money,允许同一版本化编辑更正或清空,并留不可变历史。money=null与明确0严格区分。
- 多目的地实到以原车实际destinationKey集合为边界,不额外设置站数上限,禁止重复/不属于该车的key;不要求首站到达等于签收。输入时间必须带时区;不以estimatedArrivalAt或signedAt回填。
- actualDistanceMiles为mile;fuelConsumptionLitresPer100Km为手工登记的L/100km,均可空或明确0,非负、DECIMAL(12,3)。不派生总燃油量,不写默认18.6。
- 7个PhotoSlot来自原型最终函数的5个装车框和2个油耗框;`DRIVER_LICENSE`/`RECEIPT`只表示本页图片位置,不审批司机资格,也不触发派送签收。每框一个当前文件,替换保存不可变历史,不删除File对象。无照片清空入口,避免扩大当前原型操作。
## 4. 保存、命令恢复与错误
1. 编辑/绑定先核对平台、权限、真实自有资格和三个阶段,再读取/创建附加行。并发首次创建依dispatch唯一约束串行;同一附加行内CAS版本、命令成功结果及历史同事务提交。
2. 命令唯一键为dispatchId + idempotencyKey,另存operatorId并强制匹配;摘要包含action、原recordVersion和完整规范化载荷。同键同操作者同摘要返回原结果,不同操作者403、同键异载荷409。重放先于新写版本判断,不修改原版本重发。
3. 新增独立上传命令意图,文件调用前持久化`PENDING`且不持业务行锁跨HTTP;成功后记录原fileId和元数据,不推进附加版本。上传响应未知转`UNKNOWN_RESULT`且`retryAllowed=false`,禁止自动换键/重复上传;GET命令返回原已知状态。现有File没有此业务上传请求键的查找接口,因此不承诺把未知上传自动恢复为成功。
4. 绑定时复核File的tenantId=0、两个受控businessType之一、businessId=本单内部dispatchId、fileId、JPG/PNG及大小;绑定成功才标记照片已保存。上传失败不创建新的业务主单;已知成功上传的fileId可保留原请求另行明确恢复绑定,未知文件不伪造已上传。
5. 不持派送、库存、Finance或车辆占用写锁;只写本页附加行/命令/历史。源读取与CAS的并发保护需要第7节捕获的稳定装车事实;没有新排车状态机。
6. 入口沿现有HTTP200+业务code约定;缺版本/无效金额/非法目的地为参数错误,身份与权限拒绝为403/A0403,缺主单为404/NOT_FOUND,附加版本变化/源事实变化/同键异载荷为409/A0409。建议诊断子码`OWNED_SOURCE_UNAVAILABLE`、`OWNED_RECORD_VERSION_CONFLICT`、`OWNED_COMMAND_MISMATCH`、`OWNED_UPLOAD_UNKNOWN_RESULT`,正文不向用户展示内部ID。
## 5. 新增结构建议(根维护实际DDL)
全部在Lastleg schema,MySQL8,内部BIGINT主键;表与列按根要求完整中文COMMENT。新包不写初始化或增量SQL文件。
| 表 | 精确主要字段与约束 |
| --- | --- |
| `ll_fleet_owned_business_record` | `id BIGINT PK`;`dispatch_id BIGINT NOT NULL UNIQUE`;`record_version BIGINT NOT NULL`(持久行首次成功为1);`currency VARCHAR(3) NULL`;`total_amount DECIMAL(18,2) NULL`;`actual_distance_miles DECIMAL(12,3) NULL`;`fuel_consumption_litres_per_100km DECIMAL(12,3) NULL`;`arrivals_json JSON NOT NULL`;`photos_json JSON NOT NULL`;`created_by/updated_by VARCHAR(64)`、`created_at/updated_at DATETIME(3)`。Money成对、非负与JSON形状由业务层及可实现CHECK约束共同保证 |
| `ll_fleet_owned_business_command` | `id BIGINT PK`;`dispatch_id BIGINT`、`idempotency_key VARCHAR(128)`联合唯一;另存`operator_id VARCHAR(64) NOT NULL`;`action VARCHAR(32)`;`expected_version BIGINT NULL`(上传无需编辑版本);`request_hash CHAR(64)`;`request_json JSON`;`status VARCHAR(24)`;`result_json JSON NULL`;`error_code VARCHAR(64) NULL`;`created_at/updated_at DATETIME(3)`。保留原请求和原结果,不只保存hash |
| `ll_fleet_owned_business_history` | `id BIGINT PK`;`dispatch_id BIGINT`、`record_version BIGINT`唯一;`action VARCHAR(32)`、`operator_id VARCHAR(64)`、`reason VARCHAR(1000) NULL`、`snapshot_json JSON`、`occurred_at DATETIME(3)`;索引`(dispatch_id,record_version)` |
| `ll_fleet_owned_business_ownership` | `dispatch_id BIGINT PK`;`vehicle_id BIGINT NULL`;`resource_mode VARCHAR(16) NOT NULL`;`vehicle_ownership VARCHAR(16) NOT NULL`;`vehicle_record_version BIGINT NULL`;`ownership_captured_at DATETIME(3) NOT NULL`;`loading_completion_hash VARCHAR(128) NOT NULL`;`loading_completed_at DATETIME(3) NOT NULL`。MASTER读真实VehicleRepository;原dispatch EXTERNAL资源原样保存,并记DISPATCHED及null车辆版本;仅保存归属证据,不复制主单/货物/状态 |
金额、arrival、photo可以用有界JSON存附加行,暂不新增逐目的地/逐图片业务表;历史提供文件旧版本授权依据。并发初建使用唯一键+事务锁,不用未保存的随机业务ID假造记录;主单表不更新。
## 6. 查询与分层实现
新增包内 `controller/OwnedBusinessController`、`model/OwnedBusinessModels`、`service/OwnedBusinessQueryService`、`OwnedBusinessRecordService`、`OwnedBusinessPhotoService`、`repository/OwnedBusinessRepository`、`mapper/OwnedBusinessQueryMapper`、`OwnedBusinessRecordMapper`、`OwnedBusinessCommandMapper`、`OwnedBusinessHistoryMapper` 及`gateway/OwnedBusinessFileGateway`。
所有Mapper显式`@Mapper`,由既有Lastleg根扫描注册。GET为repeatable-read快照;count/四项统计共用同一SQL过滤片段,再做主单分页,页内目的地、预约及货物使用dispatchId集合批量查询,Mapper局部只读复用真实表,不循环调用排车GET或浏览器逐行补数据。细节API直接给三个页签所需数据,不把旧车次八页签整体搬来。
## 7. 根负责的精确外部触点
1. **已冻结唯一捕获hook**:根在DispatchAggregateService.loadingComplete最终确认时点同事务调用本包port.OwnedBusinessOwnershipCapture.capture(long dispatchId, Long vehicleId, String resourceMode, String loadingCompletionHash, Instant loadingCompletedAt)。实现经Repository→Mapper持久化,MASTER只普通读VehicleRepository真实归属/版本,不反向加车辆写锁;EXTERNAL记DISPATCHED。相同原事实捕获重放只返回,异事实拒绝;原命令已成功的回放不补写。
2. **旧已装车单**:无归属快照不回填、不在GET降级使用当前主档;本页不纳入,原排车读取完全不变。新原生样本用装车同事务快照验收。
3. **文件分类(根已明确冻结)**:`OVERSEAS_DISPATCH_LOADING_PHOTO`、`OVERSEAS_DISPATCH_FUEL_PHOTO`,平台tenantId固定0、businessId为原dispatch内部ID,JPG/PNG、每文件10MiB及内容签名校验。根唯一修改File分类、直接测试和契约;本包新增代理只按此消费,并承担原dispatch可见性、类别及当前/历史引用授权。不复用客户POD、司机、运维或车次运营类别授权,也不把dispatchId冒充tripId。内部HTTP沿`/internal/file/v1/business-files/0/{type}/{dispatchId}`,不将该地址或内部业务ID交浏览器。根共享File接线不阻断本包结构实现。
4. **IAM/导航/Gateway/SQL**:根集成3权限及角色授权、Admin路由菜单、Lastleg及统一基线、Gateway合同。路径已落在现有Lastleg域,但仍由根验证实际路由注册,不推定可达。
5. **照片保存语义已冻结**:逐槽上传后绑定立即保存;绑定失败保留原fileId和原绑定键恢复,不重复上传;绑定成功才显示已保存,关闭弹框不写数据。
## 8. 冻结点与预计耗时
v1所有业务项已冻结。仅根接共享hook、File、权限及4表基线;独占包继续实现,不等待其他无关任务。
本包实现及有意义的定向测试预计2–3小时;根统一排队的编译/Maven、共享接线和环境验收另计。每阶段交付记录在本设计末尾,实际通过仅按根执行证据报告。
## 实现检查点(根统一验证前)
- port.OwnedBusinessOwnershipCapture及MANDATORY捕获提供者已落源码;根只需现有唯一loadingComplete hook。原dispatch实际模式MASTER/EXTERNAL原样持久化,不引入另一套资源模式枚举。
- 原排车和附加流水由UNION ALL在同一查询排序分页,History区分eventSource和两个可空版本;原排车事件operations=null、photos=[],不复制原流水。
- 四表DML已按根当前lastleg.sql末部字段实现:更新显式写NULL;命令二元唯一且摘要含操作者/action/原版本/请求。源码与直接回归仍在收口,尚未运行任何检查。
### 根集成所需精确门禁触点
- `scripts/synchronous-dependency-baseline.mjs`:新增 `lastleg-service/.../fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java` → file-service,REQUEST、READ_WRITE、MIXED。元数据和内容只读;上传无File远端幂等键,本域写前持久化意图、未知停写,不能自动重试。
- `lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json`:原baseline不允许宽泛fleet→dispatch-delivery,需要逐类只读边评审。本包实际复用为 Models→DispatchViews;Rows→DispatchAggregate;QueryMapper/Repository→DispatchItem;Projection→DispatchItem/DispatchContracts;QueryService→DispatchAggregateService/DispatchContracts/DispatchTypes。根hook的DispatchAggregateService→OwnedBusinessOwnershipCapture只依赖窄端口,不回调本页查询。后端不得借此访问或修改派送/库存/Finance持久化。
- OpenAPI新增文档数、Gateway自动发现Controller及Mapper注册合同均由根统一执行,不在本对话重复门禁;实际通过尚待根。
- 新附加历史从可信会话保存operatorName到snapshot;原排车历史只有operator_id、缺少姓名权威时operatorName=null,不把内部ID伪装成姓名。