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