fix: complete October 22 workflow feedback and request failure recovery
This commit is contained in:
+11
-7
@@ -211,6 +211,7 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 全部预录单经客户确认后发送报关行执行,保留版本与发送状态校验。 */
|
||||
@Transactional
|
||||
public ContainerView submitToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
String action = "SUBMIT_CUSTOMS_TO_BROKER_" + planId;
|
||||
@@ -221,8 +222,8 @@ public class ContainerCustomsService {
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中");
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送客户回执给报关行");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有报关行回执未完成客户确认");
|
||||
if (mapper.taxRefundCount(container.loadingPlanId()) == 0) throw conflict("全买单柜无需发送预录单确认结果给报关行");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0) throw conflict("仍有预录单未完成客户确认");
|
||||
if (mapper.brokerSubmission(container.id()) == null) throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||
if (mapper.brokerSubmittedAt(container.id()) != null) throw conflict("当前柜已发送给报关行执行");
|
||||
if (mapper.submitToBroker(container.id(), container.version(), operatorId, operatorName) != 1) throw concurrent();
|
||||
@@ -231,6 +232,7 @@ public class ContainerCustomsService {
|
||||
return platformView(planId);
|
||||
}
|
||||
|
||||
/** 上传预录单并保存文件版本,已发送报关行执行后仍禁止替换。 */
|
||||
@Transactional
|
||||
public DocumentView uploadCompletion(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
@@ -246,7 +248,7 @@ public class ContainerCustomsService {
|
||||
throw conflict("当前客户资料不在报关中冻结版本");
|
||||
}
|
||||
if (mapper.brokerSubmittedAt(container.id()) != null) {
|
||||
throw conflict("客户确认回执已发送给报关行,不可再修改回执");
|
||||
throw conflict("客户确认的预录单已发送给报关行,不可再修改预录单");
|
||||
}
|
||||
if (mapper.brokerSubmission(container.id()) == null) {
|
||||
throw conflict("当前柜尚未完成首次发送报关行资料");
|
||||
@@ -300,6 +302,7 @@ public class ContainerCustomsService {
|
||||
return merchantViewForDocument(tenantId, source);
|
||||
}
|
||||
|
||||
/** 预录单确认并发送报关行执行后,接收真实报关完税文件。 */
|
||||
@Transactional
|
||||
public DocumentView uploadFinalDocument(long documentId, long operatorId, String operatorName, String key,
|
||||
int expectedVersion, MultipartFile file) {
|
||||
@@ -316,7 +319,7 @@ public class ContainerCustomsService {
|
||||
}
|
||||
if (mapper.brokerSubmittedAt(container.id()) == null) throw conflict("客户确认结果尚未发送给报关行");
|
||||
CompletionRow receipt = mapper.lockCompletion(documentId);
|
||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户回执尚未确认");
|
||||
if (receipt == null || !"CONFIRMED".equals(receipt.customerConfirmStatus())) throw conflict("该客户的预录单尚未确认");
|
||||
FinalRow existing = mapper.lockFinalDocument(documentId);
|
||||
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RESULT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
|
||||
long finalId;
|
||||
@@ -371,7 +374,7 @@ public class ContainerCustomsService {
|
||||
if (container == null || !(DECLARING.equals(container.status()) || COMPLETED.equals(container.status()))) throw conflict("当前柜尚未进入报关行执行阶段,不能创建正式申报批次");
|
||||
if (mapper.taxRefundCount(planId) == 0) throw conflict("全买单柜不创建退税正式申报批次");
|
||||
if (mapper.unconfirmedReceiptCount(container.id()) != 0 || mapper.brokerSubmittedAt(container.id()) == null) {
|
||||
throw conflict("当前柜尚未完成退税资料、回执确认和报关行执行");
|
||||
throw conflict("当前柜尚未完成退税资料、预录单确认和报关行执行");
|
||||
}
|
||||
if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent();
|
||||
long batchId = IdWorker.getId();
|
||||
@@ -536,14 +539,15 @@ public class ContainerCustomsService {
|
||||
row.operatorName(), row.createdAt())).toList();
|
||||
}
|
||||
|
||||
/** 按既有客户范围与可见状态下载预录单,沿用文件真实名称。 */
|
||||
public Download downloadCompletion(long tenantId, long documentId, boolean platform) {
|
||||
DocumentRow source = requiredDocument(documentId);
|
||||
ContainerRow container = mapper.containerById(source.customsContainerId());
|
||||
if (!platform && (source.tenantId() != tenantId || container == null || !Set.of(DECLARING, COMPLETED).contains(container.status()))) {
|
||||
throw notFound("报关行回执不存在");
|
||||
throw notFound("预录单不存在");
|
||||
}
|
||||
CompletionRow completion = mapper.completion(documentId);
|
||||
if (completion == null) throw notFound("报关行回执不存在");
|
||||
if (completion == null) throw notFound("预录单不存在");
|
||||
return download(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), completion.fileId(), completion.fileName());
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -126,6 +126,17 @@ public class PlatformOceanBookingController {
|
||||
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
|
||||
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
/** 放舱后单独维护预计到港,其他运输资料仍冻结。 */
|
||||
@PutMapping("/{oceanBookingId}/estimated-arrival")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_UPDATE_ESTIMATED_ARRIVAL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="维护订舱预计到港")
|
||||
public ApiResponse<Detail> updateEstimatedArrival(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody EstimatedArrivalRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.updateEstimatedArrival(oceanBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
/** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */
|
||||
@PutMapping("/{oceanBookingId}/actual-cargo")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
|
||||
|
||||
@@ -95,6 +95,10 @@ public final class OceanBookingDtos {
|
||||
@jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false)
|
||||
@jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {}
|
||||
|
||||
/** 放舱后仅维护预计到港,不接收其他运输资料。 */
|
||||
public record EstimatedArrivalRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull LocalDateTime estimatedArrivalAt) {}
|
||||
|
||||
/** 放舱后的受控实货量补录,不接收运输资料。 */
|
||||
public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Min(1) @Max(999999999) Integer actualPackageCount,
|
||||
|
||||
@@ -27,6 +27,11 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
/** 已放舱预计到港窄更新,保留状态、版本和操作审计。 */
|
||||
@Update("UPDATE fl_ocean_booking SET estimated_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND status='RELEASED' AND version=#{version}")
|
||||
int updateEstimatedArrival(@Param("id") long id, @Param("estimatedArrivalAt") java.time.LocalDateTime estimatedArrivalAt,
|
||||
@Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
/** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount},
|
||||
|
||||
+5
@@ -54,6 +54,11 @@ public class OceanBookingRepository {
|
||||
public int touchDraft(long id, int version, long operatorId, String operatorName) {
|
||||
return bookings.touchDraft(id, version, operatorId, operatorName);
|
||||
}
|
||||
/** 按订舱版本仅更新已放舱预计到港,不重写其他运输或货量资料。 */
|
||||
public int updateEstimatedArrival(long id, java.time.LocalDateTime estimatedArrivalAt, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateEstimatedArrival(id, estimatedArrivalAt, version, operatorId, operatorName);
|
||||
}
|
||||
|
||||
/** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */
|
||||
public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return bookings.updateActualCargo(value, version, operatorId, operatorName);
|
||||
|
||||
+18
@@ -145,6 +145,24 @@ public class OceanBookingService {
|
||||
addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
/** 放舱后单独维护预计到港,按版本和幂等键保存并审计前后快照。 */
|
||||
@Transactional
|
||||
public Detail updateEstimatedArrival(long id, long operatorId, String name, String key, EstimatedArrivalRequest request) {
|
||||
String action = "UPDATE_ESTIMATED_ARRIVAL";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.RELEASED);
|
||||
if (request.estimatedArrivalAt() == null) throw badRequest("预计到港时间不能为空");
|
||||
if (before.getEstimatedSailingAt() != null && request.estimatedArrivalAt().isBefore(before.getEstimatedSailingAt())) {
|
||||
throw badRequest("预计到港时间不能早于预计开船时间");
|
||||
}
|
||||
if (repository.updateEstimatedArrival(id, request.estimatedArrivalAt(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity saved = required(id);
|
||||
addHistory(id, before.getStatus(), saved.getStatus(), action, "维护预计到港", before, saved, operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(saved));
|
||||
}
|
||||
|
||||
/** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */
|
||||
@Transactional
|
||||
public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) {
|
||||
|
||||
@@ -82,15 +82,19 @@ public class ContainerService {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 首次关联已放舱订舱,仅规范化柜号并保留关联资格与并发校验。 */
|
||||
@Transactional
|
||||
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
|
||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
|
||||
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
|
||||
String existingContainerNo = trim(container.getContainerNo());
|
||||
String requestedContainerNo = trim(request.containerNo());
|
||||
String containerNo = requestedContainerNo == null
|
||||
? existingContainerNo
|
||||
: Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
// 本关联入口不按 ISO 长度、结构或校验位阻断,仍统一空白与大小写以核对唯一性。
|
||||
if (requestedContainerNo != null) {
|
||||
requestedContainerNo = trim(requestedContainerNo.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
|
||||
.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
String containerNo = requestedContainerNo == null ? existingContainerNo : requestedContainerNo;
|
||||
if (existingContainerNo != null && !existingContainerNo.equals(containerNo)) {
|
||||
throw conflict("输入的真实集装箱号与当前柜次已录入柜号不一致,请先在预装运输信息中纠正");
|
||||
}
|
||||
|
||||
@@ -493,10 +493,15 @@ public class LoadingListService {
|
||||
return repository.preloadDetail(requirePlan(planId));
|
||||
}
|
||||
|
||||
/** 保留关联入口已接受的柜号,运输入口首次录入或纠错仍执行原有校验。 */
|
||||
private static String targetContainerNo(AggregateRow before, String requested) {
|
||||
if (requested != null) return Iso6346ContainerNumber.require(requested);
|
||||
return StringUtils.hasText(before.getContainerNo())
|
||||
? Iso6346ContainerNumber.require(before.getContainerNo()) : null;
|
||||
if (requested == null) return StringUtils.hasText(before.getContainerNo()) ? before.getContainerNo() : null;
|
||||
String normalized = requested.replaceAll("[\\p{javaWhitespace}\\p{Z}]", "")
|
||||
.toUpperCase(java.util.Locale.ROOT);
|
||||
if (StringUtils.hasText(normalized) && normalized.equals(before.getContainerNo())) {
|
||||
return before.getContainerNo();
|
||||
}
|
||||
return Iso6346ContainerNumber.require(requested);
|
||||
}
|
||||
|
||||
private static String transportHash(long planId, int version, String containerNo,
|
||||
|
||||
@@ -140,6 +140,7 @@ public class LoadingPickService {
|
||||
smartLoadingRepository.items(plan.getId()));
|
||||
}
|
||||
|
||||
/** 确认预装前验证作业资格和真实柜号,格式问题必须在实物作业前纠正。 */
|
||||
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
||||
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
||||
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
||||
@@ -151,6 +152,7 @@ public class LoadingPickService {
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw conflict("确认预装前必须录入真实集装箱号");
|
||||
}
|
||||
requireContainerNumberBeforeStarting(booking.getContainerNo());
|
||||
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
||||
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
||||
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
||||
@@ -164,12 +166,14 @@ public class LoadingPickService {
|
||||
return links;
|
||||
}
|
||||
|
||||
/** 仅在柜号已满足完成事件要求时创建 PDA 任务,已有任务的重复同步保持原语义。 */
|
||||
@Transactional
|
||||
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
||||
List<LoadingPlanItemEntity> planItems) {
|
||||
inboundHoldGuard.requirePlanNotHeld(planId);
|
||||
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
||||
if (task == null) {
|
||||
requireContainerNumberBeforeStarting(containerNo);
|
||||
task = new LoadingPickTaskEntity();
|
||||
task.setLoadingPlanId(planId);
|
||||
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
|
||||
@@ -610,6 +614,16 @@ public class LoadingPickService {
|
||||
/** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */
|
||||
public record CompletionAccepted(long commandId, long acceptedAtNanos) {}
|
||||
|
||||
/** 在仍可纠错的预装阶段提示格式错误,避免领取或实物作业后卡在完成门禁。 */
|
||||
private static void requireContainerNumberBeforeStarting(String containerNo) {
|
||||
try {
|
||||
Iso6346ContainerNumber.require(containerNo);
|
||||
} catch (BizException exception) {
|
||||
throw conflict("请先在预装运输信息中纠正真实集装箱号后再确认预装或启动PDA,需符合ISO 6346");
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成前复核真实柜号,以生成一致的跨服务装柜完成事件。 */
|
||||
private void requireCompletionContainerNumber(long taskId) {
|
||||
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
||||
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
||||
|
||||
Reference in New Issue
Block a user