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
@@ -7778,4 +7778,276 @@ USE baiyue_finance;
-- 系统基线 fin_subject:1 行。
INSERT INTO fin_subject (id, subject_code, subject_name, status, version, created_by, updated_by, created_at, updated_at) VALUES (1,'PLATFORM_DEFAULT','平台','ACTIVE',0,0,0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3));
SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables;
-- phase4-sailing-calendar-schema.sql
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。
CREATE TABLE fl_sailing_channel (
id BIGINT NOT NULL,
channel_code VARCHAR(64) NOT NULL,
channel_name VARCHAR(128) NOT NULL,
source_type VARCHAR(24) NOT NULL,
mapped_channel_code VARCHAR(64) NULL,
default_departure_port_code VARCHAR(32) NULL,
default_destination_port_code VARCHAR(32) NULL,
default_cutoff_days TINYINT NOT NULL DEFAULT 3,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
remark VARCHAR(1000) NULL,
created_by BIGINT NOT NULL,
created_by_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
updated_by_name VARCHAR(128) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE fl_sailing_template (
id BIGINT NOT NULL,
template_no VARCHAR(64) NOT NULL,
template_name VARCHAR(128) NOT NULL,
channel_id BIGINT NOT NULL,
channel_code_snapshot VARCHAR(64) NOT NULL,
channel_name_snapshot VARCHAR(128) NOT NULL,
warehouse_id BIGINT NOT NULL,
warehouse_name_snapshot VARCHAR(128) NULL,
departure_port_code VARCHAR(32) NOT NULL,
destination_port_code VARCHAR(32) NOT NULL,
weekday TINYINT NOT NULL,
weekday_mask TINYINT NOT NULL DEFAULT 1,
etd_local_time TIME NOT NULL,
cutoff_days TINYINT NOT NULL DEFAULT 3,
cutoff_local_time TIME NOT NULL DEFAULT '18:00:00',
valid_from DATE NOT NULL,
valid_to DATE NULL,
voyage_rule VARCHAR(64) NULL,
vessel_name VARCHAR(128) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
remark VARCHAR(1000) NULL,
created_by BIGINT NOT NULL,
created_by_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
updated_by_name VARCHAR(128) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_template_no (template_no),
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE fl_sailing (
id BIGINT NOT NULL,
sailing_no VARCHAR(64) NOT NULL,
template_id BIGINT NULL,
channel_id BIGINT NOT NULL,
channel_code_snapshot VARCHAR(64) NOT NULL,
channel_name_snapshot VARCHAR(128) NOT NULL,
warehouse_id BIGINT NOT NULL,
warehouse_name_snapshot VARCHAR(128) NULL,
departure_port_code VARCHAR(32) NOT NULL,
destination_port_code VARCHAR(32) NOT NULL,
vessel_name VARCHAR(128) NOT NULL,
voyage_no VARCHAR(64) NOT NULL,
etd_at DATETIME(3) NOT NULL,
warehouse_cutoff_at DATETIME(3) NOT NULL,
eta_at DATETIME(3) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL',
generated_key VARCHAR(192) NULL,
planned_capacity_cbm DECIMAL(14,3) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT NOT NULL,
created_by_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
updated_by_name VARCHAR(128) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_no (sailing_no),
UNIQUE KEY uk_fl_sailing_generated_key (generated_key),
UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no),
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE fl_sailing_history (
id BIGINT NOT NULL,
sailing_id BIGINT NOT NULL,
action VARCHAR(32) NOT NULL,
from_status VARCHAR(16) NULL,
to_status VARCHAR(16) NULL,
reason VARCHAR(1000) NULL,
operator_id BIGINT NULL,
operator_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
USE baiyue_iam;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102206,0,'PLATFORM','MENU',5102000,'platform:firstleg:sailing-calendar:list','船期日历','/sailing-calendar','GET',125,'ACTIVE',1,0),
(5102207,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:create','新增船期','/sailing-calendar','POST',10,'ACTIVE',1,0),
(5102208,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:edit','编辑船期','/sailing-calendar','PUT',20,'ACTIVE',1,0),
(5102209,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-template:manage','管理渠道与周模板','/sailing-calendar','POST',30,'ACTIVE',1,0),
(5102210,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-booking:link','关联订舱','/sailing-calendar','POST',40,'ACTIVE',1,0),
(5102211,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:forecast-manage','维护货量预报','/sailing-calendar','POST',50,'ACTIVE',1,0),
(5102212,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:exception-reassign','异常重新安排','/sailing-calendar','POST',60,'ACTIVE',1,0);
INSERT INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN (
'platform:firstleg:sailing-calendar:list',
'platform:firstleg:sailing-calendar:create',
'platform:firstleg:sailing-calendar:edit',
'platform:firstleg:sailing-template:manage',
'platform:firstleg:sailing-booking:link',
'platform:firstleg:sailing-volume:forecast-manage',
'platform:firstleg:sailing-volume:exception-reassign'
)
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
-- phase4-sailing-facts-schema.sql
SET NAMES utf8mb4;
USE baiyue_firstleg;
/*
* 船期日历的事实契约。
*
* 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是
* RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。
*/
CREATE TABLE fl_sailing_receipt_batch_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键',
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID',
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID',
channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照',
channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3',
fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED',
reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID',
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源',
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version),
KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version),
KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id),
KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id),
CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0),
CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED'))
) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史';
CREATE TABLE fl_sailing_inbound_volume_forecast (
id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键',
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID',
channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照',
channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照',
destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照',
expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间',
forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替',
source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报',
fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED',
effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间',
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version),
KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version),
KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id),
KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id),
CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0),
CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED'))
) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史';
CREATE TABLE fl_sailing_assignment (
id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空',
origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID',
inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID',
inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示',
receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID',
forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID',
assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER',
assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本',
match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED',
volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积',
forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积',
route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT',
volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源',
rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本',
matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
superseded_at DATETIME(3) NULL,
locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间',
lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED',
operator_id BIGINT UNSIGNED NULL,
reason VARCHAR(500) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id),
KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id),
KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id),
KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id),
KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id),
CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')),
CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER'))
) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史';
CREATE TABLE fl_sailing_booking_link (
id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键',
sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED',
active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束',
booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立',
warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照',
channel_code VARCHAR(64) NULL COMMENT '关联渠道快照',
departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照',
destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照',
linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
revoked_at DATETIME(3) NULL,
operator_id BIGINT UNSIGNED NULL,
operator_name VARCHAR(64) NULL,
reason VARCHAR(500) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard),
KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id),
KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id),
CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')),
CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)),
CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED')
) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史';
SELECT 'PASS' AS initialization_status, 'CN' AS region, 219 AS initialized_tables;
@@ -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) {
@@ -0,0 +1,143 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。
CREATE TABLE fl_sailing_channel (
id BIGINT NOT NULL,
channel_code VARCHAR(64) NOT NULL,
channel_name VARCHAR(128) NOT NULL,
source_type VARCHAR(24) NOT NULL,
mapped_channel_code VARCHAR(64) NULL,
default_departure_port_code VARCHAR(32) NULL,
default_destination_port_code VARCHAR(32) NULL,
default_cutoff_days TINYINT NOT NULL DEFAULT 3,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
remark VARCHAR(1000) NULL,
created_by BIGINT NOT NULL,
created_by_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
updated_by_name VARCHAR(128) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_channel_code (channel_code),
KEY idx_fl_sailing_channel_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE fl_sailing_template (
id BIGINT NOT NULL,
template_no VARCHAR(64) NOT NULL,
template_name VARCHAR(128) NOT NULL,
channel_id BIGINT NOT NULL,
channel_code_snapshot VARCHAR(64) NOT NULL,
channel_name_snapshot VARCHAR(128) NOT NULL,
warehouse_id BIGINT NOT NULL,
warehouse_name_snapshot VARCHAR(128) NULL,
departure_port_code VARCHAR(32) NOT NULL,
destination_port_code VARCHAR(32) NOT NULL,
weekday TINYINT NOT NULL,
weekday_mask TINYINT NOT NULL DEFAULT 1,
etd_local_time TIME NOT NULL,
cutoff_days TINYINT NOT NULL DEFAULT 3,
cutoff_local_time TIME NOT NULL DEFAULT '18:00:00',
valid_from DATE NOT NULL,
valid_to DATE NULL,
voyage_rule VARCHAR(64) NULL,
vessel_name VARCHAR(128) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
remark VARCHAR(1000) NULL,
created_by BIGINT NOT NULL,
created_by_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
updated_by_name VARCHAR(128) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_template_no (template_no),
KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday),
CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7),
CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE fl_sailing (
id BIGINT NOT NULL,
sailing_no VARCHAR(64) NOT NULL,
template_id BIGINT NULL,
channel_id BIGINT NOT NULL,
channel_code_snapshot VARCHAR(64) NOT NULL,
channel_name_snapshot VARCHAR(128) NOT NULL,
warehouse_id BIGINT NOT NULL,
warehouse_name_snapshot VARCHAR(128) NULL,
departure_port_code VARCHAR(32) NOT NULL,
destination_port_code VARCHAR(32) NOT NULL,
vessel_name VARCHAR(128) NOT NULL,
voyage_no VARCHAR(64) NOT NULL,
etd_at DATETIME(3) NOT NULL,
warehouse_cutoff_at DATETIME(3) NOT NULL,
eta_at DATETIME(3) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL',
generated_key VARCHAR(192) NULL,
planned_capacity_cbm DECIMAL(14,3) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT NOT NULL,
created_by_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NOT NULL,
updated_by_name VARCHAR(128) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_fl_sailing_no (sailing_no),
UNIQUE KEY uk_fl_sailing_generated_key (generated_key),
UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no),
KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status),
KEY idx_fl_sailing_route (departure_port_code,destination_port_code),
CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE fl_sailing_history (
id BIGINT NOT NULL,
sailing_id BIGINT NOT NULL,
action VARCHAR(32) NOT NULL,
from_status VARCHAR(16) NULL,
to_status VARCHAR(16) NULL,
reason VARCHAR(1000) NULL,
operator_id BIGINT NULL,
operator_name VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
USE baiyue_iam;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102206,0,'PLATFORM','MENU',5102000,'platform:firstleg:sailing-calendar:list','船期日历','/sailing-calendar','GET',125,'ACTIVE',1,0),
(5102207,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:create','新增船期','/sailing-calendar','POST',10,'ACTIVE',1,0),
(5102208,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-calendar:edit','编辑船期','/sailing-calendar','PUT',20,'ACTIVE',1,0),
(5102209,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-template:manage','管理渠道与周模板','/sailing-calendar','POST',30,'ACTIVE',1,0),
(5102210,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-booking:link','关联订舱','/sailing-calendar','POST',40,'ACTIVE',1,0),
(5102211,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:forecast-manage','维护货量预报','/sailing-calendar','POST',50,'ACTIVE',1,0),
(5102212,0,'PLATFORM','BUTTON',5102206,'platform:firstleg:sailing-volume:exception-reassign','异常重新安排','/sailing-calendar','POST',60,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE
parent_id=VALUES(parent_id), permission_code=VALUES(permission_code), permission_name=VALUES(permission_name),
route_path=VALUES(route_path), http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p
ON p.tenant_id=0 AND p.app_code='PLATFORM'
AND p.permission_code IN (
'platform:firstleg:sailing-calendar:list',
'platform:firstleg:sailing-calendar:create',
'platform:firstleg:sailing-calendar:edit',
'platform:firstleg:sailing-template:manage',
'platform:firstleg:sailing-booking:link',
'platform:firstleg:sailing-volume:forecast-manage',
'platform:firstleg:sailing-volume:exception-reassign'
)
WHERE r.tenant_id=0 AND r.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
@@ -0,0 +1,126 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
/*
* 船期日历的事实契约。
*
* 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是
* RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。
*/
CREATE TABLE fl_sailing_receipt_batch_fact (
id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键',
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID',
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID',
channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照',
channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代',
actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3',
fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED',
reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID',
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源',
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version),
KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version),
KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id),
KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id),
CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0),
CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED'))
) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史';
CREATE TABLE fl_sailing_inbound_volume_forecast (
id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实',
forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键',
fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID',
channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照',
channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照',
destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照',
expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间',
forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替',
source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报',
fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED',
effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间',
created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version),
KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version),
KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id),
KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id),
CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0),
CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED'))
) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史';
CREATE TABLE fl_sailing_assignment (
id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键',
sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空',
origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID',
inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID',
inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示',
receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID',
forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID',
assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER',
assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本',
match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED',
volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积',
forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积',
route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT',
volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源',
rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本',
matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
superseded_at DATETIME(3) NULL,
locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间',
lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED',
operator_id BIGINT UNSIGNED NULL,
reason VARCHAR(500) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id),
KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id),
KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id),
KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id),
KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id),
CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')),
CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER'))
) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史';
CREATE TABLE fl_sailing_booking_link (
id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键',
sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED',
active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束',
booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立',
warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照',
channel_code VARCHAR(64) NULL COMMENT '关联渠道快照',
departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照',
destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照',
linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
revoked_at DATETIME(3) NULL,
operator_id BIGINT UNSIGNED NULL,
operator_name VARCHAR(64) NULL,
reason VARCHAR(500) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard),
KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id),
KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id),
CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')),
CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)),
CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED')
) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史';
@@ -0,0 +1,138 @@
openapi: 3.0.3
info:
title: Firstleg Sailing Calendar API
version: v1
description: 船期实例、固定周模板、渠道管理、收货事实汇总和已放舱订舱关联。
paths:
/api/platform/v1/sailings/calendar:
get:
summary: 查询船期月历
x-permission: platform:firstleg:sailing-calendar:list
parameters:
- {name: month, in: query, required: true, schema: {type: string, pattern: '^\\d{4}-\\d{2}$'}}
- {name: warehouseId, in: query, schema: {type: integer, format: int64}}
- {name: channelId, in: query, schema: {type: integer, format: int64}}
- {name: channelCode, in: query, schema: {type: string, maxLength: 64}}
- {name: departurePortCode, in: query, schema: {type: string, maxLength: 32}}
- {name: destinationPortCode, in: query, schema: {type: string, maxLength: 32}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
- {name: status, in: query, schema: {type: string}}
responses: {'200': {description: 月历和按船期汇总}}
/api/platform/v1/sailings:
post:
summary: 新增单次船期
x-permission: platform:firstleg:sailing-calendar:create
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}:
get:
summary: 查询船期详情
x-permission: platform:firstleg:sailing-calendar:list
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/history:
get:
summary: 查询船期变更历史
x-permission: platform:firstleg:sailing-calendar:list
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: OK}}
put:
summary: 编辑船期
x-permission: platform:firstleg:sailing-calendar:edit
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/cancel:
post:
summary: 取消船期
x-permission: platform:firstleg:sailing-calendar:edit
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/copy:
post:
summary: 复制下一班船期
x-permission: platform:firstleg:sailing-calendar:create
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/volume-summary:
get:
summary: 查询实收、预报和归班明细
x-permission: platform:firstleg:sailing-calendar:list
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/booking-advice:
get:
summary: 查询只读订柜建议
x-permission: platform:firstleg:sailing-calendar:list
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/booking-links:
post:
summary: 关联一条 RELEASED 海运订舱
x-permission: platform:firstleg:sailing-booking:link
responses: {'200': {description: ACTIVE 关联结果}}
/api/platform/v1/sailing-channels:
get:
summary: 查询船期渠道
x-permission: platform:firstleg:sailing-calendar:list
responses: {'200': {description: OK}}
post:
summary: 新增船期渠道
x-permission: platform:firstleg:sailing-template:manage
responses: {'200': {description: OK}}
/api/platform/v1/sailing-channels/{id}:
put:
summary: 编辑或启停船期渠道
x-permission: platform:firstleg:sailing-template:manage
responses: {'200': {description: OK}}
/api/platform/v1/sailing-templates:
get:
summary: 查询固定周模板
x-permission: platform:firstleg:sailing-template:manage
responses: {'200': {description: OK}}
post:
summary: 新增固定周模板
x-permission: platform:firstleg:sailing-template:manage
responses: {'200': {description: OK}}
/api/platform/v1/sailing-templates/{id}:
put:
summary: 编辑固定周模板
x-permission: platform:firstleg:sailing-template:manage
responses: {'200': {description: OK}}
/api/platform/v1/sailing-templates/{id}/generation-preview:
post:
summary: 预览模板生成实例
x-permission: platform:firstleg:sailing-template:manage
responses: {'200': {description: OK}}
/api/platform/v1/sailing-templates/{id}/generate:
post:
summary: 按模板生成实例
x-permission: platform:firstleg:sailing-template:manage
responses: {'200': {description: OK}}
/api/platform/v1/sailings/{id}/exception-reassign:
post:
summary: 受限异常重新安排
x-permission: platform:firstleg:sailing-volume:exception-reassign
responses: {'200': {description: OK}}
/api/platform/v1/sailing-assignments/exceptions:
get:
summary: 查询未安排和晚到待办
x-permission: platform:firstleg:sailing-calendar:list
responses: {'200': {description: OK}}
/api/platform/v1/sailing-forecasts:
post:
summary: 新增未收货量预报
x-permission: platform:firstleg:sailing-volume:forecast-manage
responses: {'200': {description: OK}}
/api/platform/v1/sailing-forecasts/{id}:
put:
summary: 修订未收货量预报
x-permission: platform:firstleg:sailing-volume:forecast-manage
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: 新版本预报}}
/api/platform/v1/sailing-forecasts/{id}/void:
post:
summary: 作废未收货量预报
x-permission: platform:firstleg:sailing-volume:forecast-manage
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: OK}}
/api/platform/v1/sailing-forecasts/{id}/bind-receipt:
post:
summary: 将实收批次绑定到预报批次
x-permission: platform:firstleg:sailing-volume:forecast-manage
parameters: [{name: id, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: OK}}
@@ -73,7 +73,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql();
String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 210 AS initialized_tables;";
String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 219 AS initialized_tables;";
assertThat(count(sql, pass)).isEqualTo(1);
assertThat(sql.trim()).endsWith(pass);
assertThat(sql)
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.sailingcalendar.fact;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SailingFactsSqlContractTest {
@Test
void definesVersionedReceiptForecastAssignmentAndBookingFacts() throws Exception {
String sql = new ClassPathResource("db/phase4-sailing-facts-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE fl_sailing_receipt_batch_fact")
.contains("CREATE TABLE fl_sailing_inbound_volume_forecast")
.contains("CREATE TABLE fl_sailing_assignment")
.contains("CREATE TABLE fl_sailing_booking_link")
.contains("receipt_batch_key VARCHAR(64) NOT NULL")
.contains("fact_version INT UNSIGNED NOT NULL")
.contains("received_at DATETIME(3) NOT NULL")
.contains("actual_volume_cbm DECIMAL(18,4) NOT NULL")
.contains("expected_arrival_at DATETIME(3) NOT NULL")
.contains("forecast_volume_cbm DECIMAL(18,4) NOT NULL")
.contains("match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')")
.contains("booking_status_snapshot='RELEASED'")
.contains("UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard)");
}
@Test
void doesNotUseLegacyInboundTimestampOrDeclarationVolumeAsFact() throws Exception {
String sql = new ClassPathResource("db/phase4-sailing-facts-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.doesNotContain("inbounded_at DATETIME")
.doesNotContain("original_volume_cbm DECIMAL");
}
@Test
void unifiedInitializationIncludesThePhase4FactsInTheFirstlegSchema() throws Exception {
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(unified)
.contains("-- phase4-sailing-calendar-schema.sql")
.contains("-- phase4-sailing-facts-schema.sql")
.contains("USE baiyue_firstleg;")
.contains("CREATE TABLE fl_sailing_receipt_batch_fact")
.contains("CREATE TABLE fl_sailing_booking_link");
}
}
+1 -1
View File
@@ -77,7 +77,7 @@ spring:
- id: firstleg-service
uri: lb://firstleg-service
predicates:
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/v1/firstleg/mojia-sku-sync/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
- Path=/api/merchant/v1/first-leg/**,/api/merchant/v1/customs/**,/api/platform/v1/first-leg/**,/api/platform/v1/customs/**,/api/platform/v1/port-schedules/**,/api/platform/v1/carrier-schedule-queries/**,/api/platform/v1/container-shipping-nodes/**,/api/platform/v1/containers/**,/api/platform/v1/terminal-drayages/**,/api/platform/v1/customers/{customerId}/container-transit-summary,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/v1/loading-plans/**,/api/platform/v1/sailings/**,/api/platform/v1/sailing-channels/**,/api/platform/v1/sailing-templates/**,/api/platform/v1/sailing-assignments/**,/api/platform/v1/sailing-forecasts/**,/api/platform/v1/firstleg/mojia-sku-sync/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
- id: finance-service
uri: lb://finance-service
predicates:
@@ -9,7 +9,7 @@ const domesticSchemas = ['baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_wa
const overseasSchemas = ['baiyue_event_bridge_overseas', 'baiyue_lastleg', 'baiyue_lastlag_warehouse']
test('两个入口分别只操作国内九库与海外三库,包括限定名引用', () => {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 210], [overseas, overseasSchemas, domesticSchemas, 138]]) {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 219], [overseas, overseasSchemas, domesticSchemas, 138]]) {
assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed)
assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed)
const body = sql.replace(/^--.*$/gm, '')
@@ -28,7 +28,7 @@ test('纯初始化禁止迁移、数据回填、清理、重复建表和提前
assert.doesNotMatch(body.replace(/^CREATE TRIGGER [\s\S]*?\$\$/gm, ''), /^\s*(?:UPDATE|DELETE FROM|REPLACE INTO)\b/gmi)
assert.doesNotMatch(body, /CREATE TABLE IF NOT EXISTS/i)
assert.equal((body.match(/AS initialization_status/g) ?? []).length, 1)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (210|138) AS initialized_tables;$/)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (219|138) AS initialized_tables;$/)
}
})