优化
This commit is contained in:
@@ -10240,13 +10240,13 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
|||||||
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
|
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
|
||||||
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
|
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
|
||||||
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
||||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '订舱全部要求货柜确认国内离仓的最终时间', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;国内拖车仅RMB,财务以CNY入池', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
|
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
|
||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
|
||||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
|
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
|
||||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB'))
|
||||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||||
@@ -15141,7 +15141,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'signatu
|
|||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'signature_value', '第三方签名原值');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'signature_value', '第三方签名原值');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'tenant_id', '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_third_party_call_log', 'tenant_id', '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_arrived_warehouse_at', '车辆实际到达收货仓时间');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_arrived_warehouse_at', '车辆实际到达收货仓时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '车辆实际离仓时间;DEPARTED后供LOADED门禁使用');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_departed_at', '订舱全部要求货柜确认国内离仓的最终时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_loading_at', '实际开始装柜时间');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'actual_loading_at', '实际开始装柜时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'active_ocean_booking_id', '非终态预约的订舱唯一键');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'active_ocean_booking_id', '非终态预约的订舱唯一键');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'appointment_no', '平台拖车预约编号');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'appointment_no', '平台拖车预约编号');
|
||||||
@@ -15150,7 +15150,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id
|
|||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '停用保留列;订舱级拖车单不写入、不读取');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '停用保留列;订舱级拖车单不写入、不读取');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;国内拖车仅RMB,财务以CNY入池');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话');
|
||||||
@@ -15168,7 +15168,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_lo
|
|||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_address', '到仓装柜地点地址快照');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_address', '到仓装柜地点地址快照');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_city', '到仓地点城市快照');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_city', '到仓地点城市快照');
|
||||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_contact', '到仓地点联系人快照');
|
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'store_contact', '到仓地点联系人快照');
|
||||||
|
|||||||
+2
-2
@@ -49,7 +49,7 @@
|
|||||||
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING` 供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 |
|
||||||
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||||
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,PDA 完成不推进拖车状态 |
|
| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 |
|
||||||
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` |
|
||||||
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 |
|
||||||
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||||
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
|
||||||
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING 供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING 供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。
|
||||||
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。
|
||||||
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
|
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
|
||||||
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ package com.baiyue.firstleg.domain;
|
|||||||
|
|
||||||
public enum TruckAppointmentStatus {
|
public enum TruckAppointmentStatus {
|
||||||
DRAFT, PENDING_DISPATCH, DISPATCHED, PICKUP_IN_PROGRESS, ARRIVED_WAREHOUSE,
|
DRAFT, PENDING_DISPATCH, DISPATCHED, PICKUP_IN_PROGRESS, ARRIVED_WAREHOUSE,
|
||||||
LOADING, DEPARTED, COMPLETED, RESCHEDULED, EXCEPTION, CANCELLED
|
LOADING, COMPLETED, RESCHEDULED, EXCEPTION, CANCELLED
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
|
|||||||
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long oceanBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
|
||||||
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}}
|
||||||
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
|
public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Boolean bookingDocumentReady){}
|
||||||
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") @Digits(integer=16,fraction=2) BigDecimal payableAmount,@Pattern(regexp="RMB") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
|
||||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
|
||||||
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package com.baiyue.firstleg.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface TruckAppointmentCompletionMapper {
|
||||||
|
@Select("SELECT ocean_booking_id FROM fl_ocean_booking_container_allocation WHERE container_booking_id=#{containerId}")
|
||||||
|
Long bookingForContainer(@Param("containerId") long containerId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{bookingId} "
|
||||||
|
+ "AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE")
|
||||||
|
List<TruckAppointmentEntity> activeForUpdate(@Param("bookingId") long bookingId);
|
||||||
|
|
||||||
|
// Current reads are essential: another container can have committed while waiting for the booking lock.
|
||||||
|
@Select("""
|
||||||
|
SELECT r.id requirementId,r.quantity requiredQuantity,c.id containerId,c.container_no containerNo,
|
||||||
|
c.record_status recordStatus,p.id planId,p.plan_status planStatus,
|
||||||
|
i.id itemId,i.account_id customerId,i.order_id orderId,i.order_no orderNo,
|
||||||
|
i.inbound_id inboundId,i.inbound_code inboundCode,i.volume_cbm volumeCbm,
|
||||||
|
d.status dispatchStatus,d.confirmed_at departedAt
|
||||||
|
FROM fl_ocean_booking_container_requirement r
|
||||||
|
LEFT JOIN fl_ocean_booking_container_allocation a ON a.requirement_id=r.id AND a.ocean_booking_id=r.ocean_booking_id
|
||||||
|
LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id
|
||||||
|
LEFT JOIN fl_loading_plan p ON p.container_booking_id=c.id
|
||||||
|
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0
|
||||||
|
LEFT JOIN fl_outbound_dispatch d ON d.container_booking_id=c.id
|
||||||
|
AND d.inbound_id=i.inbound_id AND d.tenant_id=i.account_id
|
||||||
|
WHERE r.ocean_booking_id=#{bookingId}
|
||||||
|
ORDER BY r.id,c.id,p.id,i.id
|
||||||
|
FOR UPDATE
|
||||||
|
""")
|
||||||
|
List<DepartureRow> departuresForUpdate(@Param("bookingId") long bookingId);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE fl_truck_appointment SET status='COMPLETED',actual_departed_at=#{departedAt},
|
||||||
|
version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id=#{id} AND version=#{version} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')
|
||||||
|
""")
|
||||||
|
int complete(@Param("id") long id, @Param("version") int version,
|
||||||
|
@Param("departedAt") LocalDateTime departedAt);
|
||||||
|
|
||||||
|
record DepartureRow(Long requirementId, Integer requiredQuantity, Long containerId, String containerNo,
|
||||||
|
String recordStatus, Long planId, String planStatus, Long itemId,
|
||||||
|
Long customerId, Long orderId, String orderNo, Long inboundId, String inboundCode,
|
||||||
|
BigDecimal volumeCbm, String dispatchStatus, LocalDateTime departedAt) { }
|
||||||
|
}
|
||||||
@@ -11,15 +11,15 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
|
|||||||
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
|
AND d.valid=1 AND d.document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING'))
|
||||||
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
AND o.status='RELEASED' AND o.bill_of_lading_no IS NOT NULL AND o.bill_of_lading_no<>''
|
||||||
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
|
AND NOT EXISTS (SELECT 1 FROM fl_truck_appointment t WHERE t.ocean_booking_id=o.id
|
||||||
AND t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION'))
|
AND t.status NOT IN ('CANCELLED','EXCEPTION'))
|
||||||
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
AND (#{q.keyword} IS NULL OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%')
|
||||||
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%'))
|
OR o.bill_of_lading_no LIKE CONCAT('%',#{q.keyword},'%'))
|
||||||
ORDER BY o.updated_at DESC,o.id DESC
|
ORDER BY o.updated_at DESC,o.id DESC
|
||||||
</script>
|
</script>
|
||||||
""") Page<Candidate> candidates(Page<Candidate> p,@Param("q") CandidateQuery q);
|
""") Page<Candidate> candidates(Page<Candidate> p,@Param("q") CandidateQuery q);
|
||||||
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
|
@Select("SELECT * FROM fl_truck_appointment WHERE ocean_booking_id=#{oceanBookingId} AND status NOT IN ('CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("oceanBookingId") long oceanBookingId);
|
||||||
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,o.warehouse_name warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.oceanBookingId} IS NULL OR a.ocean_booking_id=#{q.oceanBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
|
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,o.warehouse_name warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,(SELECT COUNT(*) FROM fl_truck_appointment_attachment x WHERE x.appointment_id=a.id) attachment_count FROM fl_truck_appointment a LEFT JOIN fl_ocean_booking o ON o.id=a.ocean_booking_id WHERE (#{q.keyword} IS NULL OR a.appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR o.booking_no LIKE CONCAT('%',#{q.keyword},'%') OR a.vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR a.carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR a.status=#{q.status}) AND (#{q.oceanBookingId} IS NULL OR a.ocean_booking_id=#{q.oceanBookingId}) ORDER BY a.updated_at DESC,a.id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
|
||||||
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
|
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
|
||||||
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
|
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@Param("version") int version);
|
||||||
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
|
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
|
||||||
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
@Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
|
||||||
|
|||||||
+8
-6
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.firstleg.outbound.dispatch.service;
|
package com.baiyue.firstleg.outbound.dispatch.service;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.firstleg.service.TruckAppointmentCompletionService;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
|
import com.baiyue.firstleg.outbound.dispatch.domain.OutboundDispatchStatus;
|
||||||
@@ -28,13 +29,16 @@ public class OutboundDispatchService {
|
|||||||
private final OutboundDispatchRepository repository;
|
private final OutboundDispatchRepository repository;
|
||||||
private final Map<String, OutboundDispatchGateContributor> contributors;
|
private final Map<String, OutboundDispatchGateContributor> contributors;
|
||||||
private final CarrierScheduleQueryService carrierSchedules;
|
private final CarrierScheduleQueryService carrierSchedules;
|
||||||
|
private final TruckAppointmentCompletionService truckCompletion;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
public OutboundDispatchService(OutboundDispatchRepository repository,
|
||||||
List<OutboundDispatchGateContributor> contributors,
|
List<OutboundDispatchGateContributor> contributors,
|
||||||
CarrierScheduleQueryService carrierSchedules) {
|
CarrierScheduleQueryService carrierSchedules,
|
||||||
|
TruckAppointmentCompletionService truckCompletion) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.carrierSchedules = carrierSchedules;
|
this.carrierSchedules = carrierSchedules;
|
||||||
|
this.truckCompletion = truckCompletion;
|
||||||
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
|
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
|
||||||
for (OutboundDispatchGateContributor contributor : contributors) {
|
for (OutboundDispatchGateContributor contributor : contributors) {
|
||||||
if (indexed.put(contributor.code(), contributor) != null) {
|
if (indexed.put(contributor.code(), contributor) != null) {
|
||||||
@@ -44,11 +48,6 @@ public class OutboundDispatchService {
|
|||||||
this.contributors = Map.copyOf(indexed);
|
this.contributors = Map.copyOf(indexed);
|
||||||
}
|
}
|
||||||
|
|
||||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
|
||||||
List<OutboundDispatchGateContributor> contributors) {
|
|
||||||
this(repository, contributors, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Aggregate> list(Query query) {
|
public List<Aggregate> list(Query query) {
|
||||||
return List.of(aggregate(bindings(query.containerBookingId())));
|
return List.of(aggregate(bindings(query.containerBookingId())));
|
||||||
}
|
}
|
||||||
@@ -73,6 +72,7 @@ public class OutboundDispatchService {
|
|||||||
public Detail confirm(long containerBookingId, long operatorId, String operatorName,
|
public Detail confirm(long containerBookingId, long operatorId, String operatorName,
|
||||||
String key, ActionRequest request) {
|
String key, ActionRequest request) {
|
||||||
validateKey(key);
|
validateKey(key);
|
||||||
|
Long bookingId = truckCompletion.lockForContainer(containerBookingId);
|
||||||
String hash = hash("CONFIRM", containerBookingId, request);
|
String hash = hash("CONFIRM", containerBookingId, request);
|
||||||
Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash);
|
Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash);
|
||||||
if (replay != null) return replay;
|
if (replay != null) return replay;
|
||||||
@@ -92,6 +92,7 @@ public class OutboundDispatchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutboundDispatchEntity representative = states.get(0);
|
OutboundDispatchEntity representative = states.get(0);
|
||||||
|
if (bookingId != null) truckCompletion.completeForBooking(bookingId);
|
||||||
history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
|
history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
|
||||||
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
|
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ public class OutboundDispatchService {
|
|||||||
public Detail reverse(long containerBookingId, long operatorId, String operatorName,
|
public Detail reverse(long containerBookingId, long operatorId, String operatorName,
|
||||||
String key, ActionRequest request) {
|
String key, ActionRequest request) {
|
||||||
validateKey(key);
|
validateKey(key);
|
||||||
|
truckCompletion.lockForContainer(containerBookingId);
|
||||||
String hash = hash("REVERSE", containerBookingId, request);
|
String hash = hash("REVERSE", containerBookingId, request);
|
||||||
Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash);
|
Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash);
|
||||||
if (replay != null) return replay;
|
if (replay != null) return replay;
|
||||||
|
|||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
|
||||||
|
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper;
|
||||||
|
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||||
|
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TruckAppointmentCompletionService {
|
||||||
|
private final TruckAppointmentCompletionMapper mapper;
|
||||||
|
private final OceanBookingRepository bookings;
|
||||||
|
private final TruckAppointmentRepository appointments;
|
||||||
|
private final TruckAppointmentFeeService fees;
|
||||||
|
|
||||||
|
/** All writers lock the booking before appointment/outbound rows, serializing multiple container departures. */
|
||||||
|
public void lockBooking(long bookingId) {
|
||||||
|
if (bookings.findForUpdate(bookingId) == null) {
|
||||||
|
throw new BizException(ResultCode.NOT_FOUND, "关联订舱不存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long lockForContainer(long containerId) {
|
||||||
|
Long bookingId = mapper.bookingForContainer(containerId);
|
||||||
|
if (bookingId != null) lockBooking(bookingId);
|
||||||
|
return bookingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void completeForBooking(long bookingId) {
|
||||||
|
lockBooking(bookingId);
|
||||||
|
var active = mapper.activeForUpdate(bookingId);
|
||||||
|
if (active.isEmpty()) return;
|
||||||
|
List<DepartureRow> rows = mapper.departuresForUpdate(bookingId);
|
||||||
|
if (!allDeparted(rows)) return;
|
||||||
|
LocalDateTime departedAt = rows.stream().map(DepartureRow::departedAt)
|
||||||
|
.max(LocalDateTime::compareTo).orElseThrow();
|
||||||
|
for (var appointment : active) {
|
||||||
|
// Fees, their immutable snapshots, Outbox and completion history share this transaction.
|
||||||
|
fees.publish(appointment, rows, departedAt);
|
||||||
|
if (mapper.complete(appointment.getId(), appointment.getVersion(), departedAt) != 1) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "拖车预约已变化,请刷新后重试");
|
||||||
|
}
|
||||||
|
var history = new TruckAppointmentHistoryEntity();
|
||||||
|
history.setId(IdWorker.getId());
|
||||||
|
history.setAppointmentId(appointment.getId());
|
||||||
|
history.setRevision(appointment.getRevision() + 1);
|
||||||
|
history.setAction("AUTO_COMPLETE");
|
||||||
|
history.setFromStatus(appointment.getStatus().name());
|
||||||
|
history.setToStatus("COMPLETED");
|
||||||
|
history.setReason("订舱全部要求货柜已确认离开国内仓库,自动完成拖车并发布应付拖车费");
|
||||||
|
history.setOperatorId(0L);
|
||||||
|
history.setOperatorName("系统");
|
||||||
|
history.setCreatedAt(LocalDateTime.now());
|
||||||
|
appointments.history(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean allDeparted(List<DepartureRow> rows) {
|
||||||
|
if (rows.isEmpty()) return false;
|
||||||
|
if (rows.stream().anyMatch(row -> row.requirementId() == null || row.requiredQuantity() == null
|
||||||
|
|| row.requiredQuantity() <= 0 || row.containerId() == null || !"ACTIVE".equals(row.recordStatus())
|
||||||
|
|| row.planId() == null || !"COMPLETED".equals(row.planStatus()) || row.itemId() == null
|
||||||
|
|| !"WAITING_FOR_LOADING".equals(row.dispatchStatus()) || row.departedAt() == null)) return false;
|
||||||
|
return rows.stream().collect(Collectors.groupingBy(DepartureRow::requirementId)).values().stream()
|
||||||
|
.allMatch(group -> group.stream().map(DepartureRow::containerId).distinct().count()
|
||||||
|
== group.get(0).requiredQuantity());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.*;
|
||||||
|
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||||
|
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||||
|
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TruckAppointmentFeeService {
|
||||||
|
private static final String TRUCKING_FEE_CODE = "FEE_TYPE_108";
|
||||||
|
private final FeeItemRepository feeItems;
|
||||||
|
private final ContainerFeeDetailService containerFees;
|
||||||
|
private final WarehouseSupplierSnapshotService suppliers;
|
||||||
|
|
||||||
|
public void publish(TruckAppointmentEntity appointment, List<DepartureRow> rows, LocalDateTime departedAt) {
|
||||||
|
BigDecimal total = appointment.getPayableAmount();
|
||||||
|
if (total == null || total.signum() == 0) return;
|
||||||
|
if (!"RMB".equals(appointment.getCurrency())) throw conflict("国内拖车应付只支持人民币");
|
||||||
|
var item = feeItems.findItemByCode(TRUCKING_FEE_CODE);
|
||||||
|
if (item == null || !"ACTIVE".equals(item.getStatus()) || !Boolean.TRUE.equals(item.getPickupPayable())) {
|
||||||
|
throw conflict("国内拖车费项目不存在、已停用或未启用应付");
|
||||||
|
}
|
||||||
|
suppliers.trucking(appointment.getCarrierId(), null);
|
||||||
|
List<Share> shares = allocate(total, rows);
|
||||||
|
Map<Long, List<Share>> plans = new LinkedHashMap<>();
|
||||||
|
shares.forEach(share -> plans.computeIfAbsent(share.owner().planId(), ignored -> new ArrayList<>()).add(share));
|
||||||
|
for (var entry : plans.entrySet()) {
|
||||||
|
BigDecimal planAmount = entry.getValue().stream().map(Share::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
var fee = new SaveRequest(item.getId(), "PAYABLE", departedAt.toLocalDate(), planAmount, "CNY",
|
||||||
|
appointment.getCarrierId(), "ON_ACCOUNT", null, null, null, null,
|
||||||
|
"国内拖车单 " + appointment.getAppointmentNo() + ";按实装体积分摊;拖车总额 " + total.toPlainString() + " 元");
|
||||||
|
var allocations = entry.getValue().stream().map(share -> new Allocation(share.owner().customerId(),
|
||||||
|
share.owner().orderId(), share.owner().orderNo(), share.owner().inboundId(), share.owner().inboundCode(), share.amount())).toList();
|
||||||
|
var created = containerFees.createAllocationGroup(entry.getKey(), 0L, new AllocationGroupRequest(fee, allocations));
|
||||||
|
containerFees.approveAllocationGroup(entry.getKey(), created.get(0).getAllocationGroupId(), 0L, "系统(拖车离仓完成)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest remainders allocate exact cents, with deterministic ties and no zero-amount finance sources. */
|
||||||
|
static List<Share> allocate(BigDecimal total, List<DepartureRow> rows) {
|
||||||
|
if (total.signum() <= 0 || total.scale() > 2) throw conflict("拖车费必须为正数且最多两位小数");
|
||||||
|
Map<Owner, BigDecimal> volumes = new LinkedHashMap<>();
|
||||||
|
for (DepartureRow row : rows) {
|
||||||
|
if (row.customerId() == null || row.customerId() <= 0 || row.orderId() == null || row.inboundId() == null
|
||||||
|
|| row.orderNo() == null || row.orderNo().isBlank() || row.inboundCode() == null || row.inboundCode().isBlank()
|
||||||
|
|| row.volumeCbm() == null || row.volumeCbm().signum() <= 0) {
|
||||||
|
throw conflict("拖车费分摊缺少柜内订单归属或有效实装体积");
|
||||||
|
}
|
||||||
|
var owner = new Owner(row.planId(), row.customerId(), row.orderId(), row.orderNo(), row.inboundId(), row.inboundCode());
|
||||||
|
volumes.merge(owner, row.volumeCbm(), BigDecimal::add);
|
||||||
|
}
|
||||||
|
if (volumes.isEmpty()) throw conflict("拖车费没有可分摊的柜内订单");
|
||||||
|
var owners = volumes.keySet().stream().sorted(Comparator.comparing(Owner::planId)
|
||||||
|
.thenComparing(Owner::customerId).thenComparing(Owner::inboundId)).toList();
|
||||||
|
BigDecimal volume = volumes.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
BigDecimal cents = total.movePointRight(2).setScale(0, RoundingMode.UNNECESSARY);
|
||||||
|
var amounts = new ArrayList<BigDecimal>();
|
||||||
|
var remainders = new ArrayList<BigDecimal>();
|
||||||
|
for (var owner : owners) {
|
||||||
|
BigDecimal numerator = cents.multiply(volumes.get(owner));
|
||||||
|
BigDecimal floor = numerator.divide(volume, 0, RoundingMode.DOWN);
|
||||||
|
amounts.add(floor);
|
||||||
|
remainders.add(numerator.subtract(floor.multiply(volume)));
|
||||||
|
}
|
||||||
|
int remaining = cents.subtract(amounts.stream().reduce(BigDecimal.ZERO, BigDecimal::add)).intValueExact();
|
||||||
|
var ranked = java.util.stream.IntStream.range(0, owners.size()).boxed()
|
||||||
|
.sorted(Comparator.<Integer, BigDecimal>comparing(remainders::get).reversed().thenComparingInt(Integer::intValue)).toList();
|
||||||
|
for (int i = 0; i < remaining; i++) amounts.set(ranked.get(i), amounts.get(ranked.get(i)).add(BigDecimal.ONE));
|
||||||
|
return java.util.stream.IntStream.range(0, owners.size()).filter(i -> amounts.get(i).signum() > 0)
|
||||||
|
.mapToObj(i -> new Share(owners.get(i), amounts.get(i).movePointLeft(2))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
record Owner(Long planId, Long customerId, Long orderId, String orderNo, Long inboundId, String inboundCode) { }
|
||||||
|
record Share(Owner owner, BigDecimal amount) { }
|
||||||
|
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||||
|
}
|
||||||
@@ -32,8 +32,9 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
@Service
|
@Service
|
||||||
public class TruckAppointmentService {
|
public class TruckAppointmentService {
|
||||||
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
||||||
private static final Set<String> CURRENCIES = Set.of("RMB", "USD");
|
private static final Set<String> CURRENCIES = Set.of("RMB");
|
||||||
@Resource TruckAppointmentRepository repo;
|
@Resource TruckAppointmentRepository repo;
|
||||||
|
@Resource TruckAppointmentCompletionService completion;
|
||||||
@Resource OceanBookingRepository oceanBookings;
|
@Resource OceanBookingRepository oceanBookings;
|
||||||
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||||
@Resource TruckAppointmentFileClient files;
|
@Resource TruckAppointmentFileClient files;
|
||||||
@@ -46,10 +47,23 @@ public class TruckAppointmentService {
|
|||||||
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
|
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
|
||||||
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
|
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
|
||||||
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
|
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
|
||||||
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
|
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);completion.lockBooking(r.oceanBookingId());TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(e.getId()));}
|
||||||
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return enrich(required(id));}
|
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);completion.lockBooking(Math.min(e.getOceanBookingId(),r.oceanBookingId()));if(!Objects.equals(e.getOceanBookingId(),r.oceanBookingId()))completion.lockBooking(Math.max(e.getOceanBookingId(),r.oceanBookingId()));checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(id));}
|
||||||
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion());if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");TruckAppointmentStatus from=e.getStatus(),to=next(from,action);String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
@Transactional
|
||||||
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
|
public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){
|
||||||
|
String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);
|
||||||
|
TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;
|
||||||
|
TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion());
|
||||||
|
if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口");
|
||||||
|
TruckAppointmentStatus from=e.getStatus(),to=next(from,action);
|
||||||
|
String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null;
|
||||||
|
int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion());
|
||||||
|
if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试");
|
||||||
|
record(required(id),from.name(),to.name(),action,r.reason(),uid,name);
|
||||||
|
completion.completeForBooking(e.getOceanBookingId());
|
||||||
|
return completeOperation(id,action,idem,enrich(required(id)));
|
||||||
|
}
|
||||||
|
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);completion.lockBooking(e.getOceanBookingId());checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); TruckAppointmentEntity active=repo.firstActiveForUpdate(e.getOceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=enrich(required(id));record(after,from,"RESCHEDULED",action,r.reason(),uid,name);completion.completeForBooking(e.getOceanBookingId());return completeOperation(id,action,idem,enrich(required(id)));}
|
||||||
private AssociationContext validateAssociations(SaveRequest r){
|
private AssociationContext validateAssociations(SaveRequest r){
|
||||||
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
OceanBookingEntity booking=oceanBookings.find(r.oceanBookingId());
|
||||||
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");
|
||||||
@@ -59,9 +73,9 @@ public class TruckAppointmentService {
|
|||||||
oceanBookingDocuments.requireActiveValidDocument(booking);
|
oceanBookingDocuments.requireActiveValidDocument(booking);
|
||||||
return new AssociationContext(booking,warehouseId,billOfLadingNo);
|
return new AssociationContext(booking,warehouseId,billOfLadingNo);
|
||||||
}
|
}
|
||||||
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");}
|
private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");if(r.payableAmount()!=null&&(r.payableAmount().signum()<0||r.payableAmount().scale()>2))throw bad("拖车费必须为非负数且最多两位小数");}
|
||||||
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
|
private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;}
|
||||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||||
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
||||||
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
|
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;}
|
||||||
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(null);e.setLoadingPlanId(null);e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
|
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(null);e.setLoadingPlanId(null);e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setCurrency(currency(r.currency()));e.setRemark(r.remark());}
|
||||||
@@ -82,8 +96,8 @@ public class TruckAppointmentService {
|
|||||||
}
|
}
|
||||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||||
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
|
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
|
||||||
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}
|
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("国内拖车应付只支持人民币RMB");return normalized;}
|
||||||
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+",请继续编辑":"该订舱已存在拖车预约,请刷新后继续编辑");}
|
private static BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该订舱已存在拖车预约 "+appointmentNo+(existing.getStatus()==TruckAppointmentStatus.COMPLETED?",请查看已完成拖车单":",请继续编辑"):"该订舱已存在拖车预约,请刷新后继续编辑");}
|
||||||
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
|
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
|
||||||
private record AssociationContext(OceanBookingEntity booking,Long warehouseId,String billOfLadingNo){}
|
private record AssociationContext(OceanBookingEntity booking,Long warehouseId,String billOfLadingNo){}
|
||||||
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
|
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
|||||||
planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
||||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间',
|
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间',
|
||||||
actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间',
|
actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间',
|
||||||
actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用',
|
actual_departed_at DATETIME(3) NULL COMMENT '订舱全部要求货柜确认国内离仓的最终时间',
|
||||||
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
|
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
|
||||||
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD',
|
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;国内拖车仅RMB,财务以CNY入池',
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
|
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
|
||||||
active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键',
|
active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN ocean_booking_id ELSE NULL END) STORED COMMENT '非终态预约的订舱唯一键',
|
||||||
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
@@ -43,8 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
|||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
|
||||||
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
|
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id),
|
||||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
|
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_ocean(ocean_booking_id),
|
||||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB'))
|
||||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||||
@@ -97,7 +97,7 @@ CALL phase3_truck_add_column(
|
|||||||
);
|
);
|
||||||
CALL phase3_truck_add_column(
|
CALL phase3_truck_add_column(
|
||||||
'actual_departed_at',
|
'actual_departed_at',
|
||||||
'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at'
|
'DATETIME(3) NULL COMMENT ''订舱全部要求货柜确认国内离仓的最终时间'' AFTER actual_loading_at'
|
||||||
);
|
);
|
||||||
CALL phase3_truck_add_column(
|
CALL phase3_truck_add_column(
|
||||||
'active_ocean_booking_id',
|
'active_ocean_booking_id',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
|||||||
info:
|
info:
|
||||||
title: Truck Appointment API
|
title: Truck Appointment API
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion does not change a booking-level truck appointment.
|
description: A truck appointment belongs to one RELEASED ocean booking, independently of when or how that booking's internal containers are linked. Each booking may have at most one non-terminal appointment. The bill-of-lading number and warehouse are frozen from the booking, which must own at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Container identities and real container numbers remain in the container/loading modules and are not part of this API. PDA loading completion alone does not complete the appointment. All required containers must be allocated and confirmed departed from the domestic warehouse. Completion atomically creates approved ON_ACCOUNT container payable fees allocated by loaded volume and publishes their immutable snapshots through the existing Finance Outbox. Create, edit, submit and reschedule also recheck departure facts, covering late appointments. Zero amounts create no fee. Cancelled and exception appointments are excluded. RMB is the only appointment currency and maps to CNY in Finance.
|
||||||
paths:
|
paths:
|
||||||
/api/platform/v1/truck-appointments:
|
/api/platform/v1/truck-appointments:
|
||||||
get:
|
get:
|
||||||
@@ -21,7 +21,7 @@ paths:
|
|||||||
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
|
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
|
||||||
post:
|
post:
|
||||||
summary: Create a truck appointment for a released booking
|
summary: Create a truck appointment for a released booking
|
||||||
description: If the booking already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. The database booking-level unique key provides the same guarantee for concurrent creates.
|
description: If the booking already has an active or completed appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. The database booking-level unique key provides the same guarantee for concurrent creates.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -29,11 +29,11 @@ paths:
|
|||||||
schema: {$ref: '#/components/schemas/SaveRequest'}
|
schema: {$ref: '#/components/schemas/SaveRequest'}
|
||||||
responses:
|
responses:
|
||||||
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
|
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
|
||||||
'409': {description: 'A0409 - this booking already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'}
|
'409': {description: 'A0409 - this booking already has an active or completed appointment; message includes its appointment number and asks the operator to continue editing'}
|
||||||
/api/platform/v1/truck-appointments/candidates:
|
/api/platform/v1/truck-appointments/candidates:
|
||||||
get:
|
get:
|
||||||
summary: Query authoritative create-time ocean-booking candidates
|
summary: Query authoritative create-time ocean-booking candidates
|
||||||
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have a non-terminal truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
description: Requires platform:firstleg:truck-appointment:list. Returns one row per RELEASED booking with a bill of lading and an active booking-owned confirmation or bill file, excluding bookings that already have an active or completed truck appointment. Container allocation and loading status do not affect eligibility. Edit pages display their current booking from appointment detail rather than injecting it into this create-only result.
|
||||||
parameters:
|
parameters:
|
||||||
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
|
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number or bill of lading}
|
||||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||||
@@ -63,7 +63,7 @@ paths:
|
|||||||
- {$ref: '#/components/parameters/RequestHash'}
|
- {$ref: '#/components/parameters/RequestHash'}
|
||||||
post:
|
post:
|
||||||
summary: Apply an idempotent appointment lifecycle action
|
summary: Apply an idempotent appointment lifecycle action
|
||||||
description: All actions transition the booking-level appointment as one aggregate. PICKUP does not accept or write a container number. ARRIVE, LOAD and DEPART persist their actual event timestamps atomically with the status transition.
|
description: All actions transition the booking-level appointment as one aggregate. PICKUP does not accept or write a container number. ARRIVE and LOAD persist their actual event timestamps. Manual DEPART and COMPLETE actions are not supported; confirmation of all required containers departing the domestic warehouse completes the appointment automatically.
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -144,7 +144,7 @@ components:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [SUBMIT, DISPATCH, PICKUP, ARRIVE, LOAD, DEPART, COMPLETE, EXCEPTION, CANCEL]
|
enum: [SUBMIT, DISPATCH, PICKUP, ARRIVE, LOAD, EXCEPTION, CANCEL]
|
||||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||||
RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}}
|
RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}}
|
||||||
schemas:
|
schemas:
|
||||||
@@ -227,8 +227,8 @@ components:
|
|||||||
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
||||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
|
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
|
||||||
plannedLoadingAt: {type: string, format: date-time}
|
plannedLoadingAt: {type: string, format: date-time}
|
||||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.}
|
payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: 应付拖车费,最多两位小数;全部订舱需求货柜确认国内离仓后按实装体积分摊生成应付来源;零金额不生成费用。}
|
||||||
currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.}
|
currency: {type: string, enum: [RMB], default: RMB, description: 人民币拖车费;完成时以CNY进入应付费用来源。}
|
||||||
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
|
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
|
||||||
DetailStableReferences:
|
DetailStableReferences:
|
||||||
type: object
|
type: object
|
||||||
@@ -248,5 +248,5 @@ components:
|
|||||||
carrierName: {type: string}
|
carrierName: {type: string}
|
||||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.}
|
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.}
|
||||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true}
|
payableAmount: {type: number, format: double, minimum: 0, nullable: true}
|
||||||
currency: {type: string, enum: [RMB, USD]}
|
currency: {type: string, enum: [RMB]}
|
||||||
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
|
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class TruckAppointmentControllerContractTest {
|
class TruckAppointmentControllerContractTest {
|
||||||
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
|
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
|
||||||
@Test void schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "receivable_amount");}
|
@Test void schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB'))").doesNotContain("customer_id", "customer_name", "receivable_amount", "'DEPARTED'");}
|
||||||
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
|
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
|
||||||
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
|
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||||
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||||
@@ -34,7 +34,7 @@ class TruckAppointmentControllerContractTest {
|
|||||||
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
||||||
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
|
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
|
||||||
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
||||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
|
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB]","default: RMB").doesNotContain("receivableAmount");}
|
||||||
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
|
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
|
||||||
Map<String,String> expected=Map.ofEntries(
|
Map<String,String> expected=Map.ofEntries(
|
||||||
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
||||||
|
|||||||
+9
-2
@@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.*;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class OutboundDispatchServiceTest {
|
class OutboundDispatchServiceTest {
|
||||||
|
private final com.baiyue.firstleg.service.TruckAppointmentCompletionService truckCompletion = mock(com.baiyue.firstleg.service.TruckAppointmentCompletionService.class);
|
||||||
private OutboundDispatchRepository repository;
|
private OutboundDispatchRepository repository;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -52,7 +53,7 @@ class OutboundDispatchServiceTest {
|
|||||||
when(customs.code()).thenReturn("CUSTOMS_CLEARANCE");
|
when(customs.code()).thenReturn("CUSTOMS_CLEARANCE");
|
||||||
when(customs.assess(any())).thenReturn(new GateItem(
|
when(customs.assess(any())).thenReturn(new GateItem(
|
||||||
"CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED"));
|
"CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED"));
|
||||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null);
|
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null, truckCompletion);
|
||||||
List<OutboundDispatchReadMapper.Binding> tenBindings = LongStream.rangeClosed(11L, 20L)
|
List<OutboundDispatchReadMapper.Binding> tenBindings = LongStream.rangeClosed(11L, 20L)
|
||||||
.mapToObj(id -> binding(id, "I-" + id)).toList();
|
.mapToObj(id -> binding(id, "I-" + id)).toList();
|
||||||
stubContainer(tenBindings);
|
stubContainer(tenBindings);
|
||||||
@@ -124,6 +125,7 @@ class OutboundDispatchServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() {
|
void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() {
|
||||||
|
when(truckCompletion.lockForContainer(22L)).thenReturn(66L);
|
||||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||||
OutboundDispatchService service = service(carrierSchedules);
|
OutboundDispatchService service = service(carrierSchedules);
|
||||||
stubContainer();
|
stubContainer();
|
||||||
@@ -141,6 +143,11 @@ class OutboundDispatchServiceTest {
|
|||||||
verify(repository).lockBindings(22L);
|
verify(repository).lockBindings(22L);
|
||||||
verify(repository).confirm(31L, 0, 9L, "admin", "ok");
|
verify(repository).confirm(31L, 0, 9L, "admin", "ok");
|
||||||
verify(repository).confirm(32L, 0, 9L, "admin", "ok");
|
verify(repository).confirm(32L, 0, 9L, "admin", "ok");
|
||||||
|
var departureOrder = org.mockito.Mockito.inOrder(truckCompletion, repository);
|
||||||
|
departureOrder.verify(truckCompletion).lockForContainer(22L);
|
||||||
|
departureOrder.verify(repository).confirm(31L, 0, 9L, "admin", "ok");
|
||||||
|
departureOrder.verify(repository).confirm(32L, 0, 9L, "admin", "ok");
|
||||||
|
departureOrder.verify(truckCompletion).completeForBooking(66L);
|
||||||
verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction())
|
verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction())
|
||||||
&& value.getContainerBookingId().equals(22L)));
|
&& value.getContainerBookingId().equals(22L)));
|
||||||
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||||
@@ -271,7 +278,7 @@ class OutboundDispatchServiceTest {
|
|||||||
|
|
||||||
private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) {
|
private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) {
|
||||||
return new OutboundDispatchService(repository,
|
return new OutboundDispatchService(repository,
|
||||||
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules);
|
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules, truckCompletion);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stubContainer() {
|
private void stubContainer() {
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ class TruckAppointmentMapperContractTest {
|
|||||||
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
|
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
|
||||||
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
|
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
|
||||||
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
|
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
|
||||||
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "bookingDocumentReady");
|
"t.status NOT IN ('CANCELLED','EXCEPTION')", "bookingDocumentReady");
|
||||||
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
|
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
|
||||||
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
|
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
|
||||||
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||||
@@ -29,7 +29,7 @@ class TruckAppointmentMapperContractTest {
|
|||||||
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
|
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
|
||||||
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||||
assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
|
assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
|
||||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
.contains("'CANCELLED','EXCEPTION'");
|
||||||
String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||||
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
|
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
|
||||||
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
|
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
|
||||||
|
|||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
|
||||||
|
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||||
|
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
|
||||||
|
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper;
|
||||||
|
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||||
|
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||||
|
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
|
||||||
|
class TruckAppointmentCompletionServiceTest {
|
||||||
|
static final LocalDateTime DEPARTED = LocalDateTime.of(2026, 9, 14, 18, 41);
|
||||||
|
private final TruckAppointmentCompletionMapper mapper = mock(TruckAppointmentCompletionMapper.class);
|
||||||
|
private final OceanBookingRepository bookings = mock(OceanBookingRepository.class);
|
||||||
|
private final TruckAppointmentRepository appointments = mock(TruckAppointmentRepository.class);
|
||||||
|
private final TruckAppointmentFeeService fees = mock(TruckAppointmentFeeService.class);
|
||||||
|
private final TruckAppointmentCompletionService service = new TruckAppointmentCompletionService(mapper, bookings, appointments, fees);
|
||||||
|
|
||||||
|
static DepartureRow row(long container, int quantity, String status, String volume) {
|
||||||
|
return new DepartureRow(9L, quantity, container, "CONT" + container, "ACTIVE", container * 10,
|
||||||
|
"COMPLETED", container * 100, container * 1000, container * 10000, "ORDER" + container,
|
||||||
|
container * 100000, "INBOUND" + container, new BigDecimal(volume), status,
|
||||||
|
"WAITING_FOR_LOADING".equals(status) ? DEPARTED : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void loadingCompletionAloneDoesNotCompleteTruck() {
|
||||||
|
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "READY_FOR_DISPATCH", "2")))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void allRequiredContainersAndEveryInboundMustHaveDeparted() {
|
||||||
|
var first = row(1, 2, "WAITING_FOR_LOADING", "2");
|
||||||
|
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first))).isFalse();
|
||||||
|
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "READY_FOR_DISPATCH", "4")))).isFalse();
|
||||||
|
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(first, row(2, 2, "WAITING_FOR_LOADING", "4")))).isTrue();
|
||||||
|
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), row(1, 1, null, "4")))).isFalse();
|
||||||
|
assertThat(TruckAppointmentCompletionService.allDeparted(List.of())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void missingRequirementAllocationCannotBeHiddenByAnInnerJoin() {
|
||||||
|
var missing = new DepartureRow(10L, 1, null, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||||
|
assertThat(TruckAppointmentCompletionService.allDeparted(List.of(row(1, 1, "WAITING_FOR_LOADING", "2"), missing))).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void completesDraftOrLateSubmittedTruckAndPublishesExactlyOnce() {
|
||||||
|
var appointment = new TruckAppointmentEntity();
|
||||||
|
appointment.setId(11L); appointment.setVersion(1); appointment.setRevision(1);
|
||||||
|
appointment.setStatus(TruckAppointmentStatus.PENDING_DISPATCH);
|
||||||
|
var rows = List.of(row(1, 1, "WAITING_FOR_LOADING", "2"));
|
||||||
|
when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity());
|
||||||
|
when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment), List.of());
|
||||||
|
when(mapper.departuresForUpdate(99L)).thenReturn(rows);
|
||||||
|
when(mapper.complete(11L, 1, DEPARTED)).thenReturn(1);
|
||||||
|
|
||||||
|
service.completeForBooking(99L);
|
||||||
|
service.completeForBooking(99L);
|
||||||
|
|
||||||
|
verify(fees, times(1)).publish(appointment, rows, DEPARTED);
|
||||||
|
var history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
|
||||||
|
verify(appointments, times(1)).history(history.capture());
|
||||||
|
assertThat(history.getValue().getAction()).isEqualTo("AUTO_COMPLETE");
|
||||||
|
assertThat(history.getValue().getFromStatus()).isEqualTo("PENDING_DISPATCH");
|
||||||
|
assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED");
|
||||||
|
assertThat(history.getValue().getRevision()).isEqualTo(2);
|
||||||
|
var order = inOrder(bookings, mapper, fees, appointments);
|
||||||
|
order.verify(bookings).findForUpdate(99L);
|
||||||
|
order.verify(mapper).activeForUpdate(99L);
|
||||||
|
order.verify(mapper).departuresForUpdate(99L);
|
||||||
|
order.verify(fees).publish(appointment, rows, DEPARTED);
|
||||||
|
order.verify(mapper).complete(11L, 1, DEPARTED);
|
||||||
|
order.verify(appointments).history(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void feeFailureDoesNotLeaveACompletedTruck() {
|
||||||
|
var appointment = new TruckAppointmentEntity();
|
||||||
|
when(bookings.findForUpdate(99L)).thenReturn(new OceanBookingEntity());
|
||||||
|
when(mapper.activeForUpdate(99L)).thenReturn(List.of(appointment));
|
||||||
|
when(mapper.departuresForUpdate(99L)).thenReturn(List.of(row(1, 1, "WAITING_FOR_LOADING", "2")));
|
||||||
|
doThrow(new IllegalStateException("fee unavailable")).when(fees).publish(any(), any(), any());
|
||||||
|
assertThatThrownBy(() -> service.completeForBooking(99L)).hasMessage("fee unavailable");
|
||||||
|
verify(mapper, never()).complete(anyLong(), anyInt(), any());
|
||||||
|
verifyNoInteractions(appointments);
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
|
||||||
|
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
||||||
|
import com.baiyue.firstleg.entity.FeeItemEntity;
|
||||||
|
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||||
|
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.*;
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
|
||||||
|
class TruckAppointmentFeeServiceTest {
|
||||||
|
private final FeeItemRepository items = mock(FeeItemRepository.class);
|
||||||
|
private final ContainerFeeDetailService containers = mock(ContainerFeeDetailService.class);
|
||||||
|
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||||
|
private final TruckAppointmentFeeService service = new TruckAppointmentFeeService(items, containers, suppliers);
|
||||||
|
|
||||||
|
@Test void allocatesAcrossCustomersAndContainersByVolumeWithExactCents() {
|
||||||
|
var shares = TruckAppointmentFeeService.allocate(new BigDecimal("100.00"), List.of(
|
||||||
|
row(3, 3, "WAITING_FOR_LOADING", "1"), row(1, 3, "WAITING_FOR_LOADING", "1"), row(2, 3, "WAITING_FOR_LOADING", "1")));
|
||||||
|
assertThat(shares).extracting(TruckAppointmentFeeService.Share::amount)
|
||||||
|
.containsExactly(new BigDecimal("33.34"), new BigDecimal("33.33"), new BigDecimal("33.33"));
|
||||||
|
assertThat(shares.stream().map(TruckAppointmentFeeService.Share::amount).reduce(BigDecimal.ZERO, BigDecimal::add)).isEqualByComparingTo("100.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void tinyAmountsNeverPublishZeroOrNegativeSources() {
|
||||||
|
var shares = TruckAppointmentFeeService.allocate(new BigDecimal("0.01"), List.of(
|
||||||
|
row(1, 2, "WAITING_FOR_LOADING", "1"), row(2, 2, "WAITING_FOR_LOADING", "2")));
|
||||||
|
assertThat(shares).hasSize(1);
|
||||||
|
assertThat(shares.get(0).owner().planId()).isEqualTo(20L);
|
||||||
|
assertThat(shares.get(0).amount()).isEqualByComparingTo("0.01");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void missingVolumeRejectsInsteadOfInventingEqualCosts() {
|
||||||
|
assertThatThrownBy(() -> TruckAppointmentFeeService.allocate(new BigDecimal("100.00"),
|
||||||
|
List.of(row(1, 1, "WAITING_FOR_LOADING", "0")))).hasMessageContaining("实装体积");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void publishesApprovedCnyOnAccountSourcesWithTruckAndSupplierReferences() {
|
||||||
|
var appointment = appointment();
|
||||||
|
var item = new FeeItemEntity(); item.setId(108L); item.setStatus("ACTIVE"); item.setPickupPayable(true);
|
||||||
|
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
|
||||||
|
var created = new ContainerFeeDetailEntity(); created.setAllocationGroupId(500L);
|
||||||
|
when(containers.createAllocationGroup(anyLong(), eq(0L), any())).thenReturn(List.of(created));
|
||||||
|
service.publish(appointment, List.of(row(1, 1, "WAITING_FOR_LOADING", "5")), DEPARTED);
|
||||||
|
var request = ArgumentCaptor.forClass(AllocationGroupRequest.class);
|
||||||
|
verify(containers).createAllocationGroup(eq(10L), eq(0L), request.capture());
|
||||||
|
assertThat(request.getValue().fee().direction()).isEqualTo("PAYABLE");
|
||||||
|
assertThat(request.getValue().fee().currency()).isEqualTo("CNY");
|
||||||
|
assertThat(request.getValue().fee().settlementMode()).isEqualTo("ON_ACCOUNT");
|
||||||
|
assertThat(request.getValue().fee().supplierId()).isEqualTo(8L);
|
||||||
|
assertThat(request.getValue().fee().remark()).contains("TA-260914-0111");
|
||||||
|
assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate());
|
||||||
|
assertThat(request.getValue().allocations().get(0).amount()).isEqualByComparingTo("100");
|
||||||
|
verify(suppliers).trucking(8L, null);
|
||||||
|
verify(containers).approveAllocationGroup(10L, 500L, 0L, "系统(拖车离仓完成)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void zeroFeeCreatesNoPayableAndUnsupportedCurrencyFailsClosed() {
|
||||||
|
var appointment = appointment(); appointment.setPayableAmount(BigDecimal.ZERO);
|
||||||
|
service.publish(appointment, List.of(), DEPARTED);
|
||||||
|
verifyNoInteractions(items, containers, suppliers);
|
||||||
|
appointment.setPayableAmount(BigDecimal.ONE); appointment.setCurrency("USD");
|
||||||
|
assertThatThrownBy(() -> service.publish(appointment, List.of(), DEPARTED)).hasMessageContaining("人民币");
|
||||||
|
}
|
||||||
|
|
||||||
|
private TruckAppointmentEntity appointment() {
|
||||||
|
var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111");
|
||||||
|
appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00"));
|
||||||
|
return appointment;
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package com.baiyue.firstleg.service;
|
||||||
|
|
||||||
|
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
|
||||||
|
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||||
|
import com.baiyue.firstleg.entity.*;
|
||||||
|
import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper;
|
||||||
|
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
|
||||||
|
import com.baiyue.firstleg.mapper.TruckAppointmentCompletionMapper.DepartureRow;
|
||||||
|
import com.baiyue.firstleg.repository.*;
|
||||||
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static com.baiyue.firstleg.service.TruckAppointmentCompletionServiceTest.DEPARTED;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** Real business services from trucking through approval, immutable snapshot and Finance Outbox. */
|
||||||
|
class TruckAppointmentPayableFlowTest {
|
||||||
|
@Test void truckTotalBecomesCustomerOwnedPublishedPayablesWithoutRegisteringPayment() throws Exception {
|
||||||
|
var repository = mock(ContainerFeeDetailRepository.class);
|
||||||
|
var loading = mock(SmartLoadingRepository.class);
|
||||||
|
var items = mock(FeeItemRepository.class);
|
||||||
|
var suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||||
|
var snapshotMapper = mock(ContainerFeeSourceSnapshotMapper.class);
|
||||||
|
var outboxMapper = mock(InboundOutboxMapper.class);
|
||||||
|
var mapper = JsonMapper.builder().findAndAddModules().build();
|
||||||
|
var snapshots = new ContainerFeeSourceSnapshotRepository(snapshotMapper);
|
||||||
|
var outbox = new FinanceFeeSourceOutboxService(outboxMapper, mapper);
|
||||||
|
var payments = new FeePaymentSnapshotValidator(mock(FeePaymentVoucherFileClient.class), "test");
|
||||||
|
var containerFees = new ContainerFeeDetailService(repository, loading, items, outbox, suppliers, snapshots, payments);
|
||||||
|
var trucking = new TruckAppointmentFeeService(items, containerFees, suppliers);
|
||||||
|
var snapshotQueries = new FinanceFeeSourceSnapshotService(items, snapshots);
|
||||||
|
|
||||||
|
var item = new FeeItemEntity(); item.setId(108L); item.setFeeCode("FEE_TYPE_108");
|
||||||
|
item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING");
|
||||||
|
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
|
||||||
|
when(items.findItem(108L)).thenReturn(item);
|
||||||
|
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of());
|
||||||
|
when(suppliers.trucking(8L, null)).thenReturn(supplier);
|
||||||
|
when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier);
|
||||||
|
var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L);
|
||||||
|
var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567");
|
||||||
|
when(loading.plan(10L)).thenReturn(plan); when(loading.booking(1L)).thenReturn(container);
|
||||||
|
var loaded = new ArrayList<LoadingPlanItemEntity>();
|
||||||
|
var departures = new ArrayList<DepartureRow>();
|
||||||
|
for (long customer = 1; customer <= 2; customer++) {
|
||||||
|
var load = new LoadingPlanItemEntity(); load.setAccountId(customer); load.setOrderId(customer * 100);
|
||||||
|
load.setOrderNo("ORDER-" + customer); load.setInboundId(customer * 1000); load.setInboundCode("IN-" + customer);
|
||||||
|
loaded.add(load);
|
||||||
|
departures.add(new DepartureRow(9L, 1, 1L, "TEST1234567", "ACTIVE", 10L, "COMPLETED", customer,
|
||||||
|
customer, load.getOrderId(), load.getOrderNo(), load.getInboundId(), load.getInboundCode(),
|
||||||
|
BigDecimal.valueOf(customer), "WAITING_FOR_LOADING", DEPARTED));
|
||||||
|
}
|
||||||
|
when(loading.items(10L)).thenReturn(loaded);
|
||||||
|
Map<Long, ContainerFeeDetailEntity> saved = new LinkedHashMap<>();
|
||||||
|
doAnswer(call -> { var value = (ContainerFeeDetailEntity) call.getArgument(0); saved.put(value.getId(), value); return null; })
|
||||||
|
.when(repository).insert(any());
|
||||||
|
when(repository.listGroup(eq(10L), anyLong())).thenAnswer(call -> List.copyOf(saved.values()));
|
||||||
|
when(repository.find(anyLong(), eq(10L), anyLong())).thenAnswer(call -> saved.get(call.getArgument(2)));
|
||||||
|
when(repository.updateSupplierSnapshot(anyLong(), eq(10L), anyLong(), eq(8L), eq("测试车队"), eq("TRUCKING"), eq(0L))).thenReturn(1);
|
||||||
|
when(repository.review(anyLong(), eq(10L), anyLong(), eq("APPROVED"), eq(0L), anyString(), isNull()))
|
||||||
|
.thenAnswer(call -> { var value = saved.get(call.getArgument(2)); value.setReviewStatus("APPROVED"); value.setReviewedAt(DEPARTED); return 1; });
|
||||||
|
|
||||||
|
var appointment = new TruckAppointmentEntity(); appointment.setAppointmentNo("TA-260914-0111");
|
||||||
|
appointment.setCarrierId(8L); appointment.setCurrency("RMB"); appointment.setPayableAmount(new BigDecimal("100.00"));
|
||||||
|
trucking.publish(appointment, departures, DEPARTED);
|
||||||
|
|
||||||
|
assertThat(saved.values()).hasSize(2).allSatisfy(value -> {
|
||||||
|
assertThat(value.getDirection()).isEqualTo("PAYABLE");
|
||||||
|
assertThat(value.getReviewStatus()).isEqualTo("APPROVED");
|
||||||
|
assertThat(value.getSettlementMode()).isEqualTo("ON_ACCOUNT");
|
||||||
|
assertThat(value.getPaidAt()).isNull();
|
||||||
|
assertThat(value.getRemark()).contains("TA-260914-0111");
|
||||||
|
});
|
||||||
|
assertThat(saved.values().stream().map(ContainerFeeDetailEntity::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add))
|
||||||
|
.isEqualByComparingTo("100.00");
|
||||||
|
var frozen = ArgumentCaptor.forClass(ContainerFeeSourceSnapshotEntity.class);
|
||||||
|
verify(snapshotMapper, times(2)).insert(frozen.capture());
|
||||||
|
var events = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||||
|
verify(outboxMapper, times(2)).insert(events.capture());
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
var source = frozen.getAllValues().get(i);
|
||||||
|
when(snapshotMapper.selectOne(any())).thenReturn(source);
|
||||||
|
var snapshot = snapshotQueries.container(1L, source.getSourceFeeId(), 1);
|
||||||
|
assertThat(snapshot.publicationStatus()).isEqualTo("PUBLISHED");
|
||||||
|
assertThat(snapshot.currency()).isEqualTo("CNY");
|
||||||
|
assertThat(snapshot.customerId()).isEqualTo(source.getTenantId());
|
||||||
|
assertThat(snapshot.orderId()).isEqualTo(source.getAllocationOrderId());
|
||||||
|
assertThat(snapshot.supplierId()).isEqualTo(8L);
|
||||||
|
assertThat(snapshot.sourceHash()).hasSize(64);
|
||||||
|
var event = mapper.readTree(events.getAllValues().get(i).getPayloadJson());
|
||||||
|
assertThat(event.path("eventType").asText()).isEqualTo("FIRSTLEG_FEE_PUBLISHED");
|
||||||
|
assertThat(event.path("aggregateType").asText()).isEqualTo("CONTAINER");
|
||||||
|
assertThat(event.path("payload").path("feeId").asText()).isEqualTo(source.getSourceFeeId().toString());
|
||||||
|
assertThat(event.path("tenantId").asText()).isEqualTo(source.getTenantId().toString());
|
||||||
|
assertThat(events.getAllValues().get(i).getPublishStatus()).isEqualTo("PENDING");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-11
@@ -41,6 +41,7 @@ import static org.mockito.Mockito.*;
|
|||||||
|
|
||||||
class TruckAppointmentServiceTest {
|
class TruckAppointmentServiceTest {
|
||||||
@Mock TruckAppointmentRepository repository;
|
@Mock TruckAppointmentRepository repository;
|
||||||
|
@Mock TruckAppointmentCompletionService completion;
|
||||||
@Mock OceanBookingRepository oceanBookings;
|
@Mock OceanBookingRepository oceanBookings;
|
||||||
@Mock WarehouseSupplierSnapshotService suppliers;
|
@Mock WarehouseSupplierSnapshotService suppliers;
|
||||||
@Mock TruckAppointmentFileClient files;
|
@Mock TruckAppointmentFileClient files;
|
||||||
@@ -51,6 +52,11 @@ class TruckAppointmentServiceTest {
|
|||||||
|
|
||||||
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
|
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
|
||||||
service.internalToken = "test-internal-token";
|
service.internalToken = "test-internal-token";
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
TruckAppointmentEntity inserted = invocation.getArgument(0);
|
||||||
|
lenient().when(repository.find(inserted.getId())).thenReturn(inserted);
|
||||||
|
return null;
|
||||||
|
}).when(repository).insert(any());
|
||||||
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
|
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
|
||||||
when(suppliers.trucking(any(), any())).thenReturn(
|
when(suppliers.trucking(any(), any())).thenReturn(
|
||||||
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
|
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
|
||||||
@@ -88,6 +94,63 @@ class TruckAppointmentServiceTest {
|
|||||||
verifyNoInteractions(oceanBookings);
|
verifyNoInteractions(oceanBookings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void createAfterDepartureReturnsCompletedVersion() {
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
var inserted = org.mockito.Mockito.mockingDetails(repository).getInvocations().stream()
|
||||||
|
.filter(call -> call.getMethod().getName().equals("insert"))
|
||||||
|
.map(call -> (TruckAppointmentEntity) call.getArguments()[0]).findFirst().orElseThrow();
|
||||||
|
inserted.setStatus(TruckAppointmentStatus.COMPLETED); inserted.setVersion(1);
|
||||||
|
return null;
|
||||||
|
}).when(completion).completeForBooking(99L);
|
||||||
|
var result = service.create(7L, "admin", validRequest());
|
||||||
|
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED);
|
||||||
|
assertThat(result.getVersion()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void completedBookingCannotCreateAnotherAutomaticCharge() {
|
||||||
|
var existing = entity(TruckAppointmentStatus.COMPLETED, 3);
|
||||||
|
existing.setAppointmentNo("TA-260914-0111");
|
||||||
|
when(repository.firstActiveForUpdate(99L)).thenReturn(existing);
|
||||||
|
assertThatThrownBy(() -> service.create(7L, "admin", validRequest())).hasMessageContaining("TA-260914-0111");
|
||||||
|
verify(repository, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void lateSubmitReturnsAutomaticCompletionInsteadOfPendingDispatch() {
|
||||||
|
var before = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||||
|
var submitted = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
|
||||||
|
when(repository.find(1L)).thenReturn(before, submitted);
|
||||||
|
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(true);
|
||||||
|
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
|
||||||
|
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("late-submit"), anyString())).thenReturn(1);
|
||||||
|
doAnswer(invocation -> { submitted.setStatus(TruckAppointmentStatus.COMPLETED); submitted.setVersion(2); return null; })
|
||||||
|
.when(completion).completeForBooking(99L);
|
||||||
|
var result = service.action(1L, "SUBMIT", new ActionRequest(0, null), "late-submit", null, 7L, "admin");
|
||||||
|
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.COMPLETED);
|
||||||
|
assertThat(result.getVersion()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void reschedulingAnExceptionCannotDuplicateAnAlreadyCompletedBookingCharge() {
|
||||||
|
var failed = entity(TruckAppointmentStatus.EXCEPTION, 1);
|
||||||
|
var completed = entity(TruckAppointmentStatus.COMPLETED, 2); completed.setId(2L);
|
||||||
|
completed.setAppointmentNo("TA-260914-0111");
|
||||||
|
when(repository.find(1L)).thenReturn(failed);
|
||||||
|
when(repository.firstActiveForUpdate(99L)).thenReturn(completed);
|
||||||
|
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("reschedule-old"), anyString())).thenReturn(true);
|
||||||
|
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(1, null),
|
||||||
|
"reschedule-old", null, 7L, "admin")).hasMessageContaining("已完成拖车单");
|
||||||
|
verify(repository, never()).reschedule(anyLong(), any(), any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void manualCompleteAndDepartureCannotBypassActualDeparture() {
|
||||||
|
for (String action : List.of("COMPLETE", "DEPART")) {
|
||||||
|
when(repository.find(1L)).thenReturn(entity(TruckAppointmentStatus.LOADING, 4));
|
||||||
|
when(repository.claimOperation(anyLong(), eq(1L), eq(action), eq(action), anyString())).thenReturn(true);
|
||||||
|
assertThatThrownBy(() -> service.action(1L, action, new ActionRequest(4, null), action, null, 7L, "admin"))
|
||||||
|
.isInstanceOf(BizException.class);
|
||||||
|
}
|
||||||
|
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void createRequiresZeroVersionAndInitialisesDraft() {
|
@Test void createRequiresZeroVersionAndInitialisesDraft() {
|
||||||
SaveRequest invalid = request(1);
|
SaveRequest invalid = request(1);
|
||||||
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
|
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
|
||||||
@@ -174,17 +237,12 @@ class TruckAppointmentServiceTest {
|
|||||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
|
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void createAcceptsUsd() {
|
@Test void createRejectsUsdBeforeWriting() {
|
||||||
SaveRequest request = validRequest();
|
SaveRequest request = validRequest();
|
||||||
when(request.currency()).thenReturn("USD");
|
when(request.currency()).thenReturn("USD");
|
||||||
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
|
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||||
|
.isInstanceOf(BizException.class).hasMessageContaining("人民币");
|
||||||
service.create(7L, "admin", request);
|
verify(repository, never()).insert(any());
|
||||||
|
|
||||||
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
|
||||||
verify(repository).insert(inserted.capture());
|
|
||||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("USD");
|
|
||||||
assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
|
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
|
||||||
@@ -192,7 +250,7 @@ class TruckAppointmentServiceTest {
|
|||||||
when(request.currency()).thenReturn("CNY");
|
when(request.currency()).thenReturn("CNY");
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||||
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
|
.isInstanceOf(BizException.class).hasMessageContaining("人民币");
|
||||||
|
|
||||||
verify(repository, never()).insert(any());
|
verify(repository, never()).insert(any());
|
||||||
verify(repository, never()).history(any());
|
verify(repository, never()).history(any());
|
||||||
@@ -534,7 +592,7 @@ class TruckAppointmentServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) {
|
private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) {
|
||||||
TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e;
|
TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setOceanBookingId(99L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SaveRequest request(int expectedVersion) {
|
private static SaveRequest request(int expectedVersion) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export async function run(context) {
|
|||||||
|
|
||||||
## 可用上下文
|
## 可用上下文
|
||||||
|
|
||||||
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;尚未包含海外实收和专用出库验收。
|
`dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账;finally 停用本轮操作员。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;尚未包含专用出库验收。
|
||||||
|
|
||||||
- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。
|
- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。
|
||||||
|
|
||||||
@@ -120,8 +120,8 @@ export async function run(context) {
|
|||||||
| PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` |
|
| PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` |
|
||||||
| 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` |
|
| 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` |
|
||||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||||
| 订舱级拖车预约完整生命周期 | `truck-appointments.mjs`(自包含创建并放舱,不创建或关联柜次;每订舱最多一张非终态拖车单;覆盖候选、附件、费用、幂等及 `SUBMIT → DISPATCH → PICKUP → ARRIVE → LOAD → DEPART → COMPLETE`) | `truck-appointments-contract.test.mjs` |
|
| 订舱级拖车预约完整生命周期 | `truck-appointments.mjs`(自包含创建并放舱,不创建或关联柜次;已有有效或已完成拖车单的订舱不可重复创建;覆盖候选、附件、人民币费用、幂等及 `SUBMIT → DISPATCH → PICKUP → ARRIVE → LOAD`,拒绝手工离仓/完成;自动完成与应付由真实国内离仓链路验证) | `truck-appointments-contract.test.mjs` |
|
||||||
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、完整状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
|
| 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed |
|
||||||
| Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 |
|
| Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 |
|
||||||
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
|
| 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` |
|
||||||
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
| 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` |
|
||||||
@@ -280,3 +280,6 @@ node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixt
|
|||||||
```powershell
|
```powershell
|
||||||
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs
|
node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
国内拖车自动完成与应付回归由 `phase3-outbound-dispatch.mjs` 在真实离仓后只读核验:订舱全部柜数、逐柜离仓、唯一 `AUTO_COMPLETE`、按拖车编号追溯柜次应付、金额守恒及 Finance 来源入池(有界等待 Outbox)。该模块的原有确认/撤销边界操作仍会写目标环境,执行须获得对应环境授权。`manual-state-ocean-trucking.mjs` 只构造可人工操作的状态,不伪造已完成拖车;自动终态由上述离仓链路覆盖。
|
||||||
|
|||||||
@@ -62,3 +62,14 @@ test('overseas plans preserve per-inbound ownership and shared physical identity
|
|||||||
assert.match(source, /byPallet.get\(pallet.palletNo\) === identity/)
|
assert.match(source, /byPallet.get\(pallet.palletNo\) === identity/)
|
||||||
assert.match(source, /JSON.stringify\(replay\) === JSON.stringify\(plans\)/)
|
assert.match(source, /JSON.stringify\(replay\) === JSON.stringify\(plans\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('overseas receipt uses scoped PDA identity and scans each physical pallet once', () => {
|
||||||
|
assert.match(source, /await createPicker\(context/)
|
||||||
|
assert.match(source, /await disablePicker\(context/)
|
||||||
|
assert.match(source, /const sourceNos = \[\.\.\.new Set\(group.flatMap/)
|
||||||
|
assert.match(source, /current\/receipts\/pallet/)
|
||||||
|
assert.match(source, /duplicate.result === 'ALREADY_RECEIVED'/)
|
||||||
|
assert.match(source, /Number\(inbound.receivedPackages\) === item.expectedCartonMarks.length/)
|
||||||
|
assert.match(source, /completed.status === 'COMPLETED'/)
|
||||||
|
assert.match(source, /fullFlowVerified: false/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export async function run(context) {
|
|||||||
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
|
check(`${label}真实船司和订舱代理齐全`, null, Boolean(carrier?.id && agent?.id))
|
||||||
const suffix = markerSuffix(context.marker)
|
const suffix = markerSuffix(context.marker)
|
||||||
const draft = oceanBookingDraft(context, suffix, {
|
const draft = oceanBookingDraft(context, suffix, {
|
||||||
|
customerId: String(group[0].task.tenantId), customerName: summary.customerName,
|
||||||
warehouseId: String(summary.warehouseId), channelCode: summary.channelCode,
|
warehouseId: String(summary.warehouseId), channelCode: summary.channelCode,
|
||||||
}, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name,
|
}, { carrierCode: 'MATSON', shippingCompanyId: String(carrier.id), shippingCompanyName: carrier.name,
|
||||||
bookingAgentId: String(agent.id), bookingAgentName: agent.name })
|
bookingAgentId: String(agent.id), bookingAgentName: agent.name })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const TRUCK_BASE = '/api/platform/v1/truck-appointments'
|
|||||||
const OCEAN_LIST_STATUSES = ['DRAFT', 'RELEASED']
|
const OCEAN_LIST_STATUSES = ['DRAFT', 'RELEASED']
|
||||||
const OCEAN_STATUSES = [...OCEAN_LIST_STATUSES]
|
const OCEAN_STATUSES = [...OCEAN_LIST_STATUSES]
|
||||||
const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE',
|
const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE',
|
||||||
'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
'LOADING', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
function code(response) { return String(response?.body?.code ?? 'missing') }
|
function code(response) { return String(response?.body?.code ?? 'missing') }
|
||||||
@@ -153,7 +153,7 @@ async function buildTruck(context, oceanBooking, status, index) {
|
|||||||
if (status === 'EXCEPTION') return truckAction(context, value, 'exception')
|
if (status === 'EXCEPTION') return truckAction(context, value, 'exception')
|
||||||
if (status === 'CANCELLED') return truckAction(context, value, 'cancel')
|
if (status === 'CANCELLED') return truckAction(context, value, 'cancel')
|
||||||
const chain = [['submit', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'], ['pickup', 'PICKUP_IN_PROGRESS'],
|
const chain = [['submit', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'], ['pickup', 'PICKUP_IN_PROGRESS'],
|
||||||
['arrive', 'ARRIVED_WAREHOUSE'], ['load', 'LOADING'], ['depart', 'DEPARTED'], ['complete', 'COMPLETED']]
|
['arrive', 'ARRIVED_WAREHOUSE'], ['load', 'LOADING']]
|
||||||
for (const [endpoint, target] of chain) {
|
for (const [endpoint, target] of chain) {
|
||||||
value = await truckAction(context, value, endpoint)
|
value = await truckAction(context, value, endpoint)
|
||||||
if (target === status) return value
|
if (target === status) return value
|
||||||
|
|||||||
@@ -29,6 +29,70 @@ async function request(context, method, path, body, headers = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyTruckDeparturePayables(context, aggregate) {
|
||||||
|
const appointmentId = id(aggregate?.truckAppointmentId)
|
||||||
|
if (!appointmentId) return
|
||||||
|
const truckResponse = await request(context, 'GET', `/api/platform/v1/truck-appointments/${appointmentId}`)
|
||||||
|
const truck = context.apiSuccess(MODULE, '回查国内离仓关联拖车', truckResponse, '', OPTIONS)
|
||||||
|
if (!truck) return
|
||||||
|
const bookingResponse = await request(context, 'GET', `/api/platform/v1/ocean-bookings/${id(truck.oceanBookingId)}`)
|
||||||
|
const booking = context.apiSuccess(MODULE, '核对拖车订舱全部要求货柜', bookingResponse, '', OPTIONS)
|
||||||
|
if (!booking) return
|
||||||
|
const expectedCount = (booking.containerRequirements || []).reduce((sum, item) => sum + Number(item.quantity), 0)
|
||||||
|
const departed = []
|
||||||
|
for (const container of booking.containers || []) {
|
||||||
|
const response = await request(context, 'GET', `${BASE}/${id(container.containerBookingId)}`)
|
||||||
|
const detail = context.apiSuccess(MODULE, '回查拖车关联货柜离仓事实', response, '', OPTIONS)
|
||||||
|
if (detail?.aggregate) departed.push(detail.aggregate)
|
||||||
|
}
|
||||||
|
const ready = expectedCount > 0 && departed.length === expectedCount
|
||||||
|
&& departed.every(item => item.dispatchStatus === 'WAITING_FOR_LOADING')
|
||||||
|
context.assert(MODULE, '全部要求货柜离仓才自动完成拖车', truckResponse,
|
||||||
|
ready ? truck.status === 'COMPLETED' : truck.status !== 'COMPLETED',
|
||||||
|
`ready=${ready}, status=${truck.status}`, OPTIONS)
|
||||||
|
if (!ready || truck.status !== 'COMPLETED') return
|
||||||
|
const historyResponse = await request(context, 'GET', `/api/platform/v1/truck-appointments/${appointmentId}/history`)
|
||||||
|
const history = context.apiSuccess(MODULE, '回查拖车自动完成历史', historyResponse, '', OPTIONS)
|
||||||
|
context.assert(MODULE, '拖车自动完成仅记录一次', historyResponse,
|
||||||
|
Array.isArray(history) && history.filter(item => item.action === 'AUTO_COMPLETE').length === 1, '', OPTIONS)
|
||||||
|
const fees = new Map()
|
||||||
|
for (const container of departed) {
|
||||||
|
const customers = [...new Set(container.inbounds.map(item => id(item.customerId)))]
|
||||||
|
for (const customer of customers) {
|
||||||
|
const response = await request(context, 'GET', `/api/platform/v1/loading-plans/${id(container.loadingPlanId)}/fee-details?accountId=${customer}`)
|
||||||
|
const details = context.apiSuccess(MODULE, '回查拖车生成的柜次应付', response, '', OPTIONS)
|
||||||
|
for (const fee of Array.isArray(details) ? details : []) {
|
||||||
|
if (fee.direction === 'PAYABLE' && String(fee.remark || '').includes(`国内拖车单 ${truck.appointmentNo};`)) fees.set(id(fee.id), fee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalCents = [...fees.values()].reduce((sum, fee) => sum + Math.round(Number(fee.amount) * 100), 0)
|
||||||
|
context.assert(MODULE, '拖车应付分摊守恒且自动审核', null,
|
||||||
|
totalCents === Math.round(Number(truck.payableAmount || 0) * 100)
|
||||||
|
&& [...fees.values()].every(fee => fee.currency === 'CNY' && fee.reviewStatus === 'APPROVED'
|
||||||
|
&& fee.settlementMode === 'ON_ACCOUNT' && id(fee.supplierId) === id(truck.carrierId)),
|
||||||
|
`sourceCount=${fees.size}, totalCents=${totalCents}`, OPTIONS)
|
||||||
|
const imported = new Map()
|
||||||
|
for (let attempt = 0; attempt < 12 && imported.size < fees.size; attempt++) {
|
||||||
|
for (const customer of new Set([...fees.values()].map(fee => id(fee.tenantId)))) {
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const response = await request(context, 'GET', `/api/platform/finance/v1/fee-sources?customerId=${customer}&direction=PAYABLE&page=${page}&pageSize=200`)
|
||||||
|
const rows = context.apiSuccess(MODULE, '回查拖车应付财务入池', response, '', OPTIONS)
|
||||||
|
if (!Array.isArray(rows)) break
|
||||||
|
rows.filter(row => row.sourceSystem === 'FIRSTLEG' && row.sourceType === 'CONTAINER_FEE'
|
||||||
|
&& fees.has(id(row.sourceFeeId))).forEach(row => imported.set(id(row.sourceFeeId), row))
|
||||||
|
if (page * 200 >= Number(response.body?.page?.total || 0)) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imported.size < fees.size) await new Promise(resolve => setTimeout(resolve, 2000))
|
||||||
|
}
|
||||||
|
context.assert(MODULE, '拖车费用真实流转到财务应付来源', null,
|
||||||
|
imported.size === fees.size && [...imported.values()].every(row => row.currency === 'CNY'
|
||||||
|
&& row.direction === 'PAYABLE' && row.reviewStatus === 'APPROVED'
|
||||||
|
&& Math.round(Number(row.amount) * 100) === Math.round(Number(fees.get(id(row.sourceFeeId)).amount) * 100)),
|
||||||
|
`expected=${fees.size}, imported=${imported.size}`, OPTIONS)
|
||||||
|
}
|
||||||
|
|
||||||
export async function run(context) {
|
export async function run(context) {
|
||||||
const fixture = context.createdResources?.phase3Loading
|
const fixture = context.createdResources?.phase3Loading
|
||||||
|| context.createdResources?.phase3ContainerLoading
|
|| context.createdResources?.phase3ContainerLoading
|
||||||
@@ -80,6 +144,7 @@ export async function run(context) {
|
|||||||
`truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS)
|
`truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS)
|
||||||
|
|
||||||
if (aggregate?.dispatchStatus === 'WAITING_FOR_LOADING') {
|
if (aggregate?.dispatchStatus === 'WAITING_FOR_LOADING') {
|
||||||
|
await verifyTruckDeparturePayables(context, aggregate)
|
||||||
context.assert(MODULE, '整柜报关完成后系统已自动确认国内离仓', detailResponse,
|
context.assert(MODULE, '整柜报关完成后系统已自动确认国内离仓', detailResponse,
|
||||||
aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||||
&& detail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
|
&& detail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
|
||||||
@@ -178,6 +243,7 @@ export async function run(context) {
|
|||||||
&& item.action === 'REVERSE_DISPATCH'),
|
&& item.action === 'REVERSE_DISPATCH'),
|
||||||
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbounds=${finalDetail?.aggregate?.inbounds?.length ?? 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbounds=${finalDetail?.aggregate?.inbounds?.length ?? 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||||
if (confirmed?.aggregate) {
|
if (confirmed?.aggregate) {
|
||||||
|
await verifyTruckDeparturePayables(context, confirmed.aggregate)
|
||||||
context.createdResources.phase3OutboundDispatch = {
|
context.createdResources.phase3OutboundDispatch = {
|
||||||
marker: context.marker, inboundId, containerBookingId,
|
marker: context.marker, inboundId, containerBookingId,
|
||||||
orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId),
|
orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId),
|
||||||
|
|||||||
@@ -114,8 +114,6 @@ export async function run(context) {
|
|||||||
['跨链路开始提货', 'pickup', 'PICKUP_IN_PROGRESS'],
|
['跨链路开始提货', 'pickup', 'PICKUP_IN_PROGRESS'],
|
||||||
['跨链路拖车到仓', 'arrive', 'ARRIVED_WAREHOUSE'],
|
['跨链路拖车到仓', 'arrive', 'ARRIVED_WAREHOUSE'],
|
||||||
['跨链路开始装货', 'load', 'LOADING'],
|
['跨链路开始装货', 'load', 'LOADING'],
|
||||||
['跨链路拖车离仓', 'depart', 'DEPARTED'],
|
|
||||||
['完成跨链路拖车', 'complete', 'COMPLETED'],
|
|
||||||
]) {
|
]) {
|
||||||
const action = await actionRequest(context, path, endpoint, version)
|
const action = await actionRequest(context, path, endpoint, version)
|
||||||
const changed = context.apiSuccess(MODULE, name, action, '', OPTIONS)
|
const changed = context.apiSuccess(MODULE, name, action, '', OPTIONS)
|
||||||
@@ -125,9 +123,9 @@ export async function run(context) {
|
|||||||
version = Number(changed.version)
|
version = Number(changed.version)
|
||||||
}
|
}
|
||||||
const detail = await context.request('GET', `${TRUCK}/${appointmentId}`)
|
const detail = await context.request('GET', `${TRUCK}/${appointmentId}`)
|
||||||
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
|
const final = context.apiSuccess(MODULE, '回查跨服务链路离仓前状态', detail, '', OPTIONS)
|
||||||
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
|
context.assert(MODULE, '跨服务链路离仓前状态持久化且关联订舱未漂移', detail,
|
||||||
final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id)
|
final?.status === 'LOADING' && id(final?.oceanBookingId) === id(ocean.id)
|
||||||
&& !Object.hasOwn(final || {}, 'containerBookingId')
|
&& !Object.hasOwn(final || {}, 'containerBookingId')
|
||||||
&& !Object.hasOwn(final || {}, 'loadingPlanId'),
|
&& !Object.hasOwn(final || {}, 'loadingPlanId'),
|
||||||
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
|
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
|
||||||
@@ -135,19 +133,19 @@ export async function run(context) {
|
|||||||
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
|
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
|
||||||
const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)
|
const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)
|
||||||
const actions = Array.isArray(history) ? history.filter((item) => item?.action !== 'CREATE') : []
|
const actions = Array.isArray(history) ? history.filter((item) => item?.action !== 'CREATE') : []
|
||||||
const expectedActions = ['SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD', 'DEPART', 'COMPLETE']
|
const expectedActions = ['SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD']
|
||||||
context.assert(MODULE, '跨服务链路历史包含完整状态链、操作者、时间和备注', historyResponse,
|
context.assert(MODULE, '跨服务链路历史包含完整状态链、操作者、时间和备注', historyResponse,
|
||||||
expectedActions.every((action) => actions.some((item) => item.action === action
|
expectedActions.every((action) => actions.some((item) => item.action === action
|
||||||
&& id(item.appointmentId) === appointmentId && Boolean(item.operatorId)
|
&& id(item.appointmentId) === appointmentId && Boolean(item.operatorId)
|
||||||
&& Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)),
|
&& Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)),
|
||||||
`history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`,
|
`history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`,
|
||||||
OPTIONS)
|
OPTIONS)
|
||||||
if (final?.status === 'COMPLETED') {
|
if (final?.status === 'LOADING') {
|
||||||
context.createdResources.serviceCenterFlow = {
|
context.createdResources.serviceCenterFlow = {
|
||||||
appointmentId, oceanBookingId: ocean.id,
|
appointmentId, oceanBookingId: ocean.id,
|
||||||
containerBookingId: null,
|
containerBookingId: null,
|
||||||
loadingPlanId: null,
|
loadingPlanId: null,
|
||||||
marker: context.marker, finalStatus: 'COMPLETED',
|
marker: context.marker, finalStatus: 'LOADING',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ test('HTTP lifecycle covers uniqueness idempotency attachments and every action'
|
|||||||
'重复创建未改变版本和状态', '上传并关联DISPATCH_ORDER拖车附件', '查询拖车附件列表',
|
'重复创建未改变版本和状态', '上传并关联DISPATCH_ORDER拖车附件', '查询拖车附件列表',
|
||||||
'删除首次上传的拖车附件引用', '重新引用平台归属拖车附件',
|
'删除首次上传的拖车附件引用', '重新引用平台归属拖车附件',
|
||||||
'删除重新引用的拖车附件', '恢复一份DISPATCH_ORDER拖车附件供后续状态流转',
|
'删除重新引用的拖车附件', '恢复一份DISPATCH_ORDER拖车附件供后续状态流转',
|
||||||
'编辑订舱级拖车为USD应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放',
|
'编辑订舱级拖车为RMB应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放',
|
||||||
'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409',
|
'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409',
|
||||||
'COMPLETED后订舱恢复候选', 'COMPLETED后同订舱可新建下一张拖车单',
|
'禁止手工离仓或完成拖车',
|
||||||
'CANCELLED后订舱恢复候选', 'CANCELLED后同订舱可再次新建',
|
'CANCELLED后订舱恢复候选', 'CANCELLED后同订舱可再次新建',
|
||||||
'EXCEPTION后订舱恢复候选', 'EXCEPTION后同订舱可再次新建']) {
|
'EXCEPTION后订舱恢复候选', 'EXCEPTION后同订舱可再次新建']) {
|
||||||
assert.match(source, new RegExp(label), label)
|
assert.match(source, new RegExp(label), label)
|
||||||
}
|
}
|
||||||
assert.match(source, /\['DISPATCH', '派车'\], \['PICKUP', '提柜'\], \['ARRIVE', '到仓'\]/)
|
assert.match(source, /\['DISPATCH', '派车'\], \['PICKUP', '提柜'\], \['ARRIVE', '到仓'\]/)
|
||||||
assert.match(source, /\['LOAD', '装货'\], \['DEPART', '离仓'\], \['COMPLETE', '完成'\]/)
|
assert.match(source, /\['LOAD', '装货'\]/)
|
||||||
assert.match(source, /context\.apiSuccess\(MODULE, `整单推进\$\{name\}`/)
|
assert.match(source, /context\.apiSuccess\(MODULE, `整单推进\$\{name\}`/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -270,10 +270,10 @@ export async function run(context) {
|
|||||||
`attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS)
|
`attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const editedResponse = await request(context, 'PUT', path,
|
const editedResponse = await request(context, 'PUT', path,
|
||||||
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 }))
|
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'RMB', payableAmount: 12.34 }))
|
||||||
appointment = context.apiSuccess(MODULE, '编辑订舱级拖车为USD应付费用', editedResponse, '', OPTIONS)
|
appointment = context.apiSuccess(MODULE, '编辑订舱级拖车为RMB应付费用', editedResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '编辑响应费用正确且无柜次协议', editedResponse,
|
context.assert(MODULE, '编辑响应费用正确且无柜次协议', editedResponse,
|
||||||
appointment?.currency === 'USD' && Number(appointment?.payableAmount) === 12.34 && !hasForbiddenTruckFields(appointment), '', OPTIONS)
|
appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 12.34 && !hasForbiddenTruckFields(appointment), '', OPTIONS)
|
||||||
|
|
||||||
const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
|
const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
|
||||||
const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' }
|
const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' }
|
||||||
@@ -289,27 +289,24 @@ export async function run(context) {
|
|||||||
codeOf(conflictResponse) === 'A0409', `code=${codeOf(conflictResponse)}`, OPTIONS)
|
codeOf(conflictResponse) === 'A0409', `code=${codeOf(conflictResponse)}`, OPTIONS)
|
||||||
|
|
||||||
for (const [name, reason] of [['DISPATCH', '派车'], ['PICKUP', '提柜'], ['ARRIVE', '到仓'],
|
for (const [name, reason] of [['DISPATCH', '派车'], ['PICKUP', '提柜'], ['ARRIVE', '到仓'],
|
||||||
['LOAD', '装货'], ['DEPART', '离仓'], ['COMPLETE', '完成']]) {
|
['LOAD', '装货']]) {
|
||||||
appointment = await action(context, path, appointment, name, reason)
|
appointment = await action(context, path, appointment, name, reason)
|
||||||
context.assert(MODULE, `${name}响应无柜次协议`, null, Boolean(appointment) && !hasForbiddenTruckFields(appointment),
|
context.assert(MODULE, `${name}响应无柜次协议`, null, Boolean(appointment) && !hasForbiddenTruckFields(appointment),
|
||||||
`status=${appointment?.status || 'missing'}`, OPTIONS)
|
`status=${appointment?.status || 'missing'}`, OPTIONS)
|
||||||
}
|
}
|
||||||
const finalDetailResponse = await request(context, 'GET', path)
|
const finalDetailResponse = await request(context, 'GET', path)
|
||||||
const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS)
|
const finalDetail = context.apiSuccess(MODULE, '回查待离仓订舱级拖车详情', finalDetailResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse,
|
context.assert(MODULE, '未确认国内离仓时保持装柜中且详情无柜次协议', finalDetailResponse,
|
||||||
finalDetail?.status === 'COMPLETED' && truckAppointmentFieldGaps(finalDetail).length === 0
|
finalDetail?.status === 'LOADING' && truckAppointmentFieldGaps(finalDetail).length === 0
|
||||||
&& !hasForbiddenTruckFields(finalDetail)
|
&& !hasForbiddenTruckFields(finalDetail)
|
||||||
&& id(finalDetail?.oceanBookingId) === identity.oceanBookingId,
|
&& id(finalDetail?.oceanBookingId) === identity.oceanBookingId,
|
||||||
`appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS)
|
`appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS)
|
||||||
const afterCompleteCandidate = await candidate(context, identity)
|
for (const obsoleteAction of ['depart', 'complete']) {
|
||||||
context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item),
|
const rejected = await request(context, 'POST', `${path}/${obsoleteAction}`,
|
||||||
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
|
{ expectedVersion: Number(finalDetail.version) }, { 'Idempotency-Key': `${context.marker}-${obsoleteAction}`.slice(0, 128) })
|
||||||
const rebuiltResponse = await request(context, 'POST', TRUCKS, base)
|
context.assert(MODULE, '禁止手工离仓或完成拖车', rejected, codeOf(rejected) === 'A0409', '', OPTIONS)
|
||||||
let rebuilt = context.apiSuccess(MODULE, 'COMPLETED后同订舱可新建下一张拖车单', rebuiltResponse, '', OPTIONS)
|
}
|
||||||
context.assert(MODULE, '重建拖车单保持订舱级响应', rebuiltResponse,
|
let rebuilt = finalDetail
|
||||||
rebuilt?.status === 'DRAFT' && !hasForbiddenTruckFields(rebuilt)
|
|
||||||
&& id(rebuilt?.oceanBookingId) === identity.oceanBookingId,
|
|
||||||
`appointmentNo=${rebuilt?.appointmentNo || 'missing'}`, OPTIONS)
|
|
||||||
const cancelled = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'CANCEL', '取消测试单')
|
const cancelled = await action(context, `${TRUCKS}/${id(rebuilt?.id)}`, rebuilt, 'CANCEL', '取消测试单')
|
||||||
const afterCancelCandidate = await candidate(context, identity)
|
const afterCancelCandidate = await candidate(context, identity)
|
||||||
context.assert(MODULE, 'CANCELLED后订舱恢复候选', afterCancelCandidate.response,
|
context.assert(MODULE, 'CANCELLED后订舱恢复候选', afterCancelCandidate.response,
|
||||||
|
|||||||
@@ -466,8 +466,8 @@ Assert-NotContains $truckOpenApi 'customerId:' '拖车 OpenAPI 客户字段'
|
|||||||
Assert-NotContains $truckOpenApi 'containerBookingId:' '拖车 OpenAPI 柜次字段'
|
Assert-NotContains $truckOpenApi 'containerBookingId:' '拖车 OpenAPI 柜次字段'
|
||||||
Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划字段'
|
Assert-NotContains $truckOpenApi 'loadingPlanId:' '拖车 OpenAPI 装柜计划字段'
|
||||||
Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性'
|
Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal appointment' '拖车订舱级唯一性'
|
||||||
Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦'
|
Assert-Contains $truckOpenApi 'PDA loading completion alone does not complete the appointment' '拖车与 PDA 生命周期解耦'
|
||||||
Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤'
|
Assert-Contains $truckOpenApi 'excluding bookings that already have an active or completed truck appointment' '拖车创建候选过滤'
|
||||||
Assert-Contains $firstlegAgents '目的港码头拖车与还空预警' 'Firstleg 目的港码头拖车入口'
|
Assert-Contains $firstlegAgents '目的港码头拖车与还空预警' 'Firstleg 目的港码头拖车入口'
|
||||||
Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg 还空预警口径'
|
Assert-Contains $firstlegAgents '按卸柜完成时间加 72 小时' 'Firstleg 还空预警口径'
|
||||||
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI'
|
Assert-Contains $terminalDrayageOpenApi '/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:' '码头拖车车队命令 OpenAPI'
|
||||||
|
|||||||
Reference in New Issue
Block a user