Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
2026-09-01 15:14:24 +08:00
138 changed files with 4762 additions and 1056 deletions
+4 -3
View File
@@ -37,11 +37,11 @@
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 三期海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` |
| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `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` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
| 拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;按真实 `containerBookingId` 查询聚合和 `gate`、确认离仓,契约 `src/main/resources/openapi/phase3-outbound-dispatch-v1.yaml` |
| 订舱拥有的拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`,出仓契约 `phase3-outbound-dispatch-v1.yaml`。预约无客户字段,必须绑定 `RELEASED` 订舱及其已分配真实 ISO 柜,同柜最多一条有效预约;提单号和仓库取订舱/柜次权威事实,附件固定平台作用域 |
| 整柜出口报关 | `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 生成文件 |
| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
@@ -64,9 +64,10 @@
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。
- 拖车预约是平台级订舱履约资源,不是客户资源:请求、实体、查询和附件不得恢复 `customerId/customerName`。创建或编辑必须选择 `RELEASED` 订舱及其已分配、有效且带真实 ISO 6346 柜号的柜次,订舱还须有有效提单号和至少一份 ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING`;同一真实柜通过查询锁与唯一约束最多保留一条非取消预约。拖车附件使用 `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,外部失败不得推进业务节点,不得在三期另建查询任务。
- 国内出口报关在装柜完成后启用,不再阻塞 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才通过离仓报关门禁;完成后以系统身份、确定性幂等键立即尝试确认国内离仓,其他门禁未齐时由受控定时补偿重试,禁止部分入仓成功或重复创建离仓/船司查询事实。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。
- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
@@ -85,9 +85,9 @@ public final class ContainerDtos {
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求并补录真实柜号")
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将现有模拟柜原地关联到已发布订舱箱型需求;真实柜号可在提柜后补录")
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long requirementId, @NotBlank @Size(max = 64) String containerNo,
@NotNull @Positive Long requirementId, @Size(max = 64) String containerNo,
@NotNull @PositiveOrZero Integer expectedVersion) {}
}
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@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,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@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 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){ }
}
@@ -45,7 +45,6 @@ public class TruckAppointmentEntity {
private LocalDateTime actualDepartedAt;
private Integer transitMinutes;
private BigDecimal payableAmount;
private BigDecimal receivableAmount;
private String currency;
private TruckAppointmentStatus status;
private Integer revision;
@@ -13,6 +13,20 @@ import org.apache.ibatis.annotations.Update;
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=#{containerNo},updated_by=#{operatorId},
updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE'
AND container_no IS NULL AND ocean_booking_id IS NOT NULL AND ocean_booking_requirement_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p WHERE p.container_booking_id=fl_container_booking.id
AND p.plan_status NOT IN ('EDITING'))
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task t WHERE t.container_booking_id=fl_container_booking.id)
AND NOT EXISTS (SELECT 1 FROM fl_loading_cabinet_evidence e WHERE e.container_booking_id=fl_container_booking.id)
AND NOT EXISTS (SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=fl_container_booking.id)
""")
int assignRealContainerNo(@Param("id") long id, @Param("containerNo") String containerNo,
@Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("""
<script>
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
@@ -1,11 +1,12 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List<TruckAppointmentEntity> activeForUpdate(@Param("containerBookingId") long containerBookingId);
@Select("<script>SELECT a.*,o.booking_no ocean_booking_no,COALESCE(o.warehouse_name,c.warehouse_name) warehouse_name,o.shipping_company_id,o.shipping_company_name,o.booking_agent_id,o.booking_agent_name,c.cabinet_no,c.container_no,(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 LEFT JOIN fl_container_booking c ON c.id=a.container_booking_id AND c.record_status='ACTIVE' WHERE (#{q.keyword} IS NULL OR a.appointment_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.containerBookingId} IS NULL OR a.container_booking_id=#{q.containerBookingId}) 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='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='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@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},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},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},receivable_amount=#{e.receivableAmount},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},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},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);
}
@@ -38,6 +38,10 @@ public class ContainerRepository {
int version, long operatorId, String operatorName) {
return mapper.linkReleasedBooking(id, oceanBookingId, requirementId, containerNo, booking.getBillOfLadingNo(), booking.getVesselName(), booking.getVoyageNo(), booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(), booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(), booking.getChannelCode(), booking.getChannelName(), booking.getCustomerId(), booking.getCustomerName(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), version, operatorId, operatorName);
}
public int assignRealContainerNo(long id, String containerNo, int version,
long operatorId, String operatorName) {
return mapper.assignRealContainerNo(id, containerNo, version, operatorId, operatorName);
}
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName);
}
@@ -4,5 +4,5 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);}
public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List<TruckAppointmentHistoryEntity> histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentHistoryEntity>().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List<TruckAppointmentAttachmentEntity> attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TruckAppointmentAttachmentEntity>().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));}
public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List<TruckAppointmentEntity> activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);}
}
@@ -76,8 +76,9 @@ public class ContainerService {
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
String containerNo = Iso6346ContainerNumber.require(request.containerNo());
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
String requestedContainerNo = trim(request.containerNo());
String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo);
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
@@ -95,6 +96,32 @@ public class ContainerService {
return required(id);
}
@Transactional
public ContainerBookingEntity assignRealContainerNo(long id, String requestedContainerNo, int expectedVersion,
long operatorId, String operatorName) {
ContainerBookingEntity container = required(id);
requireVersion(container, expectedVersion);
if (container.getOceanBookingId() == null || container.getOceanBookingRequirementId() == null) {
throw conflict("模拟柜尚未关联已发布订舱需求");
}
if (trim(container.getContainerNo()) != null) throw conflict("真实集装箱号已补录");
SmartLoadingStatus value = status(container);
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
|| repository.hasPickTask(id) || repository.hasEvidence(id) || repository.hasDocuments(id)) {
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
}
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
try {
if (repository.assignRealContainerNo(id, containerNo, expectedVersion, operatorId, operatorName) != 1) {
throw conflict("真实柜号已被补录或货柜版本发生变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
}
return required(id);
}
private void requireFirstLinkMutable(ContainerBookingEntity container) {
SmartLoadingStatus value = status(container);
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
@@ -123,11 +123,13 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "测量已提交或审核完成,不能重复提交或修改冻结结果");
}
InboundMeasurementEntity measurement = requireMeasurement(tenantId, inboundId, request.measurementId());
requireVersion(request.version(), measurement.getVersion());
boolean alreadyAdvanced = !Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus());
if (!alreadyAdvanced && !Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
if (!Set.of(MeasurementBatchService.STATUS_DRAFT, MeasurementBatchService.STATUS_RECEIVED)
.contains(measurement.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前测量批次不能重复提交审核");
}
@@ -135,19 +137,15 @@ public class InboundMeasurementService {
List<InboundWeightResultEntity> weights = repository.listWeights(tenantId, inboundId);
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
if (!alreadyAdvanced) {
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
requireConfirmedPackageTotal(inbound, items);
measurement.setStatus(STATUS_SUBMITTED);
if (!repository.updateMeasurement(measurement)) {
throw concurrentModification();
}
if (!alreadyAdvanced) {
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setMeasureCompletedAt(LocalDateTime.now());
}
inbound.setProcessStatus("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
inbound.setMeasureCompletedAt(LocalDateTime.now());
if (measurement.getTotalPackageCount() != null) {
inbound.setTotalPackageCount(measurement.getTotalPackageCount());
}
@@ -54,6 +54,7 @@ import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.slf4j.Logger;
@@ -86,6 +87,8 @@ public class LoadingPickService {
@Resource
private LoadingCompletionCommandRepository completionCommands;
@Resource
private TransactionTemplate completionTransaction;
@Resource
private SmartLoadingRepository smartLoadingRepository;
@Resource
private OceanBookingRepository oceanBookingRepository;
@@ -565,6 +568,14 @@ public class LoadingPickService {
"INBOUND_COMPLETED");
}
}
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
}
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
long taskId = task.getId();
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数"));
}
@@ -594,7 +605,6 @@ public class LoadingPickService {
if (!completionCommands.complete(command.getId(), worker)) {
throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化"));
}
completeTruckAfterCommit(task.getContainerBookingId(), taskId, command.getOperatorId(), command.getOperatorName());
}
private void requireColoadConservation(LoadingPickTaskEntity task,
@@ -74,8 +74,14 @@ public class MeasurementReviewService {
}
if (approved) {
var items = repository.listMeasurementItems(tenantId, measurement.getId());
if (items.isEmpty() || items.stream().anyMatch(item -> item.getPackageCount() == null
|| item.getPackageCount() <= 0 || item.getTotalVolumeCbm() == null
|| item.getTotalVolumeCbm().signum() <= 0 || item.getVolumeWeightKg() == null
|| item.getVolumeWeightKg().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"测量明细箱数、体积和体积重必须完整且大于零,才能审核通过");
}
long confirmed = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getPackageCount)
.filter(java.util.Objects::nonNull)
.mapToLong(Integer::longValue)
.sum();
if (order.getTotalPackageCount() == null || confirmed != order.getTotalPackageCount()) {
@@ -99,13 +105,23 @@ public class MeasurementReviewService {
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getNetWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal volumeWeight = items.stream()
.map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getVolumeWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (order.getActualGrossWeightKg() == null || order.getActualNetWeightKg() == null
|| order.getActualGrossWeightKg().compareTo(grossWeight) != 0
|| order.getActualNetWeightKg().compareTo(netWeight) != 0
|| volume.signum() <= 0) {
|| order.getActualVolumeCbm() == null || order.getActualVolumeCbm().compareTo(volume) != 0
|| order.getVolumeWeightKg() == null || order.getVolumeWeightKg().compareTo(volumeWeight) != 0
|| measurement.getTotalPackageCount() == null
|| measurement.getTotalPackageCount() != confirmed
|| measurement.getTotalVolumeCbm() == null
|| measurement.getTotalVolumeCbm().compareTo(volume) != 0
|| measurement.getVolumeWeightKg() == null
|| measurement.getVolumeWeightKg().compareTo(volumeWeight) != 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"审核通过前称重毛重、货物净重汇总和体积必须完整且一致");
"审核通过前箱数、称重、体积和体积重必须在明细、测量批次与入库汇总间完整且一致");
}
}
@@ -36,6 +36,7 @@ import org.springframework.web.multipart.MultipartFile;
@Service
public class TruckAppointmentService {
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");
@Resource TruckAppointmentRepository repo;
@Resource OceanBookingRepository oceanBookings;
@Resource OceanBookingDocumentService oceanBookingDocuments;
@@ -51,7 +52,7 @@ 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 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 TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);AssociationContext links=validateAssociations(r);if(!repo.activeForUpdate(r.containerBookingId()).isEmpty())throw conflict("当前真实柜已有有效拖车预约");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_container"))throw conflict("当前真实柜已有有效拖车预约");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);TruckAppointmentEntity active=repo.firstActiveForUpdate(r.containerBookingId());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_container"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.containerBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);return enrich(e);}
@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("当前状态不可编辑");AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);return 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=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@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=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@@ -79,7 +80,7 @@ public class TruckAppointmentService {
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 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());}}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}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(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());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.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":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(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());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 TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}}
private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}}
private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}}
@@ -102,6 +103,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 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 BizException existingAppointmentConflict(TruckAppointmentEntity existing){String appointmentNo=existing==null?null:trim(existing.getAppointmentNo());return conflict(StringUtils.hasText(appointmentNo)?"该柜已存在拖车预约 "+appointmentNo+",请继续编辑":"该柜已存在拖车预约,请刷新后继续编辑");}
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,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);}
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;仅订舱关联首次写入、预装纠错命令可修改',
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -33,8 +33,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用',
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定',
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB',
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_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键',
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
@@ -44,7 +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 '拖车预约最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_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','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
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 '触发状态或改约的业务动作',
@@ -294,8 +294,8 @@ paths:
'403': {description: 无新增按钮权限}
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜关联到已发布订舱需求并补录真实柜号
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId、未满配 requirementId 和港口提柜后取得的唯一 containerNo,服务端规范化并校验ISO 6346结构、设备类别和校验位。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;不会创建或消费另一条真实柜记录。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
summary: 将内部模拟柜关联到已发布订舱需求
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别、校验位和全局唯一性。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
@@ -1328,10 +1328,10 @@ components:
type: array
maxItems: 100
items: {$ref: '#/components/schemas/WeightPalletSnapshot'}
page: {type: integer, format: int64, minimum: 1}
size: {type: integer, format: int64, minimum: 1, maximum: 100}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
page: {oneOf: [{type: integer, format: int64, minimum: 1}, {type: string, pattern: '^[1-9][0-9]*$'}]}
size: {oneOf: [{type: integer, format: int64, minimum: 1, maximum: 100}, {type: string, pattern: '^(?:[1-9]|[1-9][0-9]|100)$'}]}
total: {oneOf: [{type: integer, format: int64, minimum: 0}, {type: string, pattern: '^(?:0|[1-9][0-9]*)$'}]}
pages: {oneOf: [{type: integer, format: int64, minimum: 0}, {type: string, pattern: '^(?:0|[1-9][0-9]*)$'}]}
CreateWeightResultRequest:
type: object
required: [fileId, palletId]
@@ -1957,11 +1957,11 @@ components:
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重}
ContainerOceanBookingLinkRequest:
type: object
required: [oceanBookingId, requirementId, containerNo, expectedVersion]
required: [oceanBookingId, requirementId, expectedVersion]
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, maxLength: 64, description: 港口提柜后取得的全局唯一真实集装箱号;服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和全局唯一性}
expectedVersion: {type: integer, minimum: 0}
ContainerFeeAdjustRequest:
type: object
@@ -21,6 +21,7 @@ paths:
records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}}
post:
summary: Create a truck appointment for a released booking and its allocated real container
description: If the real container already has a non-terminal appointment, the server returns A0409 with the existing appointment number and instructs the operator to continue editing. This duplicate guard runs before booking-document validation; the database unique key provides the same response for concurrent creates.
requestBody:
required: true
content:
@@ -28,6 +29,7 @@ paths:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
'409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'}
/api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
@@ -187,6 +189,8 @@ components:
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.}
plannedLoadingAt: {type: string, format: date-time}
payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.}
currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.}
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
DetailStableReferences:
type: object
@@ -209,4 +213,6 @@ components:
carrierId: {type: integer, format: int64}
carrierName: {type: string}
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}
currency: {type: string, enum: [RMB, USD]}
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
@@ -62,8 +62,9 @@ class ContainerControllerContractTest {
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("RELEASED oceanBookingId")
.contains("未满配 requirementId")
.contains("唯一 containerNo")
.contains("校验ISO 6346结构、设备类别和校验位")
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -81,7 +82,9 @@ class ContainerControllerContractTest {
assertThat(openapi)
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
.contains("Content-Disposition 与 Cache-Control no-store")
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
.contains("containerNo: {type: string, nullable: true")
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
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","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 schemaHasPlatformAggregateWithoutCustomerColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");}
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -30,8 +30,10 @@ class TruckAppointmentControllerContractTest {
}
}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");}
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@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:","cabinetNo:","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 methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.customs.container;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ScopeRequest;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.customs.client.CustomsFileClient;
@@ -108,6 +109,35 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).freezeScopes(anyLong());
}
@Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){
var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var declaring=container(ContainerCustomsService.DECLARING,3);
var source=document(1L);
var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1});
when(mapper.planInbounds(9L)).thenReturn(List.of(inbound));
when(mapper.container(9L)).thenReturn(declaring);
when(mapper.document(1L)).thenReturn(source);
when(mapper.lockContainerById(2L)).thenReturn(declaring);
assertThatThrownBy(()->service.uploadSource(7L,9L,5L,"upload-after-start",List.of(11L),file))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
assertThatThrownBy(()->service.replaceSource(7L,1L,5L,"replace-after-start",new ScopeRequest(List.of(11L),1),file))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
assertThatThrownBy(()->service.updateScope(7L,1L,5L,"scope-after-start",new ScopeRequest(List.of(11L),1)))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
assertThatThrownBy(()->service.deleteSource(7L,1L,5L,"delete-after-start",1))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
verifyNoInteractions(files);
verify(mapper,never()).insertDocument(anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
verify(mapper,never()).replaceDocument(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
verify(mapper,never()).touchScope(anyLong(),anyLong(),anyInt());
verify(mapper,never()).deleteDocument(anyLong(),anyLong(),anyInt(),anyLong());
verify(mapper,never()).insertDocumentVersion(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyString(),anyString(),anyLong());
assertThat(source.version()).isEqualTo(1);
assertThat(source.status()).isEqualTo("ACTIVE");
}
@Test void concurrentStartWithSameKeyReplaysOneAggregateAndDifferentPayloadConflicts() throws Exception {
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var ready=container(ContainerCustomsService.READY,2);
@@ -271,6 +301,27 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
@Test void allBuyContainerCompletesOnceAndPublishesAutoDispatchTrigger(){
var declaring=container(ContainerCustomsService.DECLARING,3);
var completed=container(ContainerCustomsService.COMPLETED,4);
var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION");
when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(declaring);
when(mapper.planInbounds(9L)).thenReturn(List.of(buy));
when(mapper.complete(2L,3,5L,"admin")).thenReturn(1);
when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1);
when(mapper.container(9L)).thenReturn(completed);
when(mapper.documents(2L,null)).thenReturn(List.of());
var result=service.completeAllBuy(9L,5L,"admin","all-buy-key",new ActionRequest(3,true,"代理已完成"));
assertThat(result.status()).isEqualTo(ContainerCustomsService.COMPLETED);
verify(mapper,times(1)).complete(2L,3,5L,"admin");
verify(mapper,times(1)).insertHistory(anyLong(),eq(2L),eq("COMPLETE_CUSTOMS"),
eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.COMPLETED),eq("代理已完成"),eq(5L),eq("admin"));
verify(events,times(1)).publishEvent(new ContainerCustomsService.CustomsCompletedEvent(8L));
}
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
private void assertMerchantOrderNotFound(long tenantId,long orderId){
@@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringJUnitConfig(LoadingDeliveryService.class)
class LoadingDeliveryServiceTest {
@@ -45,4 +47,76 @@ class LoadingDeliveryServiceTest {
verify(cabinetEvidence).requireComplete(99L);
}
@Test
void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() throws Exception {
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
when(repository.completionTarget(10L, 20L)).thenReturn(null);
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
eq("pda-full"), anyString())).thenReturn(true);
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(1);
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-full", "done");
verify(repository).completeLoading(10L, 20L, 9L, 8);
}
@Test
void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() throws Exception {
var target = new com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget(
20L, 21L, 22L, "LOADING", 6, "LOADING", 9);
Snapshot source = new Snapshot(20L, 10L, "LOADING", 6);
Snapshot loadedSlice = new Snapshot(21L, 10L, "LOADING", 9);
when(repository.completionTarget(10L, 20L)).thenReturn(target);
when(repository.findInboundForUpdate(10L, 21L)).thenReturn(loadedSlice);
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
eq("pda-partial"), anyString())).thenReturn(true);
when(repository.findInbound(10L, 20L)).thenReturn(source);
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
when(repository.completeLoadedChild(10L, 20L, 9L, 9)).thenReturn(1);
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-partial", "done");
verify(repository).findInboundForUpdate(10L, 21L);
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
}
@Test
void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() {
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
when(repository.completionTarget(10L, 20L)).thenReturn(null);
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
eq("pda-conflict"), anyString())).thenReturn(true);
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(0);
try {
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request");
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
throw new AssertionError(exception);
}
assertThatThrownBy(() -> service.completeAfterPdaSettlement(10L, 20L, 9L, "operator",
"pda-conflict", "done")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
verify(repository).completeLoading(10L, 20L, 9L, 8);
verify(histories, never()).insert(org.mockito.ArgumentMatchers
.any(com.baiyue.firstleg.entity.InboundStatusHistoryEntity.class));
}
private static InboundProgress resolvedProgress() {
InboundProgress progress = new InboundProgress();
progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1);
progress.setCompletedTaskCount(0); progress.setUnresolvedCount(0);
return progress;
}
}
@@ -67,6 +67,37 @@ class OceanBookingDocumentServiceTest {
verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class));
}
@Test void draftAcceptsBookingConfirmationAndBillOfLadingAsTwoVersionedFiles() {
MockMultipartFile confirmation = new MockMultipartFile("file", "confirmation.pdf",
"application/pdf", "confirmation".getBytes());
MockMultipartFile bill = new MockMultipartFile("file", "bill.pdf",
"application/pdf", "bill".getBytes());
when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
.thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1));
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
.thenReturn(ApiResponse.success(metadata(91L)));
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
.thenReturn(ApiResponse.success(metadata(92L)));
when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
.thenReturn(1);
var first = service.upload(10L, 0, "BOOKING_CONFIRMATION", "confirmation-key",
confirmation, 7L, "admin");
var second = service.upload(10L, 1, "BILL_OF_LADING", "bill-key", bill, 7L, "admin");
assertThat(first.bookingVersion()).isEqualTo(1);
assertThat(second.bookingVersion()).isEqualTo(2);
ArgumentCaptor<OceanBookingDocumentEntity> documents =
ArgumentCaptor.forClass(OceanBookingDocumentEntity.class);
verify(repository, times(2)).insert(documents.capture());
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getDocumentType)
.containsExactly("BOOKING_CONFIRMATION", "BILL_OF_LADING");
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getFileId)
.containsExactly(91L, 92L);
}
@Test void attachRejectsCrossScopeFileBeforePersisting() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(1));
@@ -121,6 +152,27 @@ class OceanBookingDocumentServiceTest {
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
OceanBookingEntity booking = draft(3);
OceanBookingDocumentEntity confirmation = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
OceanBookingDocumentEntity bill = activeDocument(21L, 92L, "BILL_OF_LADING");
when(repository.activeDocuments(10L)).thenReturn(List.of(confirmation, bill));
assertThatThrownBy(() -> service.requireActiveValidDocument(booking))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
private static OceanBookingDocumentEntity activeDocument(long id, long fileId, String type) {
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
document.setId(id); document.setOceanBookingId(10L); document.setFileId(fileId);
document.setDocumentType(type); document.setValid(true);
return document;
}
private static OceanBookingEntity draft(int version) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
@@ -75,14 +75,17 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口")
void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception {
@DisplayName("真实柜号只允许订舱关联或提柜后原子补录")
void onlyBookingLinkOrPickupAssignmentWritesContainerNumber() throws Exception {
String draftSql = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class,
long.class, String.class).getAnnotation(Update.class).value());
String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.filter(method -> method.getName().equals("linkReleasedBooking"))
.findFirst().orElseThrow().getAnnotation(Update.class).value());
String pickupSql = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("assignRealContainerNo", long.class, String.class, int.class,
long.class, String.class).getAnnotation(Update.class).value());
assertThat(draftSql)
.contains("capacity_cbm")
.contains("p.plan_status!='EDITING'")
@@ -92,6 +95,11 @@ class ContainerBookingMapperContractTest {
.doesNotContain("&gt;");
assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'",
"ocean_booking_id IS NULL");
assertThat(pickupSql)
.contains("container_no=#{containerNo}", "container_no IS NULL", "ocean_booking_id IS NOT NULL",
"ocean_booking_requirement_id IS NOT NULL", "version=#{version}", "fl_loading_pick_task",
"fl_loading_cabinet_evidence", "fl_container_document")
.doesNotContain("ocean_booking_id=#{");
assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
.doesNotContain("updateTransport", "unlinkReleasedBooking");
}
@@ -105,6 +113,7 @@ class ContainerBookingMapperContractTest {
}) {
assertThat(source)
.contains("国内唯一权威真实集装箱号")
.contains("提柜后可原子补录")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("CONSTRAINT chk_container_booking_iso6346")
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'");
@@ -78,7 +78,7 @@ class Phase3FinalUnifiedSqlContractTest {
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id));
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)")
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)")
.doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'",
"(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'");
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
@@ -111,6 +111,35 @@ class Phase3FinalUnifiedSqlContractTest {
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
}
@Test
void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception {
String unified = normalise(Files.readString(resolveUnified()));
String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql";
String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()";
List<String> tables = List.of(
"fl_customs_container",
"fl_customs_customer_document",
"fl_customs_customer_document_inbound",
"fl_customs_customer_document_version",
"fl_customs_frozen_document_inbound",
"fl_customs_completion_document",
"fl_customs_completion_document_version",
"fl_customs_container_history",
"fl_customs_command"
);
assertBlock(unified, marker, "/* Phase 3: link inbound preload plans",
"firstleg/src/main/resources/db/phase3-container-customs-schema.sql");
assertThat(count(unified, marker)).isEqualTo(1);
assertThat(unified.indexOf(marker)).isLessThan(unified.indexOf(finalAssertion));
for (String table : tables) {
String create = "CREATE TABLE IF NOT EXISTS " + table + " (";
assertThat(count(unified, create)).as(table).isEqualTo(1);
assertThat(unified.indexOf(create)).as(table + " before final assertion")
.isLessThan(unified.indexOf(finalAssertion));
}
}
@Test
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
String unified = normalise(Files.readString(resolveUnified()));
@@ -155,7 +184,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
@@ -23,6 +23,9 @@ class TruckAppointmentMapperContractTest {
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}",
"version=#{version}", "revision=revision+1", "version=version+1");
@@ -34,5 +37,6 @@ class TruckAppointmentMapperContractTest {
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
"version=version+1", "revision=revision+1");
assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName");
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
}
}
@@ -212,6 +212,58 @@ class ContainerServiceTest {
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("模拟柜允许不带真实柜号原子关联已发布订舱需求")
void linksReleasedBookingWithoutRealContainerNumber() {
stubLinkContext();
ContainerBookingEntity container = repository.find(11L);
when(repository.find(11L)).thenReturn(container, container);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, null, 2));
assertThat(result.getContainerNo()).isNull();
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).containerNoExists(anyString(), anyLong());
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("提柜后按柜次版本原子补录真实柜号")
void assignsRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
ContainerBookingEntity assigned = booking("EDITING", 4);
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked, assigned);
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
ContainerBookingEntity result = service.assignRealContainerNo(
11L, " ms cu 663987 0 ", 3, 9L, "admin");
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
}
@Test
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
void rejectsDuplicateRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked);
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.assignRealContainerNo(
11L, "MSCU6639870", 3, 9L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {
@@ -234,6 +234,38 @@ class InboundMeasurementServiceTest {
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
}
@Test
void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
InboundMeasurementResponses.WeightResult created = service.createWeightResult(
1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null));
assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL");
ArgumentCaptor<InboundWeightResultEntity> saved =
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
verify(repository).insertWeight(saved.capture());
InboundWeightResultEntity weight = saved.getValue();
weight.setId(20L);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInboundWeightSummary(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认"));
assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500");
verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class));
}
@Test
void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -254,6 +286,30 @@ class InboundMeasurementServiceTest {
verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5");
}
@Test
void weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"))
.thenThrow(mock(feign.FeignException.class));
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5"))
.isInstanceOf(BizException.class)
.hasMessageContaining("仓库服务暂不可用");
}
@Test
void weightPalletOptionsFailsClosedOnWarehouseNetworkError() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, null))
.thenThrow(mock(feign.RetryableException.class));
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("仓库服务暂不可用");
}
@Test
void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -775,9 +831,13 @@ class InboundMeasurementServiceTest {
}
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
inbound.setMeasureReviewStatus("APPROVED");
inbound.setActualGrossWeightKg(new BigDecimal("40.000"));
inbound.setActualNetWeightKg(new BigDecimal("30.000"));
inbound.setActualVolumeCbm(new BigDecimal("2.0000"));
inbound.setVolumeWeightKg(new BigDecimal("334.000"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setStatus("APPROVED");
@@ -791,14 +851,17 @@ class InboundMeasurementServiceTest {
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("审核完成");
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000");
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000");
verify(repository, never()).updateMeasurement(any());
verify(repository).updateInbound(inbound);
verify(repository, never()).updateInbound(any());
}
@Test
@@ -44,10 +44,13 @@ import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.time.LocalDateTime;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -74,6 +77,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingCompletionCommandRepository completionCommands;
@MockBean
private TransactionTemplate completionTransaction;
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private OceanBookingRepository oceanBookingRepository;
@@ -100,6 +105,12 @@ class LoadingPickServiceTest {
@BeforeEach
void activeOperationalContainer() {
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
Consumer<TransactionStatus> action = invocation.getArgument(0);
action.accept(mock(TransactionStatus.class));
return null;
}).when(completionTransaction).executeWithoutResult(any());
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setContainerNo("CSLU1234567");
@@ -92,6 +92,7 @@ class MeasurementReviewServiceTest {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
@@ -119,6 +120,40 @@ class MeasurementReviewServiceTest {
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT")
void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() {
Fixture fixture = fixture(false);
InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100");
InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816");
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku",
new MeasurementReviewRequest(20L, 3, null));
ArgumentCaptor<InboundStatusHistoryEntity> history =
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("体积或体积重任一层汇总不一致时审核零写入")
void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() {
Fixture fixture = fixture(false);
fixture.order.setActualVolumeCbm(new BigDecimal("0.7479"));
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
"approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("体积和体积重");
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
@DisplayName("驳回原因必填且状态推进为测量驳回")
void shouldRequireRejectReason() {
@@ -218,13 +253,15 @@ class MeasurementReviewServiceTest {
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
order.setActualNetWeightKg(new BigDecimal("1747.500"));
order.setActualVolumeCbm(new BigDecimal("0.7480"));
order.setVolumeWeightKg(new BigDecimal("124.916"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
measurement.setTotalPackageCount(5);
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916");
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
@@ -237,6 +274,14 @@ class MeasurementReviewServiceTest {
return new Fixture(service, repository, idempotency, order, measurement);
}
private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(packages);
item.setTotalVolumeCbm(new BigDecimal(volume));
item.setVolumeWeightKg(new BigDecimal(volumeWeight));
return item;
}
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setRecognitionStatus("CONFIRMED");
@@ -125,24 +125,35 @@ class TruckAppointmentServiceTest {
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
SaveRequest request = validRequest();
when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0)));
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0);
existing.setAppointmentNo("TA-260901-0003");
when(repository.firstActiveForUpdate(88L)).thenReturn(existing);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
verifyNoInteractions(oceanBookingDocuments);
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
assertThat(existing.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
assertThat(existing.getVersion()).isZero();
}
@Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() {
SaveRequest request = validRequest();
TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0);
concurrent.setAppointmentNo("TA-260901-0003");
when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent);
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'"))
.when(repository).insert(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
verify(repository, times(2)).firstActiveForUpdate(88L);
verify(repository, never()).history(any());
assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
assertThat(concurrent.getVersion()).isZero();
}
@Test void createPreservesMaximumChineseRemarkAndBoundaryValues() {
@@ -164,6 +175,31 @@ class TruckAppointmentServiceTest {
assertThat(inserted.getValue().getPalletCount()).isZero();
assertThat(inserted.getValue().getGrossWeightKg()).isZero();
assertThat(inserted.getValue().getVolumeCbm()).isZero();
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
}
@Test void createAcceptsUsd() {
SaveRequest request = validRequest();
when(request.currency()).thenReturn("USD");
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
service.create(7L, "admin", request);
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() {
SaveRequest request = validRequest();
when(request.currency()).thenReturn("CNY");
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
}
@Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() {