feat(firstleg): add sailing calendar facts and APIs

This commit is contained in:
2026-09-27 18:55:19 +08:00
parent 93e2e8fb44
commit 4525790534
34 changed files with 2694 additions and 5 deletions
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionPage;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ExceptionQuery;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Validated
@RestController
@Tag(name = "船期异常货量")
@RequestMapping("/api/platform/v1/sailing-assignments")
public class PlatformSailingAssignmentController {
@Resource private SailingCalendarService service;
@GetMapping("/exceptions")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
@Operation(summary = "查询未安排和晚到货量")
public ApiResponse<ExceptionPage> exceptions(@AuthenticationPrincipal AuthenticatedUser user, @Valid ExceptionQuery query) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.exceptions(query));
}
}
@@ -0,0 +1,104 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@Validated
@RestController
@Tag(name = "船期日历")
@RequestMapping("/api/platform/v1/sailings")
public class PlatformSailingCalendarController {
@Resource private SailingCalendarService service;
@GetMapping("/calendar")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
@Operation(summary = "查询船期月历")
public ApiResponse<CalendarResult> calendar(@AuthenticationPrincipal AuthenticatedUser user, @Valid SailingQuery query) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.calendar(query));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<SailingDetail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
}
@GetMapping("/{id}/history")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
@Operation(summary = "查询船期变更历史")
public ApiResponse<List<SailingHistoryView>> history(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.history(id));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')")
public ApiResponse<SailingDetail> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody SailingCreateRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(service.createSailing(user.userId(), user.username(), request).getId()));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')")
public ApiResponse<SailingDetail> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody SailingUpdateRequest request) {
FirstlegScopeGuard.platform(user); service.updateSailing(id, user.userId(), user.username(), request); return ApiResponse.success(service.detail(id));
}
@PostMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:edit')")
public ApiResponse<SailingDetail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody CancelRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(id, user.userId(), user.username(), request));
}
@PostMapping("/{id}/exception-reassign")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:exception-reassign')")
public ApiResponse<ExceptionReassignResult> exceptionReassign(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id,
@Valid @RequestBody ExceptionReassignRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.exceptionReassign(id, user.userId(), user.username(), request));
}
@PostMapping("/{id}/copy")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:create')")
public ApiResponse<SailingDetail> copy(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody CopyRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.copy(id, user.userId(), user.username(), request));
}
@GetMapping("/{id}/volume-summary")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<VolumeDetail> volumeSummary(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.volumeSummary(id));
}
@GetMapping("/{id}/booking-advice")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<BookingAdvice> bookingAdvice(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.bookingAdvice(id));
}
@PostMapping("/{id}/booking-links")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-booking:link')")
public ApiResponse<java.util.Map<String, Object>> bookingLink(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody BookingLinkRequest request) {
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkBooking(id, user.userId(), user.username(), request));
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/platform/v1/sailing-channels")
public class PlatformSailingChannelController {
@Resource private SailingCalendarService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-calendar:list')")
public ApiResponse<List<SailingChannelEntity>> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid ChannelQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(query).stream().map(ChannelView::channel).toList()); }
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<SailingChannelEntity> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ChannelCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createChannel(user.userId(), user.username(), request)); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<SailingChannelEntity> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody ChannelUpdateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.updateChannel(id, user.userId(), user.username(), request)); }
}
@@ -0,0 +1,74 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastCreateRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastRevisionRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastReceiptBindRequest;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ForecastVoidRequest;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 平台运营维护船期日历使用的正式未收货量预报事实入口。 */
@Validated
@RestController
@Tag(name = "船期货量预报")
@RequestMapping("/api/platform/v1/sailing-forecasts")
public class PlatformSailingForecastController {
@Resource private SailingCalendarService service;
@PostMapping
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "新增未收货量预报", description = "保存版本化预报事实并按仓库、渠道、港口和截止时间自动归班;无法解释时保留UNMATCHED。")
public ApiResponse<SailingInboundVolumeForecastEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ForecastCreateRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
return ApiResponse.success(service.createForecast(user.userId(), request));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "修订未收货量预报", description = "旧版本标记为SUPERSEDED,新版本重新归班;不覆盖历史版本。")
public ApiResponse<SailingInboundVolumeForecastEntity> revise(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody ForecastRevisionRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
return ApiResponse.success(service.reviseForecast(user.userId(), id, request));
}
@PostMapping("/{id}/void")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "作废未收货量预报", description = "作废有效预报并将其当前归班版本标记为SUPERSEDED。")
public ApiResponse<Void> voidForecast(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody ForecastVoidRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
service.voidForecast(user.userId(), id, request);
return ApiResponse.success((Void) null);
}
@PostMapping("/{id}/bind-receipt")
@PreAuthorize("hasAuthority('platform:firstleg:sailing-volume:forecast-manage')")
@Operation(summary = "将实收批次绑定到预报批次", description = "多批预报无法由实测体积唯一解释时,由运营显式选择预报批次;服务端保留原历史版本。")
public ApiResponse<Void> bindReceipt(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id,
@Valid @RequestBody ForecastReceiptBindRequest request) {
FirstlegScopeGuard.platform(user, request.tenantId());
service.bindReceiptToForecast(user.userId(), id, request);
return ApiResponse.success((Void) null);
}
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.sailingcalendar.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
import com.baiyue.firstleg.sailingcalendar.service.SailingCalendarService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
@Validated
@RestController
@RequestMapping("/api/platform/v1/sailing-templates")
public class PlatformSailingTemplateController {
@Resource private SailingCalendarService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<List<TemplateView>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid TemplateQuery query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateViews(query)); }
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<TemplateView> create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody TemplateCreateRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.templateView(service.createTemplate(user.userId(), user.username(), request).getId())); }
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<TemplateView> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody TemplateUpdateRequest request) { FirstlegScopeGuard.platform(user); service.updateTemplate(id, user.userId(), user.username(), request); return ApiResponse.success(service.templateView(id)); }
@PostMapping("/{id}/generation-preview") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<GenerationPreviewResult> preview(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); return ApiResponse.success(new GenerationPreviewResult(id, service.generationPreview(id, range.from(), range.to()))); }
@PostMapping("/{id}/generate") @PreAuthorize("hasAuthority('platform:firstleg:sailing-template:manage')")
public ApiResponse<GenerationResult> generate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@Valid @RequestBody GenerationRange range) { FirstlegScopeGuard.platform(user); List<SailingEntity> generated = service.generate(user.userId(), user.username(), id, range.from(), range.to()); List<GenerationPreview> records = service.generationPreview(id, range.from(), range.to()); return ApiResponse.success(new GenerationResult(id, records, generated.size(), Math.max(0, records.size() - generated.size()))); }
}
@@ -0,0 +1,283 @@
package com.baiyue.firstleg.sailingcalendar.dto;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
public final class SailingCalendarDtos {
private SailingCalendarDtos() {}
public record ChannelQuery(@Size(max = 64) String keyword, String status) {}
public record ChannelData(@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank String sourceType,
@Size(max = 64) String mappedChannelCode,
@Size(max = 32) String defaultDeparturePortCode,
@Size(max = 32) String defaultDestinationPortCode,
@Min(0) @Max(30) Integer defaultCutoffDays,
@Size(max = 1000) String remark) {}
/** Flat JSON contract; data() keeps the service boundary grouped by domain object. */
public record ChannelCreateRequest(@Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank String sourceType,
@Size(max = 64) String mappedChannelCode,
@Size(max = 32) String defaultDeparturePortCode,
@Size(max = 32) String defaultDestinationPortCode,
@Min(0) @Max(30) Integer defaultCutoffDays,
@Size(max = 1000) String remark) {
public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType,
mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); }
}
public record ChannelUpdateRequest(@Min(0) Integer expectedVersion,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank String sourceType,
@Size(max = 64) String mappedChannelCode,
@Size(max = 32) String defaultDeparturePortCode,
@Size(max = 32) String defaultDestinationPortCode,
@Min(0) @Max(30) Integer defaultCutoffDays,
@Size(max = 1000) String remark,
@NotBlank String status) {
public ChannelData data() { return new ChannelData(channelCode, channelName, sourceType,
mappedChannelCode, defaultDeparturePortCode, defaultDestinationPortCode, defaultCutoffDays, remark); }
}
public record ChannelView(SailingChannelEntity channel, boolean editable) {}
public record TemplateQuery(@Positive Long warehouseId, @Positive Long channelId,
String status, @Min(1) long page, @Min(1) @Max(200) long size) {
public TemplateQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record TemplateData(@Size(max = 128) String templateName,
@NotNull @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo,
@NotNull LocalTime departureTime,
@NotNull @Min(0) @Max(30) Integer cutoffDays,
@NotNull LocalTime cutoffLocalTime,
@Size(max = 64) String voyageRule,
@Size(max = 128) String vesselName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 1000) String remark) {}
public record TemplateCreateRequest(@Min(0) Integer expectedVersion,
@Size(max = 128) String templateName,
@NotNull @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo,
@NotNull LocalTime departureTime,
@NotNull @Min(0) @Max(30) Integer cutoffDays,
@NotNull LocalTime cutoffLocalTime,
@Size(max = 64) String voyageRule,
@Size(max = 128) String vesselName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 1000) String remark) {
public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName,
weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule,
vesselName, departurePortCode, destinationPortCode, remark); }
}
public record TemplateUpdateRequest(@Min(0) Integer expectedVersion,
@Size(max = 128) String templateName,
@NotNull @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
List<@NotNull @Min(1) @Max(7) Integer> weekdays,
@NotNull LocalDate effectiveFrom,
LocalDate effectiveTo,
@NotNull LocalTime departureTime,
@NotNull @Min(0) @Max(30) Integer cutoffDays,
@NotNull LocalTime cutoffLocalTime,
@Size(max = 64) String voyageRule,
@Size(max = 128) String vesselName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 1000) String remark,
String status) {
public TemplateData data() { return new TemplateData(templateName, channelId, warehouseId, warehouseName,
weekdays, effectiveFrom, effectiveTo, departureTime, cutoffDays, cutoffLocalTime, voyageRule,
vesselName, departurePortCode, destinationPortCode, remark); }
}
public record TemplateView(Long id, String templateNo, String templateName, Long channelId, String channelCode,
String channelName, Long warehouseId, String warehouseName, List<Integer> weekdays,
LocalDate effectiveFrom, LocalDate effectiveTo, LocalTime departureTime,
Integer cutoffDays, LocalTime cutoffLocalTime, String voyageRule, String vesselName,
String departurePortCode, String destinationPortCode, String remark,
String status, Integer version) {}
public record SailingQuery(@NotBlank String month,
@Positive Long warehouseId, @Positive Long channelId,
@Size(max = 64) String channelCode,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String keyword, String status) {}
public record SailingData(@Positive Long templateId, @Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@Size(max = 64) String sourceType,
@Size(max = 128) String remark,
BigDecimal plannedCapacityCbm) {}
public record SailingCreateRequest(@Min(0) Integer expectedVersion,
@Positive Long templateId,
@Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@Size(max = 64) String sourceType,
@Size(max = 128) String remark,
BigDecimal plannedCapacityCbm) {
public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName,
channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName,
vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); }
}
public record SailingUpdateRequest(@Min(0) Integer expectedVersion,
@Positive Long templateId,
@Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank @Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@NotBlank @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@Size(max = 64) String sourceType,
@Size(max = 128) String remark,
BigDecimal plannedCapacityCbm,
String status) {
public SailingData data() { return new SailingData(templateId, channelId, warehouseId, warehouseName,
channelCode, channelName, departurePortCode, departurePortName, destinationPortCode, destinationPortName,
vesselName, voyageNo, etdAt, etaAt, cutoffAt, sourceType, remark, plannedCapacityCbm); }
}
public record CalendarItem(Long id, String sailingNo, Long warehouseId, String warehouseName,
String channelCode, String channelName, String vesselName, String voyageNo,
String departurePortCode, String destinationPortCode, LocalDateTime etdAt,
LocalDateTime cutoffAt, String status, Integer version,
VolumeSummary volumeSummary) {}
public record VolumeSummary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm,
BigDecimal riskUnmatchedCbm, int bookedContainerCount,
int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm,
BigDecimal bookedPlanningCapacityCbm, LocalDateTime snapshotAt,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
public record CalendarResult(String month, String timezone, List<CalendarItem> records,
Summary summary, FilterOptions filterOptions) {
public record Summary(BigDecimal actualReceivedCbm, BigDecimal catchableForecastCbm, BigDecimal riskUnmatchedCbm,
int bookedContainerCount, int recommendedContainerCount, int pendingContainerCount,
int lateBatchCount, boolean overCapacity, BigDecimal planningCapacityCbm, int sailingCount,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
public record FilterOptions(List<Option> warehouses, List<Option> channels, List<Option> departurePorts, List<Option> destinationPorts) {}
public record Option(String value, String label) {}
}
public record SailingDetail(CalendarItem sailing, List<SailingHistoryView> histories) {}
public record SailingHistoryView(Long id, String action, String fromStatus, String toStatus,
String reason, String operatorName, LocalDateTime createdAt) {}
public record GenerationPreview(LocalDate date, LocalDateTime etdAt, LocalDateTime cutoffAt,
String voyageNo, boolean conflict, String conflictReason) {}
public record GenerationRange(@NotNull LocalDate from, @NotNull LocalDate to) {}
public record GenerationPreviewResult(Long templateId, List<GenerationPreview> records) {}
public record GenerationResult(Long templateId, List<GenerationPreview> records, int generatedCount, int skippedCount) {}
public record CancelRequest(Integer expectedVersion, @Size(max = 1000) String reason) {}
public record CopyRequest(@NotNull LocalDateTime etdAt, @NotNull LocalDateTime cutoffAt) {}
public record AssignmentView(String id, String receiptBatchId, String forecastId, String inboundCode,
LocalDateTime receivedAt, BigDecimal volumeCbm, String status,
String assignmentSource, String reason, Integer assignmentVersion) {}
public record VolumeDetail(VolumeSummary summary, List<AssignmentView> assignments) {}
public record BookingAdvice(BigDecimal actualReceivedCbm, BigDecimal catchableTotalCbm,
BigDecimal planningCapacityCbm, int bookedContainerCount,
int recommendedContainerCount, int pendingContainerCount,
List<BookingScenario> scenarios, LocalDateTime calculatedAt,
BigDecimal catchableReceivedCbm, BigDecimal lateReceivedCbm) {}
public record BookingScenario(String containerTypeCode, String containerTypeName,
BigDecimal capacityCbm, int recommendedContainerCount, int pendingContainerCount) {}
public record BookingLinkRequest(@NotNull @Positive Long oceanBookingId, Integer expectedVersion) {}
public record ExceptionReassignRequest(@NotNull @Positive Long assignmentId,
@NotNull @Positive Long targetSailingId,
@NotNull @Min(0) Integer expectedAssignmentVersion,
@NotNull @Min(0) Integer expectedTargetVersion,
@NotBlank @Size(max = 500) String reason) {}
public record ExceptionReassignResult(Long oldAssignmentId, Long newAssignmentId,
Long sourceSailingId, Long targetSailingId,
Integer assignmentVersion, String matchStatus,
BigDecimal volumeCbm, BigDecimal forecastVolumeCbm,
String reason) {}
public record ExceptionQuery(Long warehouseId, String channelCode, String status,
@Min(1) long page, @Min(1) @Max(200) long size) {
public ExceptionQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record ExceptionRecord(Long assignmentId, String inboundCode, Long warehouseId, String warehouseName,
String channelCode, String channelName, String sourceSailingNo,
String sourceVoyageNo, String status, BigDecimal volumeCbm,
LocalDateTime receivedAt, LocalDateTime expectedArrivalAt,
Integer waitingDays, String reason, Integer assignmentVersion) {}
public record ExceptionPage(List<ExceptionRecord> records, long page, long size, long total, long pages) {}
public record ForecastCreateRequest(@NotNull @Positive Long tenantId,
@NotNull @Positive Long inboundId,
@NotBlank @Size(max = 64) String forecastKey,
@NotNull @Positive Long receivingWarehouseId,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@NotNull LocalDateTime expectedArrivalAt,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.0001") BigDecimal forecastVolumeCbm,
@NotBlank @Size(max = 32) String sourceType) {}
public record ForecastRevisionRequest(@NotNull @Positive Long tenantId,
@NotNull @Min(1) Integer expectedFactVersion,
@Size(max = 64) String channelCode,
@Size(max = 128) String channelName,
@Size(max = 32) String departurePortCode,
@Size(max = 32) String destinationPortCode,
@NotNull LocalDateTime expectedArrivalAt,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0.0001") BigDecimal forecastVolumeCbm,
@NotBlank @Size(max = 32) String sourceType) {}
public record ForecastVoidRequest(@NotNull @Positive Long tenantId, @NotBlank @Size(max = 500) String reason) {}
public record ForecastReceiptBindRequest(@NotNull @Positive Long tenantId,
@NotNull @Positive Long receiptBatchId,
@NotNull @Min(1) Integer expectedAssignmentVersion,
@NotBlank @Size(max = 500) String reason) {}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_sailing_channel")
public class SailingChannelEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String channelCode;
private String channelName;
private String sourceType;
private String mappedChannelCode;
private String defaultDeparturePortCode;
private String defaultDestinationPortCode;
private Integer defaultCutoffDays;
private String status;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_sailing")
public class SailingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String sailingNo;
private Long templateId;
private Long channelId;
private String channelCodeSnapshot;
private String channelNameSnapshot;
private Long warehouseId;
private String warehouseNameSnapshot;
private String departurePortCode;
private String destinationPortCode;
private String vesselName;
private String voyageNo;
private LocalDateTime etdAt;
private LocalDateTime warehouseCutoffAt;
private LocalDateTime etaAt;
private String status;
private String sourceType;
private String generatedKey;
private BigDecimal plannedCapacityCbm;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 船期实例的追加式变更历史。 */
@Getter
@Setter
@TableName("fl_sailing_history")
public class SailingHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long sailingId;
private String action;
private String fromStatus;
private String toStatus;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,45 @@
package com.baiyue.firstleg.sailingcalendar.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_sailing_template")
public class SailingTemplateEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String templateNo;
private String templateName;
private Long channelId;
private String channelCodeSnapshot;
private String channelNameSnapshot;
private Long warehouseId;
private String warehouseNameSnapshot;
private String departurePortCode;
private String destinationPortCode;
private Integer weekday;
private Integer weekdayMask;
private LocalTime etdLocalTime;
private Integer cutoffDays;
private LocalTime cutoffLocalTime;
private LocalDate validFrom;
private LocalDate validTo;
private String voyageRule;
private String vesselName;
private String status;
private String remark;
private Long createdBy;
private String createdByName;
private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
private LocalDateTime updatedAt;
private Integer version;
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 自动归班事实;重算只追加新版本并将旧版本标记为SUPERSEDED。 */
@Getter
@Setter
@TableName("fl_sailing_assignment")
public class SailingAssignmentEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long sailingId;
private Long originSailingId;
private Long inboundId;
private String inboundCode;
private Long receiptBatchId;
private Long forecastId;
private String assignmentSource;
private Integer assignmentVersion;
private String matchStatus;
private BigDecimal volumeCbm;
private BigDecimal forecastVolumeCbm;
private String routeSource;
private String volumeSource;
private String ruleVersion;
private LocalDateTime matchedAt;
private LocalDateTime supersededAt;
private LocalDateTime lockedAt;
private String lockReason;
private Long operatorId;
private String reason;
private LocalDateTime createdAt;
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
/** 计划船期与RELEASED订舱的最小关联;不创建真实柜次。 */
@Getter
@Setter
@TableName("fl_sailing_booking_link")
public class SailingBookingLinkEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private Long sailingId;
private Long oceanBookingId;
private String linkStatus;
private Integer activeBookingGuard;
private String bookingStatusSnapshot;
private Long warehouseId;
private String channelCode;
private String departurePortCode;
private String destinationPortCode;
private LocalDateTime linkedAt;
private LocalDateTime revokedAt;
private Long operatorId;
private String operatorName;
private String reason;
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 未收货量预报事实;申报体积不是本实体的回退来源。 */
@Getter
@Setter
@TableName("fl_sailing_inbound_volume_forecast")
public class SailingInboundVolumeForecastEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private String forecastKey;
private Integer factVersion;
private Long inboundId;
private Long receivingWarehouseId;
private String channelCode;
private String channelName;
private String departurePortCode;
private String destinationPortCode;
private LocalDateTime expectedArrivalAt;
private BigDecimal forecastVolumeCbm;
private String sourceType;
private String factStatus;
private LocalDateTime effectiveAt;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.sailingcalendar.fact.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 船期日历消费的稳定收货批次实测事实;不直接替代入库单聚合。 */
@Getter
@Setter
@TableName("fl_sailing_receipt_batch_fact")
public class SailingReceiptBatchFactEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId;
private String receiptBatchKey;
private Integer factVersion;
private Long inboundId;
private Long measurementId;
private Long receivingWarehouseId;
private String channelCode;
private String channelName;
private String departurePortCode;
private String destinationPortCode;
private LocalDateTime receivedAt;
private BigDecimal actualVolumeCbm;
private String factStatus;
private Long reversalOfFactId;
private String sourceType;
private Long createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
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.util.List;
@Mapper
public interface SailingAssignmentMapper extends BaseMapper<SailingAssignmentEntity> {
@Select("SELECT * FROM fl_sailing_assignment WHERE id=#{id} FOR UPDATE")
SailingAssignmentEntity selectForUpdate(@Param("id") long id);
@Update("UPDATE fl_sailing_assignment SET match_status='SUPERSEDED',superseded_at=#{at},operator_id=#{operatorId},reason=#{reason} "
+ "WHERE id=#{id} AND assignment_version=#{expectedVersion} AND match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED')")
int supersedeCurrent(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("reason") String reason,
@Param("at") java.time.LocalDateTime at);
@Select("SELECT * FROM fl_sailing_assignment WHERE match_status IN ('UNMATCHED','LATE') ORDER BY matched_at ASC,id ASC")
List<SailingAssignmentEntity> selectExceptions();
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SailingBookingLinkMapper extends BaseMapper<SailingBookingLinkEntity> {}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SailingInboundVolumeForecastMapper extends BaseMapper<SailingInboundVolumeForecastEntity> {}
@@ -0,0 +1,18 @@
package com.baiyue.firstleg.sailingcalendar.fact.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface SailingReceiptBatchFactMapper extends BaseMapper<SailingReceiptBatchFactEntity> {
@Select("SELECT * FROM fl_sailing_receipt_batch_fact WHERE tenant_id=#{tenantId} AND receipt_batch_key=#{batchKey} AND fact_status='APPROVED' ORDER BY fact_version DESC, id DESC LIMIT 1")
SailingReceiptBatchFactEntity selectCurrent(@Param("tenantId") long tenantId, @Param("batchKey") String batchKey);
@Select("SELECT * FROM fl_sailing_receipt_batch_fact WHERE tenant_id=#{tenantId} AND receiving_warehouse_id=#{warehouseId} AND fact_status='APPROVED' AND received_at >= #{from} AND received_at < #{to} ORDER BY received_at, id")
IPage<SailingReceiptBatchFactEntity> selectReceived(Page<SailingReceiptBatchFactEntity> page, @Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId, @Param("from") java.time.LocalDateTime from, @Param("to") java.time.LocalDateTime to);
}
@@ -0,0 +1,195 @@
package com.baiyue.firstleg.sailingcalendar.fact.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingAssignmentMapper;
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingBookingLinkMapper;
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingInboundVolumeForecastMapper;
import com.baiyue.firstleg.sailingcalendar.fact.mapper.SailingReceiptBatchFactMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.time.LocalDateTime;
/**
* 船期日历事实读写端口。
*
* 这里故意不从 fl_inbound_order 分页拼装数据,也不把订舱柜次关联当作已订柜数;
* 上层服务应在事务内写入新的事实版本,再由日历域按有效状态读取。
*/
@Repository
public class SailingFactsRepository {
@Resource private SailingReceiptBatchFactMapper receiptFacts;
@Resource private SailingInboundVolumeForecastMapper forecasts;
@Resource private SailingAssignmentMapper assignments;
@Resource private SailingBookingLinkMapper bookingLinks;
public void insertReceiptFact(SailingReceiptBatchFactEntity value) {
receiptFacts.insert(value);
}
public SailingReceiptBatchFactEntity currentReceiptFact(long tenantId, String batchKey) {
return receiptFacts.selectCurrent(tenantId, batchKey);
}
/** 查询船期候选范围内的有效实收事实;平台汇总故意不带客户分页条件。 */
public List<SailingReceiptBatchFactEntity> approvedReceiptFacts(long warehouseId, LocalDateTime from, LocalDateTime to) {
return receiptFacts.selectList(new LambdaQueryWrapper<SailingReceiptBatchFactEntity>()
.eq(SailingReceiptBatchFactEntity::getReceivingWarehouseId, warehouseId)
.eq(SailingReceiptBatchFactEntity::getFactStatus, "APPROVED")
.ge(SailingReceiptBatchFactEntity::getReceivedAt, from)
.lt(SailingReceiptBatchFactEntity::getReceivedAt, to)
.orderByAsc(SailingReceiptBatchFactEntity::getReceivedAt)
.orderByAsc(SailingReceiptBatchFactEntity::getId));
}
public void insertForecast(SailingInboundVolumeForecastEntity value) {
forecasts.insert(value);
}
public SailingInboundVolumeForecastEntity forecast(long id) { return forecasts.selectById(id); }
public int consumeForecast(long tenantId, long forecastId, LocalDateTime at) {
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.set(SailingInboundVolumeForecastEntity::getFactStatus, "CONSUMED")
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
}
public int supersedeForecast(long tenantId, long forecastId, LocalDateTime at) {
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.set(SailingInboundVolumeForecastEntity::getFactStatus, "SUPERSEDED")
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
}
public int voidForecast(long tenantId, long forecastId, LocalDateTime at) {
return forecasts.update(null, new LambdaUpdateWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getId, forecastId)
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.set(SailingInboundVolumeForecastEntity::getFactStatus, "VOIDED")
.set(SailingInboundVolumeForecastEntity::getUpdatedAt, at));
}
public SailingReceiptBatchFactEntity receiptFact(long id) { return receiptFacts.selectById(id); }
public List<SailingInboundVolumeForecastEntity> currentForecasts(long tenantId, long inboundId) {
return forecasts.selectList(new LambdaQueryWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getTenantId, tenantId)
.eq(SailingInboundVolumeForecastEntity::getInboundId, inboundId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.orderByDesc(SailingInboundVolumeForecastEntity::getFactVersion)
.orderByDesc(SailingInboundVolumeForecastEntity::getId));
}
/** 返回某个预计批次当前仍有效的归班版本,供实收替换时保留原船期。 */
public SailingAssignmentEntity currentAssignmentForForecast(long tenantId, long forecastId) {
return assignments.selectOne(new LambdaQueryWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getForecastId, forecastId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.orderByDesc(SailingAssignmentEntity::getAssignmentVersion)
.orderByDesc(SailingAssignmentEntity::getId)
.last("LIMIT 1"));
}
public SailingAssignmentEntity currentAssignmentForReceipt(long tenantId, long receiptBatchId) {
return assignments.selectOne(new LambdaQueryWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getReceiptBatchId, receiptBatchId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.orderByDesc(SailingAssignmentEntity::getAssignmentVersion)
.orderByDesc(SailingAssignmentEntity::getId)
.last("LIMIT 1"));
}
/** 查询仍有效且有ETA的预计事实;实收事实应由上层扣除,避免重复计量。 */
public List<SailingInboundVolumeForecastEntity> validForecasts(long warehouseId, LocalDateTime from, LocalDateTime to) {
return forecasts.selectList(new LambdaQueryWrapper<SailingInboundVolumeForecastEntity>()
.eq(SailingInboundVolumeForecastEntity::getReceivingWarehouseId, warehouseId)
.eq(SailingInboundVolumeForecastEntity::getFactStatus, "VALID")
.ge(SailingInboundVolumeForecastEntity::getExpectedArrivalAt, from)
.lt(SailingInboundVolumeForecastEntity::getExpectedArrivalAt, to)
.orderByAsc(SailingInboundVolumeForecastEntity::getExpectedArrivalAt)
.orderByAsc(SailingInboundVolumeForecastEntity::getId));
}
public void insertAssignment(SailingAssignmentEntity value) {
assignments.insert(value);
}
public SailingAssignmentEntity assignmentForUpdate(long id) { return assignments.selectForUpdate(id); }
public int supersedeAssignment(long id, int expectedVersion, long operatorId, String reason, LocalDateTime at) {
return assignments.supersedeCurrent(id, expectedVersion, operatorId, reason, at);
}
public List<SailingAssignmentEntity> assignments(long sailingId) {
return assignments.selectList(new LambdaQueryWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getSailingId, sailingId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.orderByAsc(SailingAssignmentEntity::getMatchedAt)
.orderByAsc(SailingAssignmentEntity::getId));
}
public List<SailingAssignmentEntity> exceptionAssignments() { return assignments.selectExceptions(); }
/** 旧版本只允许转为SUPERSEDED,不物理删除或覆盖。 */
public int supersedeReceiptAssignments(long tenantId, long receiptBatchId, java.time.LocalDateTime at) {
return assignments.update(null, new LambdaUpdateWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getReceiptBatchId, receiptBatchId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.set(SailingAssignmentEntity::getMatchStatus, "SUPERSEDED")
.set(SailingAssignmentEntity::getSupersededAt, at));
}
public int supersedeForecastAssignments(long tenantId, long forecastId, java.time.LocalDateTime at) {
return assignments.update(null, new LambdaUpdateWrapper<SailingAssignmentEntity>()
.eq(SailingAssignmentEntity::getTenantId, tenantId)
.eq(SailingAssignmentEntity::getForecastId, forecastId)
.in(SailingAssignmentEntity::getMatchStatus, List.of("FORECAST_MATCHED", "RECEIVED", "LATE", "UNMATCHED"))
.set(SailingAssignmentEntity::getMatchStatus, "SUPERSEDED")
.set(SailingAssignmentEntity::getSupersededAt, at));
}
public void insertBookingLink(SailingBookingLinkEntity value) {
bookingLinks.insert(value);
}
public SailingBookingLinkEntity activeBookingLink(long oceanBookingId) {
return bookingLinks.selectOne(new LambdaQueryWrapper<SailingBookingLinkEntity>()
.eq(SailingBookingLinkEntity::getOceanBookingId, oceanBookingId)
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
.last("LIMIT 1"));
}
public List<SailingBookingLinkEntity> activeBookingLinks(long sailingId) {
return bookingLinks.selectList(new LambdaQueryWrapper<SailingBookingLinkEntity>()
.eq(SailingBookingLinkEntity::getSailingId, sailingId)
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
.orderByAsc(SailingBookingLinkEntity::getLinkedAt)
.orderByAsc(SailingBookingLinkEntity::getId));
}
public int revokeBookingLink(long id, long operatorId, String reason, java.time.LocalDateTime at) {
return bookingLinks.update(null, new LambdaUpdateWrapper<SailingBookingLinkEntity>()
.eq(SailingBookingLinkEntity::getId, id)
.eq(SailingBookingLinkEntity::getLinkStatus, "ACTIVE")
.set(SailingBookingLinkEntity::getLinkStatus, "REVOKED")
.set(SailingBookingLinkEntity::getActiveBookingGuard, null)
.set(SailingBookingLinkEntity::getRevokedAt, at)
.set(SailingBookingLinkEntity::getOperatorId, operatorId)
.set(SailingBookingLinkEntity::getReason, reason));
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ChannelQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
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.util.List;
@Mapper
public interface SailingChannelMapper extends BaseMapper<SailingChannelEntity> {
@Select("""
<script>SELECT * FROM fl_sailing_channel
WHERE (#{query.keyword} IS NULL OR channel_code LIKE CONCAT('%',#{query.keyword},'%') OR channel_name LIKE CONCAT('%',#{query.keyword},'%'))
AND (#{query.status} IS NULL OR status=#{query.status})
ORDER BY status ASC,channel_name ASC,id ASC</script>
""")
List<SailingChannelEntity> selectList(@Param("query") ChannelQuery query);
@Update("""
UPDATE fl_sailing_channel SET channel_code=#{value.channelCode},channel_name=#{value.channelName},
source_type=#{value.sourceType},mapped_channel_code=#{value.mappedChannelCode},
default_departure_port_code=#{value.defaultDeparturePortCode},default_destination_port_code=#{value.defaultDestinationPortCode},
default_cutoff_days=#{value.defaultCutoffDays},
remark=#{value.remark},status=#{value.status},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND version=#{expectedVersion}
""")
int updateChannel(@Param("value") SailingChannelEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SailingHistoryMapper extends BaseMapper<SailingHistoryEntity> {}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
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.util.List;
@Mapper
public interface SailingMapper extends BaseMapper<SailingEntity> {
@Select("""
<script>SELECT * FROM fl_sailing
WHERE etd_at &gt;= #{startAt} AND etd_at &lt; #{endAt}
AND (#{query.warehouseId} IS NULL OR warehouse_id=#{query.warehouseId})
AND (#{query.channelId} IS NULL OR channel_id=#{query.channelId})
AND (#{query.channelCode} IS NULL OR channel_code_snapshot=#{query.channelCode})
AND (#{query.departurePortCode} IS NULL OR departure_port_code=#{query.departurePortCode})
AND (#{query.destinationPortCode} IS NULL OR destination_port_code=#{query.destinationPortCode})
AND (#{query.status} IS NULL OR status=#{query.status})
AND (#{query.keyword} IS NULL OR sailing_no LIKE CONCAT('%',#{query.keyword},'%')
OR vessel_name LIKE CONCAT('%',#{query.keyword},'%') OR voyage_no LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY etd_at ASC,sailing_no ASC,id ASC</script>
""")
List<SailingEntity> selectCalendar(@Param("query") SailingQuery query,
@Param("startAt") java.time.LocalDateTime startAt,
@Param("endAt") java.time.LocalDateTime endAt);
@Select("""
SELECT * FROM fl_sailing
WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode}
AND (#{departurePortCode} IS NULL OR departure_port_code=#{departurePortCode})
AND (#{destinationPortCode} IS NULL OR destination_port_code=#{destinationPortCode})
AND status='OPEN' AND etd_at >= #{from} AND warehouse_cutoff_at >= #{from}
ORDER BY etd_at ASC,sailing_no ASC,id ASC
""")
List<SailingEntity> selectFutureCandidates(@Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode,
@Param("departurePortCode") String departurePortCode,
@Param("destinationPortCode") String destinationPortCode,
@Param("from") java.time.LocalDateTime from);
@Select("""
SELECT * FROM fl_sailing
WHERE warehouse_id=#{warehouseId} AND channel_code_snapshot=#{channelCode}
AND (#{departurePortCode} IS NULL OR departure_port_code=#{departurePortCode})
AND (#{destinationPortCode} IS NULL OR destination_port_code=#{destinationPortCode})
AND status='OPEN' AND etd_at >= #{from}
ORDER BY etd_at ASC,sailing_no ASC,id ASC
""")
List<SailingEntity> selectOpenSailingsFrom(@Param("warehouseId") long warehouseId,
@Param("channelCode") String channelCode,
@Param("departurePortCode") String departurePortCode,
@Param("destinationPortCode") String destinationPortCode,
@Param("from") java.time.LocalDateTime from);
@Update("""
UPDATE fl_sailing SET channel_id=#{value.channelId},channel_code_snapshot=#{value.channelCodeSnapshot},
channel_name_snapshot=#{value.channelNameSnapshot},warehouse_id=#{value.warehouseId},warehouse_name_snapshot=#{value.warehouseNameSnapshot},
departure_port_code=#{value.departurePortCode},destination_port_code=#{value.destinationPortCode},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
etd_at=#{value.etdAt},eta_at=#{value.etaAt},warehouse_cutoff_at=#{value.warehouseCutoffAt},source_type=#{value.sourceType},
remark=#{value.remark},planned_capacity_cbm=#{value.plannedCapacityCbm},status=#{value.status},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND version=#{expectedVersion}
""")
int updateSailing(@Param("value") SailingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_sailing SET status=#{status},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}")
int updateStatus(@Param("id") long id, @Param("status") String status, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.sailingcalendar.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.TemplateQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface SailingTemplateMapper extends BaseMapper<SailingTemplateEntity> {
@Select("""
<script>SELECT * FROM fl_sailing_template
WHERE (#{query.warehouseId} IS NULL OR warehouse_id=#{query.warehouseId})
AND (#{query.channelId} IS NULL OR channel_id=#{query.channelId})
AND (#{query.status} IS NULL OR status=#{query.status})
ORDER BY updated_at DESC,id DESC</script>
""")
Page<SailingTemplateEntity> selectPage(Page<SailingTemplateEntity> page, @Param("query") TemplateQuery query);
@Update("""
UPDATE fl_sailing_template SET template_name=#{value.templateName},channel_id=#{value.channelId},
channel_code_snapshot=#{value.channelCodeSnapshot},channel_name_snapshot=#{value.channelNameSnapshot},
warehouse_id=#{value.warehouseId},warehouse_name_snapshot=#{value.warehouseNameSnapshot},
departure_port_code=#{value.departurePortCode},destination_port_code=#{value.destinationPortCode},weekday=#{value.weekday},weekday_mask=#{value.weekdayMask},
etd_local_time=#{value.etdLocalTime},cutoff_days=#{value.cutoffDays},cutoff_local_time=#{value.cutoffLocalTime},
valid_from=#{value.validFrom},valid_to=#{value.validTo},voyage_rule=#{value.voyageRule},vessel_name=#{value.vesselName},
status=#{value.status},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND version=#{expectedVersion}
""")
int updateTemplate(@Param("value") SailingTemplateEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -0,0 +1,112 @@
package com.baiyue.firstleg.sailingcalendar.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.ChannelQuery;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.SailingQuery;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.TemplateQuery;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingChannelMapper;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingHistoryMapper;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingMapper;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingTemplateMapper;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
import com.baiyue.firstleg.sailingcalendar.fact.repository.SailingFactsRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class SailingCalendarRepository {
@Resource private SailingChannelMapper channels;
@Resource private SailingTemplateMapper templates;
@Resource private SailingHistoryMapper histories;
@Resource private SailingMapper sailings;
@Resource private SailingFactsRepository facts;
@Resource private OceanBookingRepository oceanBookings;
public List<SailingChannelEntity> channels(ChannelQuery query) { return channels.selectList(query); }
public SailingChannelEntity channel(long id) { return channels.selectById(id); }
public SailingChannelEntity channelByCode(String code) {
return channels.selectOne(new LambdaQueryWrapper<SailingChannelEntity>()
.eq(SailingChannelEntity::getChannelCode, code));
}
public void insert(SailingChannelEntity value) { channels.insert(value); }
public int update(SailingChannelEntity value, int version, long operatorId, String operatorName) {
return channels.updateChannel(value, version, operatorId, operatorName);
}
public PageResult<SailingTemplateEntity> templates(TemplateQuery query) {
Page<SailingTemplateEntity> page = templates.selectPage(new Page<>(query.page(), query.size()), query);
return PageResult.from(page);
}
public SailingTemplateEntity template(long id) { return templates.selectById(id); }
public void insert(SailingTemplateEntity value) { templates.insert(value); }
public int update(SailingTemplateEntity value, int version, long operatorId, String operatorName) {
return templates.updateTemplate(value, version, operatorId, operatorName);
}
public List<SailingEntity> calendar(SailingQuery query, LocalDateTime start, LocalDateTime end) {
return sailings.selectCalendar(query, start, end);
}
public List<SailingEntity> futureCandidates(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) {
return sailings.selectFutureCandidates(warehouseId, channelCode, departurePortCode, destinationPortCode, from);
}
public List<SailingEntity> openSailingsFrom(long warehouseId, String channelCode, String departurePortCode, String destinationPortCode, LocalDateTime from) {
return sailings.selectOpenSailingsFrom(warehouseId, channelCode, departurePortCode, destinationPortCode, from);
}
public SailingEntity sailing(long id) { return sailings.selectById(id); }
public SailingEntity sailingByGeneratedKey(String key) {
return sailings.selectOne(new LambdaQueryWrapper<SailingEntity>().eq(SailingEntity::getGeneratedKey, key));
}
public void insert(SailingEntity value) { sailings.insert(value); }
public int update(SailingEntity value, int version, long operatorId, String operatorName) {
return sailings.updateSailing(value, version, operatorId, operatorName);
}
public int updateStatus(long id, String status, int version, long operatorId, String operatorName) {
return sailings.updateStatus(id, status, version, operatorId, operatorName);
}
public void insertHistory(SailingHistoryEntity value) { histories.insert(value); }
public List<SailingHistoryEntity> histories(long sailingId) {
return histories.selectList(new LambdaQueryWrapper<SailingHistoryEntity>()
.eq(SailingHistoryEntity::getSailingId, sailingId)
.orderByDesc(SailingHistoryEntity::getCreatedAt)
.orderByDesc(SailingHistoryEntity::getId));
}
public List<SailingAssignmentEntity> assignments(long sailingId) { return facts.assignments(sailingId); }
public void insertAssignment(SailingAssignmentEntity value) { facts.insertAssignment(value); }
public List<SailingAssignmentEntity> exceptionAssignments() { return facts.exceptionAssignments(); }
public SailingAssignmentEntity assignmentForUpdate(long id) { return facts.assignmentForUpdate(id); }
public int supersedeAssignment(long id, int expectedVersion, long operatorId, String reason, LocalDateTime at) {
return facts.supersedeAssignment(id, expectedVersion, operatorId, reason, at);
}
public SailingReceiptBatchFactEntity receiptFact(long id) { return facts.receiptFact(id); }
public SailingInboundVolumeForecastEntity forecast(long id) { return facts.forecast(id); }
public void insertForecast(SailingInboundVolumeForecastEntity value) { facts.insertForecast(value); }
public int consumeForecast(long tenantId, long id, LocalDateTime at) { return facts.consumeForecast(tenantId, id, at); }
public int supersedeForecast(long tenantId, long id, LocalDateTime at) { return facts.supersedeForecast(tenantId, id, at); }
public int voidForecast(long tenantId, long id, LocalDateTime at) { return facts.voidForecast(tenantId, id, at); }
public int supersedeForecastAssignments(long tenantId, long id, LocalDateTime at) { return facts.supersedeForecastAssignments(tenantId, id, at); }
public SailingAssignmentEntity currentAssignmentForForecast(long tenantId, long id) { return facts.currentAssignmentForForecast(tenantId, id); }
public SailingAssignmentEntity currentAssignmentForReceipt(long tenantId, long id) { return facts.currentAssignmentForReceipt(tenantId, id); }
public List<SailingBookingLinkEntity> bookingLinks(long sailingId) { return facts.activeBookingLinks(sailingId); }
public SailingBookingLinkEntity activeBookingLink(long oceanBookingId) { return facts.activeBookingLink(oceanBookingId); }
public void insertBookingLink(SailingBookingLinkEntity value) { facts.insertBookingLink(value); }
public int revokeBookingLink(long id, long operatorId, String reason, LocalDateTime at) {
return facts.revokeBookingLink(id, operatorId, reason, at);
}
public OceanBookingEntity booking(long id) { return oceanBookings.find(id); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return oceanBookings.requirements(id); }
}
@@ -0,0 +1,482 @@
package com.baiyue.firstleg.sailingcalendar.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingTemplateEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingHistoryEntity;
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Objects;
import java.util.Map;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.common.core.container.ContainerSpecificationCatalog;
import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*;
@Service
public class SailingCalendarService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final String OPEN = "OPEN";
@Resource private SailingCalendarRepository repository;
public List<ChannelView> channels(ChannelQuery query) {
return repository.channels(query).stream().map(v -> new ChannelView(v, "ACTIVE".equals(v.getStatus()))).toList();
}
@Transactional
public SailingChannelEntity createChannel(long operatorId, String operatorName, ChannelCreateRequest request) {
if (version(request.expectedVersion()) != 0) throw bad("新建渠道expectedVersion必须为0");
validateChannel(request.data());
if (repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在");
SailingChannelEntity value = new SailingChannelEntity(); value.setId(IdWorker.getId());
copy(value, request.data()); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("渠道代码已存在"); }
return value;
}
@Transactional
public SailingChannelEntity updateChannel(long id, long operatorId, String operatorName, ChannelUpdateRequest request) {
SailingChannelEntity before = requiredChannel(id); validateChannel(request.data());
if (!before.getChannelCode().equalsIgnoreCase(request.data().channelCode().trim())
&& repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在");
SailingChannelEntity value = new SailingChannelEntity(); value.setId(id); copy(value, request.data());
value.setStatus(normalizeStatus(request.status(), "渠道状态"));
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("渠道已被其他人修改");
return requiredChannel(id);
}
public PageResult<SailingTemplateEntity> templates(TemplateQuery query) { return repository.templates(query); }
public List<TemplateView> templateViews(TemplateQuery query) { return repository.templates(query).records().stream().map(v -> templateViewOf(v)).toList(); }
public TemplateView templateView(long id) { return templateViewOf(requiredTemplate(id)); }
@Transactional
public SailingTemplateEntity createTemplate(long operatorId, String operatorName, TemplateCreateRequest request) {
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel);
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId());
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
repository.insert(value); return value;
}
@Transactional
public SailingTemplateEntity updateTemplate(long id, long operatorId, String operatorName, TemplateUpdateRequest request) {
requiredTemplate(id); SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel);
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(id); copy(value, request.data(), channel);
value.setStatus(StringUtils.hasText(request.status()) ? normalizeStatus(request.status(), "模板状态") : requiredTemplate(id).getStatus());
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("模板已被其他人修改");
return requiredTemplate(id);
}
public CalendarResult calendar(SailingQuery query) {
LocalDate first;
try { first = LocalDate.parse(query.month() + "-01"); } catch (RuntimeException ex) { throw bad("month必须为yyyy-MM"); }
LocalDate next = first.plusMonths(1);
List<SailingEntity> values = repository.calendar(query, first.atStartOfDay(), next.atStartOfDay());
List<CalendarItem> items = values.stream().map(this::item).toList();
BigDecimal capacity = items.stream().map(v -> v.volumeSummary().planningCapacityCbm() == null ? BigDecimal.ZERO : v.volumeSummary().planningCapacityCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal actual = items.stream().map(v -> v.volumeSummary().actualReceivedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal forecast = items.stream().map(v -> v.volumeSummary().catchableForecastCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal risk = items.stream().map(v -> v.volumeSummary().riskUnmatchedCbm()).reduce(BigDecimal.ZERO, BigDecimal::add);
int booked = items.stream().mapToInt(v -> v.volumeSummary().bookedContainerCount()).sum();
int recommended = items.stream().mapToInt(v -> v.volumeSummary().recommendedContainerCount()).sum();
int pending = items.stream().mapToInt(v -> v.volumeSummary().pendingContainerCount()).sum();
int late = items.stream().mapToInt(v -> v.volumeSummary().lateBatchCount()).sum();
BigDecimal catchableReceived = items.stream().map(v -> nvl(v.volumeSummary().catchableReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal lateReceived = items.stream().map(v -> nvl(v.volumeSummary().lateReceivedCbm())).reduce(BigDecimal.ZERO, BigDecimal::add);
var summary = new CalendarResult.Summary(actual, forecast, risk, booked, recommended, pending, late, items.stream().anyMatch(v -> v.volumeSummary().overCapacity()), capacity, items.size(), catchableReceived, lateReceived);
return new CalendarResult(query.month(), ZONE.getId(), items, summary,
new CalendarResult.FilterOptions(
values.stream().map(v -> new CalendarResult.Option(String.valueOf(v.getWarehouseId()), v.getWarehouseNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getChannelCodeSnapshot(), v.getChannelNameSnapshot())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDeparturePortCode(), v.getDeparturePortCode())).distinct().toList(),
values.stream().map(v -> new CalendarResult.Option(v.getDestinationPortCode(), v.getDestinationPortCode())).distinct().toList()));
}
public SailingDetail detail(long id) {
SailingEntity value = requiredSailing(id);
return new SailingDetail(item(value), history(id));
}
public List<SailingHistoryView> history(long id) {
requiredSailing(id);
return repository.histories(id).stream()
.map(v -> new SailingHistoryView(v.getId(), v.getAction(), v.getFromStatus(), v.getToStatus(), v.getReason(), v.getOperatorName(), v.getCreatedAt()))
.toList();
}
public ExceptionPage exceptions(ExceptionQuery query) {
List<ExceptionRecord> all = repository.exceptionAssignments().stream().map(a -> {
Long sourceId = a.getSailingId() != null ? a.getSailingId() : a.getOriginSailingId();
SailingEntity source = sourceId == null ? null : repository.sailing(sourceId);
LocalDateTime receivedAt = null, expectedAt = null;
if (a.getReceiptBatchId() != null && repository.receiptFact(a.getReceiptBatchId()) != null) receivedAt = repository.receiptFact(a.getReceiptBatchId()).getReceivedAt();
if (a.getForecastId() != null && repository.forecast(a.getForecastId()) != null) expectedAt = repository.forecast(a.getForecastId()).getExpectedArrivalAt();
LocalDateTime eventAt = receivedAt != null ? receivedAt : expectedAt;
Integer waiting = eventAt == null ? null : (int) Math.max(0, ChronoUnit.DAYS.between(eventAt.toLocalDate(), LocalDateTime.now(ZONE).toLocalDate()));
return new ExceptionRecord(a.getId(), a.getInboundCode(), source == null ? null : source.getWarehouseId(), source == null ? null : source.getWarehouseNameSnapshot(), source == null ? null : source.getChannelCodeSnapshot(), source == null ? null : source.getChannelNameSnapshot(), source == null ? null : source.getSailingNo(), source == null ? null : source.getVoyageNo(), a.getMatchStatus(), nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), receivedAt, expectedAt, waiting, a.getReason(), a.getAssignmentVersion());
}).filter(r -> query.status() == null || query.status().equals(r.status()))
.filter(r -> query.warehouseId() == null || Objects.equals(query.warehouseId(), r.warehouseId()))
.filter(r -> query.channelCode() == null || Objects.equals(query.channelCode(), r.channelCode())).toList();
long page = query.page(), size = query.size(), total = all.size(), from = Math.min(total, (page - 1) * size), to = Math.min(total, from + size);
return new ExceptionPage(all.subList((int) from, (int) to), page, size, total, (total + size - 1) / size);
}
@Transactional
public SailingDetail cancel(long id, long operatorId, String operatorName, CancelRequest request) {
SailingEntity before = requiredSailing(id);
if (!Objects.equals(before.getVersion(), version(request.expectedVersion()))) throw conflict("船期已被其他人修改");
if (List.of("DEPARTED", "COMPLETED", "CANCELLED").contains(before.getStatus())) throw conflict("当前船期不可取消");
if (repository.updateStatus(id, "CANCELLED", version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改");
LocalDateTime at = LocalDateTime.now(ZONE); String reason = StringUtils.hasText(request.reason()) ? request.reason().trim() : "CANCELLED";
for (SailingAssignmentEntity current : repository.assignments(id)) {
int assignmentVersion = current.getAssignmentVersion() == null ? 1 : current.getAssignmentVersion();
if (repository.supersedeAssignment(current.getId(), assignmentVersion, operatorId, reason, at) != 1) {
throw conflict("取消船期时归班事实已被其他人修改,操作已回滚,请刷新后重试");
}
SailingAssignmentEntity unmatched = copyAssignment(current); unmatched.setId(IdWorker.getId()); unmatched.setSailingId(null); unmatched.setOriginSailingId(id);
unmatched.setAssignmentSource("ROLLOVER"); unmatched.setAssignmentVersion(assignmentVersion + 1); unmatched.setMatchStatus("UNMATCHED"); unmatched.setMatchedAt(at); unmatched.setReason(reason); unmatched.setOperatorId(operatorId); unmatched.setCreatedAt(at); unmatched.setLockedAt(null); unmatched.setLockReason(null); repository.insertAssignment(unmatched);
}
for (SailingBookingLinkEntity link : repository.bookingLinks(id)) repository.revokeBookingLink(link.getId(), operatorId, reason, at);
recordHistory(id, "CANCEL", before.getStatus(), "CANCELLED", reason, operatorId, operatorName, at);
return detail(id);
}
@Transactional
public ExceptionReassignResult exceptionReassign(long sourceSailingId, long operatorId, String operatorName, ExceptionReassignRequest request) {
SailingEntity source = requiredSailing(sourceSailingId); SailingEntity target = requiredSailing(request.targetSailingId());
if (source.getId().equals(target.getId())) throw bad("目标船期不能与原船期相同");
if (!"OPEN".equals(target.getStatus()) || target.getWarehouseCutoffAt() == null || !target.getWarehouseCutoffAt().isAfter(LocalDateTime.now(ZONE))) throw conflict("目标船期必须为未截止的OPEN状态");
if (!Objects.equals(source.getWarehouseId(), target.getWarehouseId()) || !Objects.equals(source.getChannelCodeSnapshot(), target.getChannelCodeSnapshot())
|| !Objects.equals(source.getDeparturePortCode(), target.getDeparturePortCode()) || !Objects.equals(source.getDestinationPortCode(), target.getDestinationPortCode())) throw conflict("目标船期的仓库、渠道或港口不一致");
if (!Objects.equals(target.getVersion(), version(request.expectedTargetVersion()))) throw conflict("目标船期版本已变化,请刷新后重试");
SailingAssignmentEntity current = repository.assignmentForUpdate(request.assignmentId());
if (current == null) throw notFound("归班事实不存在");
if (!Objects.equals(current.getAssignmentVersion(), version(request.expectedAssignmentVersion()))) throw conflict("归班事实版本已变化,请刷新后重试");
if (!List.of("LATE", "UNMATCHED").contains(current.getMatchStatus())) throw conflict("只有晚到或未安排事实允许异常重新安排");
if (current.getSailingId() != null && !Objects.equals(current.getSailingId(), sourceSailingId)
|| current.getSailingId() == null && !Objects.equals(current.getOriginSailingId(), sourceSailingId)) throw conflict("归班事实不属于原船期");
String newStatus; LocalDateTime eventAt = null;
if (current.getReceiptBatchId() != null) {
var fact = repository.receiptFact(current.getReceiptBatchId()); if (fact == null || !"APPROVED".equals(fact.getFactStatus())) throw conflict("实收事实不可重新安排");
eventAt = fact.getReceivedAt(); if (eventAt == null || eventAt.isAfter(target.getWarehouseCutoffAt())) throw conflict("实收时间晚于目标船期截止时间"); newStatus = "RECEIVED";
} else if (current.getForecastId() != null) {
var fact = repository.forecast(current.getForecastId()); if (fact == null || !"VALID".equals(fact.getFactStatus())) throw conflict("预报事实已失效");
eventAt = fact.getExpectedArrivalAt(); if (eventAt == null || eventAt.isAfter(target.getWarehouseCutoffAt())) throw conflict("预计到仓时间晚于目标船期截止时间"); newStatus = "FORECAST_MATCHED";
} else throw conflict("缺少可核验的实收或预报事实");
LocalDateTime at = LocalDateTime.now(ZONE); if (repository.supersedeAssignment(current.getId(), current.getAssignmentVersion(), operatorId, request.reason().trim(), at) != 1) throw conflict("归班事实已被其他人修改");
SailingAssignmentEntity next = copyAssignment(current); next.setId(IdWorker.getId()); next.setSailingId(target.getId()); next.setOriginSailingId(sourceSailingId); next.setAssignmentSource("MANUAL"); next.setAssignmentVersion(current.getAssignmentVersion() + 1); next.setMatchStatus(newStatus); next.setMatchedAt(at); next.setLockedAt(null); next.setLockReason(null); next.setOperatorId(operatorId); next.setReason(request.reason().trim()); next.setCreatedAt(at); repository.insertAssignment(next);
return new ExceptionReassignResult(current.getId(), next.getId(), sourceSailingId, target.getId(), next.getAssignmentVersion(), newStatus, next.getVolumeCbm(), next.getForecastVolumeCbm(), request.reason().trim());
}
/** 运营维护未收货量预报,并立即生成一条可追溯的自动归班事实。 */
@Transactional
public SailingInboundVolumeForecastEntity createForecast(long operatorId, ForecastCreateRequest request) {
if (request.expectedArrivalAt() == null || request.expectedArrivalAt().isBefore(LocalDateTime.now(ZONE))) {
throw bad("预计到仓时间不能早于当前时间");
}
if (request.forecastVolumeCbm() == null || request.forecastVolumeCbm().signum() <= 0) {
throw bad("预计体积必须大于0");
}
String channelCode = trimUpper(request.channelCode());
SailingChannelEntity channel = StringUtils.hasText(channelCode) ? repository.channelByCode(channelCode) : null;
if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码");
String departurePort = trimUpper(request.departurePortCode());
String destinationPort = trimUpper(request.destinationPortCode());
if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode());
if (!StringUtils.hasText(destinationPort)) destinationPort = trimUpper(channel.getDefaultDestinationPortCode());
SailingInboundVolumeForecastEntity value = new SailingInboundVolumeForecastEntity();
value.setId(IdWorker.getId()); value.setTenantId(request.tenantId()); value.setForecastKey(request.forecastKey().trim());
value.setFactVersion(1); value.setInboundId(request.inboundId()); value.setReceivingWarehouseId(request.receivingWarehouseId());
value.setChannelCode(channelCode); value.setChannelName(trim(request.channelName()));
value.setDeparturePortCode(departurePort); value.setDestinationPortCode(destinationPort);
value.setExpectedArrivalAt(request.expectedArrivalAt()); value.setForecastVolumeCbm(request.forecastVolumeCbm());
value.setSourceType(request.sourceType().trim().toUpperCase()); value.setFactStatus("VALID");
value.setEffectiveAt(LocalDateTime.now(ZONE)); value.setCreatedBy(operatorId); value.setCreatedAt(LocalDateTime.now(ZONE)); value.setUpdatedAt(value.getCreatedAt());
repository.insertForecast(value);
insertForecastAssignment(value, operatorId);
return value;
}
@Transactional
public SailingInboundVolumeForecastEntity reviseForecast(long operatorId, long forecastId, ForecastRevisionRequest request) {
SailingInboundVolumeForecastEntity current = repository.forecast(forecastId);
if (current == null || !Objects.equals(current.getTenantId(), request.tenantId())) throw notFound("货量预报不存在");
if (!"VALID".equals(current.getFactStatus())) throw conflict("只有有效预报可以修订");
if (!Objects.equals(current.getFactVersion(), request.expectedFactVersion())) throw conflict("预报版本已变化,请刷新后重试");
if (request.expectedArrivalAt().isBefore(LocalDateTime.now(ZONE))) throw bad("预计到仓时间不能早于当前时间");
if (request.forecastVolumeCbm().signum() <= 0) throw bad("预计体积必须大于0");
String channelCode = trimUpper(request.channelCode());
if (!StringUtils.hasText(channelCode)) channelCode = current.getChannelCode();
SailingChannelEntity channel = repository.channelByCode(channelCode);
if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码");
String departurePort = trimUpper(request.departurePortCode());
String destinationPort = trimUpper(request.destinationPortCode());
if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode());
if (!StringUtils.hasText(destinationPort)) destinationPort = trimUpper(channel.getDefaultDestinationPortCode());
LocalDateTime now = LocalDateTime.now(ZONE);
if (repository.supersedeForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人修改,请刷新后重试");
repository.supersedeForecastAssignments(request.tenantId(), forecastId, now);
SailingInboundVolumeForecastEntity next = new SailingInboundVolumeForecastEntity();
next.setId(IdWorker.getId()); next.setTenantId(current.getTenantId()); next.setForecastKey(current.getForecastKey());
next.setFactVersion(current.getFactVersion() + 1); next.setInboundId(current.getInboundId()); next.setReceivingWarehouseId(current.getReceivingWarehouseId());
next.setChannelCode(channelCode); next.setChannelName(trim(request.channelName())); next.setDeparturePortCode(departurePort); next.setDestinationPortCode(destinationPort);
next.setExpectedArrivalAt(request.expectedArrivalAt()); next.setForecastVolumeCbm(request.forecastVolumeCbm()); next.setSourceType(request.sourceType().trim().toUpperCase());
next.setFactStatus("VALID"); next.setEffectiveAt(now); next.setCreatedBy(operatorId); next.setCreatedAt(now); next.setUpdatedAt(now);
repository.insertForecast(next); insertForecastAssignment(next, operatorId);
return next;
}
@Transactional
public void voidForecast(long operatorId, long forecastId, ForecastVoidRequest request) {
SailingInboundVolumeForecastEntity current = repository.forecast(forecastId);
if (current == null || !Objects.equals(current.getTenantId(), request.tenantId())) throw notFound("货量预报不存在");
LocalDateTime now = LocalDateTime.now(ZONE);
if (repository.voidForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人修改,请刷新后重试");
repository.supersedeForecastAssignments(request.tenantId(), forecastId, now);
}
@Transactional
public void bindReceiptToForecast(long operatorId, long forecastId, ForecastReceiptBindRequest request) {
SailingInboundVolumeForecastEntity forecast = repository.forecast(forecastId);
SailingReceiptBatchFactEntity receipt = repository.receiptFact(request.receiptBatchId());
if (forecast == null || receipt == null || !Objects.equals(forecast.getTenantId(), request.tenantId())
|| !Objects.equals(receipt.getTenantId(), request.tenantId())) throw notFound("预报或实收批次不存在");
if (!"VALID".equals(forecast.getFactStatus()) || !"APPROVED".equals(receipt.getFactStatus())) throw conflict("预报或实收批次已失效");
if (!Objects.equals(forecast.getInboundId(), receipt.getInboundId())) throw conflict("预报与实收批次不属于同一入仓单");
SailingAssignmentEntity currentReceipt = repository.currentAssignmentForReceipt(request.tenantId(), request.receiptBatchId());
if (currentReceipt == null) throw conflict("实收批次当前没有可绑定的归班版本");
if (!Objects.equals(currentReceipt.getAssignmentVersion(), request.expectedAssignmentVersion())) throw conflict("实收归班版本已变化,请刷新后重试");
SailingAssignmentEntity previousForecast = repository.currentAssignmentForForecast(request.tenantId(), forecastId);
LocalDateTime now = LocalDateTime.now(ZONE);
if (repository.supersedeAssignment(currentReceipt.getId(), currentReceipt.getAssignmentVersion(), operatorId, request.reason().trim(), now) != 1) throw conflict("实收归班已被其他人修改,请刷新后重试");
repository.supersedeForecastAssignments(request.tenantId(), forecastId, now);
if (repository.consumeForecast(request.tenantId(), forecastId, now) != 1) throw conflict("预报已被其他人消费,请刷新后重试");
SailingEntity sailing = previousForecast == null || previousForecast.getSailingId() == null ? null : repository.sailing(previousForecast.getSailingId());
if (sailing != null && "CANCELLED".equals(sailing.getStatus())) sailing = null;
if (sailing == null && StringUtils.hasText(forecast.getDeparturePortCode()) && StringUtils.hasText(forecast.getDestinationPortCode())) {
List<SailingEntity> candidates = repository.openSailingsFrom(forecast.getReceivingWarehouseId(), forecast.getChannelCode(), forecast.getDeparturePortCode(), forecast.getDestinationPortCode(), receipt.getReceivedAt());
sailing = candidates.isEmpty() ? null : candidates.get(0);
}
boolean late = sailing != null && receipt.getReceivedAt().isAfter(sailing.getWarehouseCutoffAt());
SailingAssignmentEntity next = new SailingAssignmentEntity(); next.setId(IdWorker.getId()); next.setTenantId(request.tenantId()); next.setSailingId(sailing == null ? null : sailing.getId());
next.setInboundId(receipt.getInboundId()); next.setReceiptBatchId(receipt.getId()); next.setForecastId(forecast.getId()); next.setAssignmentSource("MANUAL"); next.setAssignmentVersion(currentReceipt.getAssignmentVersion() + 1);
next.setMatchStatus(sailing == null ? "UNMATCHED" : late ? "LATE" : "RECEIVED"); next.setVolumeCbm(receipt.getActualVolumeCbm()); next.setRouteSource("FORECAST_FACT"); next.setVolumeSource("MEASURED_ACTUAL"); next.setRuleVersion("V1_CUTOFF_3D_18");
next.setMatchedAt(now); next.setLockedAt(late ? now : null); next.setLockReason(late ? "CUTOFF" : null); next.setReason(request.reason().trim()); next.setOperatorId(operatorId); next.setCreatedAt(now); repository.insertAssignment(next);
BigDecimal remaining = forecast.getForecastVolumeCbm().subtract(receipt.getActualVolumeCbm());
if (remaining.signum() > 0) insertResidualForecast(forecast, remaining, operatorId, now);
}
private void insertForecastAssignment(SailingInboundVolumeForecastEntity value, long operatorId) {
LocalDateTime now = LocalDateTime.now(ZONE);
List<SailingEntity> candidates = StringUtils.hasText(value.getDeparturePortCode()) && StringUtils.hasText(value.getDestinationPortCode())
? repository.futureCandidates(value.getReceivingWarehouseId(), value.getChannelCode(), value.getDeparturePortCode(), value.getDestinationPortCode(), value.getExpectedArrivalAt()) : List.of();
SailingAssignmentEntity assignment = new SailingAssignmentEntity(); assignment.setId(IdWorker.getId()); assignment.setTenantId(value.getTenantId()); assignment.setSailingId(candidates.isEmpty() ? null : candidates.get(0).getId());
assignment.setInboundId(value.getInboundId()); assignment.setForecastId(value.getId()); assignment.setAssignmentSource("AUTO"); assignment.setAssignmentVersion(1); assignment.setMatchStatus(candidates.isEmpty() ? "UNMATCHED" : "FORECAST_MATCHED"); assignment.setForecastVolumeCbm(value.getForecastVolumeCbm());
assignment.setRouteSource("FORECAST_FACT"); assignment.setVolumeSource(value.getSourceType()); assignment.setRuleVersion("V1_CUTOFF_3D_18"); assignment.setMatchedAt(now); assignment.setReason(candidates.isEmpty() ? "没有找到截止前的OPEN船期" : null); assignment.setOperatorId(operatorId); assignment.setCreatedAt(now); repository.insertAssignment(assignment);
}
private void insertResidualForecast(SailingInboundVolumeForecastEntity forecast, BigDecimal remaining, long operatorId, LocalDateTime now) {
SailingInboundVolumeForecastEntity residual = new SailingInboundVolumeForecastEntity(); residual.setId(IdWorker.getId()); residual.setTenantId(forecast.getTenantId()); residual.setForecastKey(forecast.getForecastKey()); residual.setFactVersion(forecast.getFactVersion() + 1); residual.setInboundId(forecast.getInboundId());
residual.setReceivingWarehouseId(forecast.getReceivingWarehouseId()); residual.setChannelCode(forecast.getChannelCode()); residual.setChannelName(forecast.getChannelName()); residual.setDeparturePortCode(forecast.getDeparturePortCode()); residual.setDestinationPortCode(forecast.getDestinationPortCode()); residual.setExpectedArrivalAt(forecast.getExpectedArrivalAt()); residual.setForecastVolumeCbm(remaining); residual.setSourceType(forecast.getSourceType()); residual.setFactStatus("VALID"); residual.setEffectiveAt(now); residual.setCreatedBy(operatorId); residual.setCreatedAt(now); residual.setUpdatedAt(now); repository.insertForecast(residual); insertForecastAssignment(residual, operatorId);
}
@Transactional
public SailingDetail copy(long id, long operatorId, String operatorName, CopyRequest request) {
SailingEntity source = requiredSailing(id); if (request.etdAt().isBefore(LocalDateTime.now(ZONE))) throw bad("复制船期不能早于当前时间");
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(source.getEtaAt()); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "COPY", null, OPEN, "复制自" + source.getSailingNo(), operatorId, operatorName, value.getCreatedAt()); return detail(value.getId());
}
public VolumeDetail volumeSummary(long id) {
SailingEntity value = requiredSailing(id);
List<AssignmentView> assignments = repository.assignments(id).stream().map(a -> {
LocalDateTime receivedAt = a.getReceiptBatchId() == null || repository.receiptFact(a.getReceiptBatchId()) == null
? null : repository.receiptFact(a.getReceiptBatchId()).getReceivedAt();
return new AssignmentView(String.valueOf(a.getId()), a.getReceiptBatchId() == null ? null : String.valueOf(a.getReceiptBatchId()), a.getForecastId() == null ? null : String.valueOf(a.getForecastId()), a.getInboundCode(), receivedAt, nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())), a.getMatchStatus(), a.getAssignmentSource(), a.getReason(), a.getAssignmentVersion());
}).toList();
return new VolumeDetail(summary(value), assignments);
}
public BookingAdvice bookingAdvice(long id) {
SailingEntity value = requiredSailing(id); VolumeSummary s = summary(value);
BigDecimal catchable = catchableVolume(value.getId());
int recommended = recommendedContainers(catchable);
BigDecimal bookedCapacity = bookedPlanningCapacity(value.getId());
int pending = ceil(nvl(catchable.subtract(bookedCapacity)), defaultContainerCapacity());
List<BookingScenario> scenarios = ContainerSpecificationCatalog.values().stream().map(spec -> {
int count = ceil(catchable, spec.capacityCbm());
int pendingForType = ceil(nvl(catchable.subtract(bookedCapacity)), spec.capacityCbm());
return new BookingScenario(spec.containerType(), spec.containerType(), spec.capacityCbm(), count, pendingForType);
}).toList();
return new BookingAdvice(s.actualReceivedCbm(), catchable, s.planningCapacityCbm(), s.bookedContainerCount(), recommended, pending, scenarios, LocalDateTime.now(ZONE), s.catchableReceivedCbm(), s.lateReceivedCbm());
}
@Transactional
public Map<String, Object> linkBooking(long id, long operatorId, String operatorName, BookingLinkRequest request) {
SailingEntity sailing = requiredSailing(id);
var booking = repository.booking(request.oceanBookingId());
if (booking == null) throw notFound("订舱不存在");
if (!"RELEASED".equals(booking.getStatus())) throw conflict("只有RELEASED订舱可以关联船期");
if (repository.activeBookingLink(request.oceanBookingId()) != null) throw conflict("订舱已经关联其他船期");
if (!Objects.equals(sailing.getWarehouseId(), booking.getWarehouseId()) || !Objects.equals(sailing.getChannelCodeSnapshot(), booking.getChannelCode())
|| !Objects.equals(sailing.getDeparturePortCode(), booking.getDeparturePortCode()) || !Objects.equals(sailing.getDestinationPortCode(), booking.getDestinationPortCode())) throw conflict("订舱的仓库、渠道或港口与船期不一致");
SailingBookingLinkEntity value = new SailingBookingLinkEntity(); value.setId(IdWorker.getId()); value.setTenantId(booking.getCustomerId() == null ? 0L : booking.getCustomerId()); value.setSailingId(id); value.setOceanBookingId(request.oceanBookingId()); value.setLinkStatus("ACTIVE"); value.setActiveBookingGuard(1); value.setBookingStatusSnapshot(booking.getStatus()); value.setWarehouseId(booking.getWarehouseId()); value.setChannelCode(booking.getChannelCode()); value.setDeparturePortCode(booking.getDeparturePortCode()); value.setDestinationPortCode(booking.getDestinationPortCode()); value.setLinkedAt(LocalDateTime.now(ZONE)); value.setOperatorId(operatorId); value.setOperatorName(operatorName); repository.insertBookingLink(value);
int booked = repository.requirements(booking.getId()).stream().mapToInt(v -> v.getQuantity() == null ? 0 : v.getQuantity()).sum();
return Map.of("id", value.getId(), "sailingId", id, "oceanBookingId", request.oceanBookingId(), "status", value.getLinkStatus(), "bookedContainerCount", booked);
}
@Transactional
public SailingEntity createSailing(long operatorId, String operatorName, SailingCreateRequest request) {
if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0");
validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期");
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId());
copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType()));
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); }
recordHistory(value.getId(), "CREATE", null, OPEN, "新增船期", operatorId, operatorName, value.getCreatedAt());
return value;
}
@Transactional
public SailingEntity updateSailing(long id, long operatorId, String operatorName, SailingUpdateRequest request) {
SailingEntity before = requiredSailing(id); validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data());
if (!Objects.equals(before.getStatus(), "OPEN") && !Objects.equals(before.getStatus(), "DRAFT")) throw conflict("当前船期状态不可编辑");
SailingEntity value = new SailingEntity(); value.setId(id); copy(value, request.data(), channel); value.setStatus(StringUtils.hasText(request.status()) ? normalizeSailingStatus(request.status()) : before.getStatus());
value.setSourceType(normalizeSource(request.data().sourceType()));
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改");
recordHistory(id, "EDIT", before.getStatus(), value.getStatus(), "编辑船期", operatorId, operatorName, LocalDateTime.now(ZONE));
return requiredSailing(id);
}
public List<GenerationPreview> generationPreview(long templateId, LocalDate from, LocalDate to) {
SailingTemplateEntity template = requiredTemplate(templateId); if (!from.isBefore(to.plusDays(1))) throw bad("日期范围无效");
java.util.ArrayList<GenerationPreview> result = new java.util.ArrayList<>();
for (LocalDate date = from; !date.isAfter(to); date = date.plusDays(1)) if (matchesWeekday(template, date)
&& !date.isBefore(template.getValidFrom()) && (template.getValidTo() == null || !date.isAfter(template.getValidTo()))) {
LocalDateTime etd = LocalDateTime.of(date, template.getEtdLocalTime()); LocalDateTime cutoff = cutoff(template, etd);
String voyage = voyage(template, date); String key = key(template, etd, voyage);
result.add(new GenerationPreview(date, etd, cutoff, voyage, repository.sailingByGeneratedKey(key) != null, ""));
}
return result;
}
@Transactional
public List<SailingEntity> generate(long operatorId, String operatorName, long templateId, LocalDate from, LocalDate to) {
SailingTemplateEntity template = requiredTemplate(templateId); if (!"ACTIVE".equals(template.getStatus())) throw conflict("停用模板不能生成船期");
SailingChannelEntity channel = requiredChannel(template.getChannelId());
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能生成船期");
validateTemplateSnapshot(template);
List<SailingEntity> result = new java.util.ArrayList<>();
for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) {
SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId);
value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName());
value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value);
}
return result;
}
private void recordHistory(long sailingId, String action, String fromStatus, String toStatus, String reason, long operatorId, String operatorName, LocalDateTime at) {
SailingHistoryEntity history = new SailingHistoryEntity(); history.setId(IdWorker.getId()); history.setSailingId(sailingId); history.setAction(action); history.setFromStatus(fromStatus); history.setToStatus(toStatus); history.setReason(reason); history.setOperatorId(operatorId); history.setOperatorName(operatorName); history.setCreatedAt(at); repository.insertHistory(history);
}
private CalendarItem item(SailingEntity v) { return new CalendarItem(v.getId(), v.getSailingNo(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getVesselName(), v.getVoyageNo(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getEtdAt(), v.getWarehouseCutoffAt(), v.getStatus(), v.getVersion(), summary(v)); }
private VolumeSummary summary(SailingEntity v) {
BigDecimal actual = BigDecimal.ZERO, eligibleActual = BigDecimal.ZERO, lateActual = BigDecimal.ZERO, forecast = BigDecimal.ZERO, risk = BigDecimal.ZERO; int late = 0;
for (SailingAssignmentEntity a : repository.assignments(v.getId())) {
if ("RECEIVED".equals(a.getMatchStatus()) || "LATE".equals(a.getMatchStatus())) actual = actual.add(nvl(a.getVolumeCbm()));
if ("RECEIVED".equals(a.getMatchStatus())) eligibleActual = eligibleActual.add(nvl(a.getVolumeCbm()));
if ("LATE".equals(a.getMatchStatus())) lateActual = lateActual.add(nvl(a.getVolumeCbm()));
if ("FORECAST_MATCHED".equals(a.getMatchStatus())) forecast = forecast.add(nvl(a.getForecastVolumeCbm()));
if ("UNMATCHED".equals(a.getMatchStatus()) || "LATE".equals(a.getMatchStatus())) risk = risk.add(nvl(a.getVolumeCbm()).add(nvl(a.getForecastVolumeCbm())));
if ("LATE".equals(a.getMatchStatus())) late++;
}
BookingTotals bookedTotals = bookingTotals(v.getId());
BigDecimal capacity = v.getPlannedCapacityCbm(); BigDecimal catchable = catchableVolume(v.getId());
boolean over = capacity != null && catchable.compareTo(capacity) > 0;
int recommended = recommendedContainers(catchable);
int pending = ceil(nvl(catchable.subtract(bookedTotals.capacityCbm())), defaultContainerCapacity());
return new VolumeSummary(actual, forecast, risk, bookedTotals.containerCount(), recommended, pending, late, over, capacity, bookedTotals.capacityCbm(), LocalDateTime.now(ZONE), eligibleActual, lateActual);
}
private static int recommendedContainers(BigDecimal volume) { return ceil(volume, defaultContainerCapacity()); }
private static BigDecimal defaultContainerCapacity() {
return ContainerSpecificationCatalog.values().stream()
.filter(spec -> ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE.equals(spec.containerType()))
.map(ContainerSpecificationCatalog.Specification::capacityCbm)
.findFirst()
.orElseThrow(() -> new IllegalStateException("默认柜型未配置"));
}
private BigDecimal catchableVolume(long sailingId) { return repository.assignments(sailingId).stream().map(a -> "RECEIVED".equals(a.getMatchStatus()) ? nvl(a.getVolumeCbm()) : "FORECAST_MATCHED".equals(a.getMatchStatus()) ? nvl(a.getForecastVolumeCbm()) : BigDecimal.ZERO).reduce(BigDecimal.ZERO, BigDecimal::add); }
private BigDecimal bookedPlanningCapacity(long sailingId) { return bookingTotals(sailingId).capacityCbm(); }
private BookingTotals bookingTotals(long sailingId) {
int count = 0; BigDecimal capacity = BigDecimal.ZERO;
for (SailingBookingLinkEntity link : repository.bookingLinks(sailingId)) {
OceanBookingEntity booking = repository.booking(link.getOceanBookingId());
if (booking == null || !"RELEASED".equals(booking.getStatus())) continue;
for (OceanBookingContainerRequirementEntity r : repository.requirements(link.getOceanBookingId())) {
int quantity = r.getQuantity() == null ? 0 : r.getQuantity(); count += quantity;
if (r.getCapacityCbm() != null) capacity = capacity.add(r.getCapacityCbm().multiply(BigDecimal.valueOf(quantity)));
}
}
return new BookingTotals(count, capacity);
}
private record BookingTotals(int containerCount, BigDecimal capacityCbm) {}
private static int ceil(BigDecimal volume, BigDecimal unit) { return volume == null || volume.signum() <= 0 ? 0 : volume.divide(unit, 0, java.math.RoundingMode.CEILING).intValue(); }
private static BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; }
private static void copy(SailingChannelEntity v, ChannelData d) { v.setChannelCode(d.channelCode().trim().toUpperCase()); v.setChannelName(d.channelName().trim()); v.setSourceType(d.sourceType().trim().toUpperCase()); v.setMappedChannelCode(trimUpper(d.mappedChannelCode())); v.setDefaultDeparturePortCode(trimUpper(d.defaultDeparturePortCode())); v.setDefaultDestinationPortCode(trimUpper(d.defaultDestinationPortCode())); v.setDefaultCutoffDays(d.defaultCutoffDays() == null ? 3 : d.defaultCutoffDays()); v.setRemark(trim(d.remark())); }
private static void copy(SailingTemplateEntity v, TemplateData d, SailingChannelEntity c) {
v.setTemplateName(StringUtils.hasText(d.templateName()) ? d.templateName().trim() : c.getChannelName()); v.setChannelId(c.getId()); v.setChannelCodeSnapshot(c.getChannelCode()); v.setChannelNameSnapshot(c.getChannelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseNameSnapshot(trim(d.warehouseName()));
v.setDeparturePortCode(trimUpper(StringUtils.hasText(d.departurePortCode()) ? d.departurePortCode() : c.getDefaultDeparturePortCode())); v.setDestinationPortCode(trimUpper(StringUtils.hasText(d.destinationPortCode()) ? d.destinationPortCode() : c.getDefaultDestinationPortCode()));
int mask = d.weekdays() == null || d.weekdays().isEmpty() ? 1 : d.weekdays().stream().mapToInt(day -> 1 << (day - 1)).reduce(0, (a, b) -> a | b);
v.setWeekday(Integer.numberOfTrailingZeros(mask) + 1); v.setWeekdayMask(mask); v.setEtdLocalTime(d.departureTime()); v.setCutoffDays(d.cutoffDays()); v.setCutoffLocalTime(d.cutoffLocalTime()); v.setValidFrom(d.effectiveFrom()); v.setValidTo(d.effectiveTo()); v.setVoyageRule(trim(d.voyageRule())); v.setVesselName(trim(d.vesselName())); v.setRemark(trim(d.remark()));
}
private static void copy(SailingEntity v, SailingData d, SailingChannelEntity c) { v.setTemplateId(d.templateId()); v.setChannelId(c.getId()); v.setChannelCodeSnapshot(c.getChannelCode()); v.setChannelNameSnapshot(c.getChannelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseNameSnapshot(trim(d.warehouseName())); v.setDeparturePortCode(d.departurePortCode().trim().toUpperCase()); v.setDestinationPortCode(d.destinationPortCode().trim().toUpperCase()); v.setVesselName(d.vesselName().trim()); v.setVoyageNo(d.voyageNo().trim()); v.setEtdAt(d.etdAt()); v.setEtaAt(d.etaAt()); v.setWarehouseCutoffAt(d.cutoffAt()); v.setPlannedCapacityCbm(d.plannedCapacityCbm()); v.setRemark(trim(d.remark())); }
private static SailingAssignmentEntity copyAssignment(SailingAssignmentEntity source) { SailingAssignmentEntity target = new SailingAssignmentEntity(); target.setTenantId(source.getTenantId()); target.setOriginSailingId(source.getOriginSailingId()); target.setInboundId(source.getInboundId()); target.setInboundCode(source.getInboundCode()); target.setReceiptBatchId(source.getReceiptBatchId()); target.setForecastId(source.getForecastId()); target.setVolumeCbm(source.getVolumeCbm()); target.setForecastVolumeCbm(source.getForecastVolumeCbm()); target.setRouteSource(source.getRouteSource()); target.setVolumeSource(source.getVolumeSource()); target.setRuleVersion(source.getRuleVersion()); return target; }
private static void validateChannel(ChannelData d) { if (!List.of("CATALOG", "SCHEDULE_ONLY").contains(d.sourceType().trim().toUpperCase())) throw bad("sourceType仅支持CATALOG或SCHEDULE_ONLY"); }
private static void validateTemplate(TemplateData d, SailingChannelEntity channel) {
if (d.effectiveFrom() == null || d.departureTime() == null || d.cutoffDays() == null || d.cutoffLocalTime() == null) throw bad("模板必须填写生效日期、开船时间和截止规则");
if (d.effectiveTo() != null && d.effectiveTo().isBefore(d.effectiveFrom())) throw bad("模板结束日期不能早于开始日期");
if (d.weekdays() == null || d.weekdays().isEmpty() || d.weekdays().stream().anyMatch(day -> day == null || day < 1 || day > 7)) throw bad("模板至少选择一个有效星期几");
if (!StringUtils.hasText(d.vesselName())) throw bad("模板必须填写船名");
if (!StringUtils.hasText(d.departurePortCode()) && !StringUtils.hasText(channel.getDefaultDeparturePortCode())) throw bad("模板必须填写起运港");
if (!StringUtils.hasText(d.destinationPortCode()) && !StringUtils.hasText(channel.getDefaultDestinationPortCode())) throw bad("模板必须填写目的港");
if (d.cutoffDays() == 0 && !d.cutoffLocalTime().isBefore(d.departureTime())) throw bad("同日收货截止时刻必须早于开船时间");
}
private static void validateTemplateSnapshot(SailingTemplateEntity t) {
if (!StringUtils.hasText(t.getVesselName()) || !StringUtils.hasText(t.getDeparturePortCode()) || !StringUtils.hasText(t.getDestinationPortCode())
|| t.getEtdLocalTime() == null || t.getCutoffDays() == null || t.getCutoffLocalTime() == null || t.getValidFrom() == null) throw bad("模板快照字段不完整,不能生成船期");
if (t.getCutoffDays() == 0 && !t.getCutoffLocalTime().isBefore(t.getEtdLocalTime())) throw bad("同日收货截止时刻必须早于开船时间");
}
private static void validateSailing(SailingData d) {
if (d.etaAt() != null && d.etaAt().isBefore(d.etdAt())) throw bad("ETA不能早于ETD");
if (d.cutoffAt() == null || !d.cutoffAt().isBefore(d.etdAt())) throw bad("收货截止必须早于开船时间");
}
private SailingChannelEntity resolveChannel(SailingData d) { if (d.channelId() != null) return requiredChannel(d.channelId()); SailingChannelEntity v = repository.channelByCode(d.channelCode().trim().toUpperCase()); if (v == null) throw notFound("渠道不存在"); return v; }
private SailingChannelEntity requiredChannel(long id) { SailingChannelEntity v = repository.channel(id); if (v == null) throw notFound("渠道不存在"); return v; }
private SailingTemplateEntity requiredTemplate(long id) { SailingTemplateEntity v = repository.template(id); if (v == null) throw notFound("船期模板不存在"); return v; }
private SailingEntity requiredSailing(long id) { SailingEntity v = repository.sailing(id); if (v == null) throw notFound("船期不存在"); return v; }
private static LocalDateTime cutoff(SailingTemplateEntity t, LocalDateTime etd) { return etd.toLocalDate().minusDays(t.getCutoffDays()).atTime(t.getCutoffLocalTime()); }
private static String voyage(SailingTemplateEntity t, LocalDate date) { return StringUtils.hasText(t.getVoyageRule()) ? t.getVoyageRule().replace("{date}", date.toString().replace("-", "")) : "V" + date.toString().replace("-", ""); }
private static String key(SailingTemplateEntity t, LocalDateTime etd, String voyage) { return t.getId() + "|" + etd + "|" + voyage; }
private static boolean matchesWeekday(SailingTemplateEntity t, LocalDate date) { int mask = t.getWeekdayMask() == null ? 1 << ((t.getWeekday() == null ? 1 : t.getWeekday()) - 1) : t.getWeekdayMask(); return (mask & (1 << (date.getDayOfWeek().getValue() - 1))) != 0; }
private static TemplateView templateViewOf(SailingTemplateEntity v) { int mask = v.getWeekdayMask() == null ? 1 << ((v.getWeekday() == null ? 1 : v.getWeekday()) - 1) : v.getWeekdayMask(); List<Integer> days = java.util.stream.IntStream.rangeClosed(1, 7).filter(day -> (mask & (1 << (day - 1))) != 0).boxed().toList(); return new TemplateView(v.getId(), v.getTemplateNo(), v.getTemplateName(), v.getChannelId(), v.getChannelCodeSnapshot(), v.getChannelNameSnapshot(), v.getWarehouseId(), v.getWarehouseNameSnapshot(), days, v.getValidFrom(), v.getValidTo(), v.getEtdLocalTime(), v.getCutoffDays(), v.getCutoffLocalTime(), v.getVoyageRule(), v.getVesselName(), v.getDeparturePortCode(), v.getDestinationPortCode(), v.getRemark(), v.getStatus(), v.getVersion()); }
private static void now(SailingChannelEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); }
private static void now(SailingTemplateEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); }
private static void now(SailingEntity v) { LocalDateTime n = LocalDateTime.now(ZONE); v.setCreatedAt(n); v.setUpdatedAt(n); }
private static String normalizeSource(String v) { return StringUtils.hasText(v) ? v.trim().toUpperCase() : "MANUAL"; }
private static int version(Integer v) { return v == null ? 0 : v; }
private static String normalizeStatus(String value, String label) { String v = value.trim().toUpperCase(); if (!List.of("ACTIVE", "INACTIVE", "DRAFT").contains(v)) throw bad(label + "无效"); return v; }
private static String normalizeSailingStatus(String value) { String v = value.trim().toUpperCase(); if (!List.of("DRAFT", "OPEN", "CLOSED", "DEPARTED", "CANCELLED", "COMPLETED").contains(v)) throw bad("船期状态无效"); return v; }
private static String trim(String v) { if (v == null) return null; String x = v.trim(); return x.isEmpty() ? null : x; }
private static String trimUpper(String v) { String x = trim(v); return x == null ? null : x.toUpperCase(); }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException notFound(String message) { return new BizException(ResultCode.NOT_FOUND, message); }
}
@@ -13,12 +13,21 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.MeasureReviewRecordEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import com.baiyue.firstleg.sailingcalendar.entity.SailingChannelEntity;
import com.baiyue.firstleg.sailingcalendar.entity.SailingEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.fact.repository.SailingFactsRepository;
import com.baiyue.firstleg.sailingcalendar.repository.SailingCalendarRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.List;
/**
* 测量审核应用服务。审核通过后直接进入 WAIT_SPLIT,费用初算和人工维护不阻塞主流程。
@@ -35,6 +44,10 @@ public class MeasurementReviewService {
private InboundProperties properties;
@Resource
private DropshipPalletizationService dropshipPalletization;
@Resource
private SailingFactsRepository sailingFacts;
@Resource
private SailingCalendarRepository sailingCalendar;
@Transactional
public void approve(long tenantId, long operatorId, String operatorName, long inboundId,
@@ -150,9 +163,101 @@ public class MeasurementReviewService {
repository.insertHistory(history(tenantId, inboundId, "MEASURE_PENDING",
approved ? approvedStatus : "MEASURE_REJECTED",
approved ? "MEASURE_APPROVE" : "MEASURE_REJECT", request.reason(), operatorId, operatorName));
if (approved) {
recordSailingReceiptFact(order, measurement, operatorId);
}
idempotency.complete(context, inboundId);
}
private void recordSailingReceiptFact(InboundOrderEntity order, InboundMeasurementEntity measurement, long operatorId) {
// measuredAt is the warehouse receiving batch timestamp captured by the PDA/API.
// Review time and CURRENT_TIMESTAMP are not valid substitutes because they can
// move a batch across a sailing cutoff.
LocalDateTime receivedAt = measurement.getMeasuredAt();
if (receivedAt == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "收货批次缺少实际到仓时间,不能生成船期实收事实");
}
SailingReceiptBatchFactEntity fact = new SailingReceiptBatchFactEntity();
fact.setId(IdWorker.getId()); fact.setTenantId(order.getTenantId());
fact.setReceiptBatchKey("INBOUND-" + order.getId() + "-MEASUREMENT-" + measurement.getId()); fact.setFactVersion(1);
fact.setInboundId(order.getId()); fact.setMeasurementId(measurement.getId()); fact.setReceivingWarehouseId(order.getWarehouseId());
fact.setChannelCode(order.getChannelCode()); fact.setChannelName(order.getChannelName()); fact.setReceivedAt(receivedAt);
fact.setActualVolumeCbm(order.getActualVolumeCbm()); fact.setFactStatus("APPROVED"); fact.setSourceType("FIRSTLEG_MEASUREMENT_REVIEW");
LocalDateTime now = LocalDateTime.now();
fact.setCreatedBy(operatorId); fact.setCreatedAt(now); fact.setUpdatedAt(now);
SailingChannelEntity channel = order.getChannelCode() == null ? null : sailingCalendar.channelByCode(order.getChannelCode().trim().toUpperCase());
if (channel != null) { fact.setDeparturePortCode(channel.getDefaultDeparturePortCode()); fact.setDestinationPortCode(channel.getDefaultDestinationPortCode()); }
sailingFacts.insertReceiptFact(fact);
List<SailingInboundVolumeForecastEntity> forecasts = sailingFacts.currentForecasts(order.getTenantId(), order.getId());
SailingInboundVolumeForecastEntity forecast = selectForecastForReceipt(forecasts, measurement, order.getActualVolumeCbm());
boolean ambiguousForecast = forecast == null && forecasts.size() > 1;
SailingAssignmentEntity forecastAssignment = forecast == null
? null : sailingFacts.currentAssignmentForForecast(order.getTenantId(), forecast.getId());
if (forecast != null) {
sailingFacts.supersedeForecastAssignments(order.getTenantId(), forecast.getId(), now);
if (sailingFacts.consumeForecast(order.getTenantId(), forecast.getId(), now) != 1) {
throw new BizException(ResultCode.CONFLICT, "预报批次已被其他收货事件消费,请刷新后重试");
}
BigDecimal remaining = forecast.getForecastVolumeCbm().subtract(order.getActualVolumeCbm());
if (remaining.signum() > 0) {
SailingInboundVolumeForecastEntity residual = new SailingInboundVolumeForecastEntity();
residual.setId(IdWorker.getId()); residual.setTenantId(forecast.getTenantId()); residual.setForecastKey(forecast.getForecastKey());
residual.setFactVersion((forecast.getFactVersion() == null ? 1 : forecast.getFactVersion()) + 1); residual.setInboundId(forecast.getInboundId());
residual.setReceivingWarehouseId(forecast.getReceivingWarehouseId()); residual.setChannelCode(forecast.getChannelCode()); residual.setChannelName(forecast.getChannelName());
residual.setDeparturePortCode(forecast.getDeparturePortCode()); residual.setDestinationPortCode(forecast.getDestinationPortCode()); residual.setExpectedArrivalAt(forecast.getExpectedArrivalAt());
residual.setForecastVolumeCbm(remaining); residual.setSourceType(forecast.getSourceType()); residual.setFactStatus("VALID"); residual.setEffectiveAt(now);
residual.setCreatedBy(operatorId); residual.setCreatedAt(now); residual.setUpdatedAt(now); sailingFacts.insertForecast(residual);
List<SailingEntity> residualCandidates = residual.getDeparturePortCode() != null && residual.getDestinationPortCode() != null
? sailingCalendar.futureCandidates(residual.getReceivingWarehouseId(), residual.getChannelCode(), residual.getDeparturePortCode(), residual.getDestinationPortCode(), residual.getExpectedArrivalAt())
: List.of();
SailingAssignmentEntity residualAssignment = new SailingAssignmentEntity(); residualAssignment.setId(IdWorker.getId()); residualAssignment.setTenantId(residual.getTenantId());
residualAssignment.setSailingId(residualCandidates.isEmpty() ? null : residualCandidates.get(0).getId()); residualAssignment.setInboundId(residual.getInboundId()); residualAssignment.setInboundCode(order.getInboundCode()); residualAssignment.setForecastId(residual.getId()); residualAssignment.setAssignmentSource("AUTO"); residualAssignment.setAssignmentVersion(1);
residualAssignment.setMatchStatus(residualCandidates.isEmpty() ? "UNMATCHED" : "FORECAST_MATCHED"); residualAssignment.setForecastVolumeCbm(remaining); residualAssignment.setRouteSource("FORECAST_FACT"); residualAssignment.setVolumeSource(residual.getSourceType()); residualAssignment.setRuleVersion("V1_CUTOFF_3D_18"); residualAssignment.setMatchedAt(now); residualAssignment.setReason(residualCandidates.isEmpty() ? "NO_MATCHING_OPEN_SAILING" : "RESIDUAL_FORECAST"); residualAssignment.setOperatorId(operatorId); residualAssignment.setCreatedAt(now); sailingFacts.insertAssignment(residualAssignment);
}
}
// A receipt that already had a forecast keeps that forecast's sailing anchor even when
// the warehouse cutoff has passed; only a first-time receipt searches the next open sailing.
SailingEntity sailing = forecastAssignment == null || forecastAssignment.getSailingId() == null
? null : sailingCalendar.sailing(forecastAssignment.getSailingId());
if (sailing != null && "CANCELLED".equals(sailing.getStatus())) sailing = null;
if (sailing == null && !ambiguousForecast && fact.getDeparturePortCode() != null && fact.getDestinationPortCode() != null) {
List<SailingEntity> candidates = sailingCalendar.openSailingsFrom(order.getWarehouseId(), order.getChannelCode(),
fact.getDeparturePortCode(), fact.getDestinationPortCode(), receivedAt);
sailing = candidates.isEmpty() ? null : candidates.get(0);
}
SailingAssignmentEntity assignment = new SailingAssignmentEntity(); assignment.setId(IdWorker.getId()); assignment.setTenantId(order.getTenantId());
assignment.setSailingId(sailing == null ? null : sailing.getId()); assignment.setInboundId(order.getId()); assignment.setInboundCode(order.getInboundCode()); assignment.setReceiptBatchId(fact.getId()); assignment.setForecastId(forecast == null ? null : forecast.getId()); assignment.setAssignmentSource("AUTO"); assignment.setAssignmentVersion(forecastAssignment == null || forecastAssignment.getAssignmentVersion() == null ? 1 : forecastAssignment.getAssignmentVersion() + 1);
boolean late = sailing != null && receivedAt.isAfter(sailing.getWarehouseCutoffAt());
assignment.setMatchStatus(sailing == null ? "UNMATCHED" : late ? "LATE" : "RECEIVED"); assignment.setVolumeCbm(order.getActualVolumeCbm());
assignment.setRouteSource(channel == null ? "INBOUND_FACT" : "CHANNEL_DEFAULT"); assignment.setVolumeSource("MEASURED_ACTUAL"); assignment.setRuleVersion("V1_CUTOFF_3D_18");
assignment.setMatchedAt(now); assignment.setLockedAt(late ? now : null);
assignment.setLockReason(late ? "CUTOFF" : null); assignment.setReason(ambiguousForecast ? "MULTI_FORECAST_REVIEW_REQUIRED" : sailing == null ? "NO_MATCHING_OPEN_SAILING" : forecast != null ? "FORECAST_REPLACED_BY_RECEIPT" : null); assignment.setOperatorId(operatorId); assignment.setCreatedAt(now);
sailingFacts.insertAssignment(assignment);
}
private SailingInboundVolumeForecastEntity selectForecastForReceipt(List<SailingInboundVolumeForecastEntity> forecasts,
InboundMeasurementEntity measurement,
BigDecimal actualVolume) {
if (forecasts.isEmpty()) return null;
String explicitKey = measurement.getExternalEventNo();
if (explicitKey != null && explicitKey.startsWith("SAILING_FORECAST:")) {
String key = explicitKey.substring("SAILING_FORECAST:".length()).trim();
for (SailingInboundVolumeForecastEntity forecast : forecasts) {
if (key.equals(forecast.getForecastKey())) return forecast;
}
return null;
}
if (forecasts.size() == 1) return forecasts.get(0);
SailingInboundVolumeForecastEntity exact = null;
for (SailingInboundVolumeForecastEntity forecast : forecasts) {
if (forecast.getForecastVolumeCbm() != null && actualVolume != null && forecast.getForecastVolumeCbm().compareTo(actualVolume) == 0) {
if (exact != null) return null;
exact = forecast;
}
}
return exact;
}
@Transactional
public void exceptionRelease(long tenantId, long operatorId, String operatorName, long inboundId,
String idempotencyKey, ExceptionReleaseRequest request) {