feat(firstleg): add sailing calendar facts and APIs
This commit is contained in:
@@ -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;
|
||||
|
||||
+34
@@ -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));
|
||||
}
|
||||
}
|
||||
+104
@@ -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));
|
||||
}
|
||||
}
|
||||
+30
@@ -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)); }
|
||||
}
|
||||
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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()))); }
|
||||
}
|
||||
+283
@@ -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) {}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
+24
@@ -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();
|
||||
}
|
||||
+8
@@ -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> {}
|
||||
+8
@@ -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> {}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+195
@@ -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));
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+8
@@ -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 >= #{startAt} AND etd_at < #{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);
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
+112
@@ -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); }
|
||||
}
|
||||
+482
@@ -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}}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+52
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;$/)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user