fix(firstleg): allow optional sailing booking details

This commit is contained in:
2026-09-28 14:46:19 +08:00
parent e63cb2b29e
commit 736e94c808
11 changed files with 40 additions and 33 deletions
+2 -2
View File
@@ -6,7 +6,7 @@
| 区域 | 完整入口 | 服务 schema | 表数 |
|---|---|---|---:|
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 208 |
| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 221 |
| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 |
- 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。
@@ -24,7 +24,7 @@
5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。
6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=208/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=221/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。
## 平台管理员密码
@@ -7891,8 +7891,8 @@ CREATE TABLE fl_sailing (
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,
vessel_name VARCHAR(128) NULL,
voyage_no VARCHAR(64) NULL,
etd_at DATETIME(3) NOT NULL,
warehouse_cutoff_at DATETIME(3) NOT NULL,
eta_at DATETIME(3) NULL,
@@ -8087,4 +8087,4 @@ CREATE TABLE fl_sailing_booking_link (
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;
SELECT 'PASS' AS initialization_status, 'CN' AS region, 221 AS initialized_tables;
@@ -2937,12 +2937,6 @@ CREATE TABLE wh_location (
KEY idx_location_query (customer_id,warehouse_id,status,deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库库位表:按租户和仓库维护库位及承重信息';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='wh_location' AND index_name='idx_location_query'), 'ALTER TABLE wh_location DROP INDEX idx_location_query', 'SELECT 1'));
PREPARE location_zone_index_stmt FROM @ddl; EXECUTE location_zone_index_stmt; DEALLOCATE PREPARE location_zone_index_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='wh_location' AND column_name='zone_code'), 'ALTER TABLE wh_location DROP COLUMN zone_code', 'SELECT 1'));
PREPARE location_zone_column_stmt FROM @ddl; EXECUTE location_zone_column_stmt; DEALLOCATE PREPARE location_zone_column_stmt;
CREATE TABLE wh_pallet (
id bigint unsigned NOT NULL COMMENT '托盘ID(雪花ID)',
customer_id bigint unsigned NOT NULL COMMENT '数据所属租户ID',
@@ -132,14 +132,14 @@ public final class SailingCalendarDtos {
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,
@Size(max = 64) String channelCode,
@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,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@@ -151,14 +151,14 @@ public final class SailingCalendarDtos {
@Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@Size(max = 64) String channelCode,
@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,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@@ -174,14 +174,14 @@ public final class SailingCalendarDtos {
@Positive Long channelId,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@Size(max = 64) String channelCode,
@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,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@NotNull LocalDateTime etdAt,
LocalDateTime etaAt,
@NotNull LocalDateTime cutoffAt,
@@ -33,9 +33,10 @@ public interface SailingCalendarDisplayMapper {
AND b.channel_code = #{channelCode}
AND b.departure_port_code = #{departurePortCode}
AND b.destination_port_code = #{destinationPortCode}
AND b.vessel_name = #{vesselName}
AND b.voyage_no = #{voyageNo}
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(#{etdAt}))
AND (#{vesselName} IS NOT NULL AND #{voyageNo} IS NOT NULL
AND b.vessel_name = #{vesselName}
AND b.voyage_no = #{voyageNo}
AND (b.estimated_sailing_at IS NULL OR DATE(b.estimated_sailing_at) = DATE(#{etdAt})))
)
)
""")
@@ -99,6 +99,12 @@ public interface SailingMapper extends BaseMapper<SailingEntity> {
int updateSailing(@Param("value") SailingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_sailing SET vessel_name=#{vesselName},voyage_no=#{voyageNo},etd_at=COALESCE(#{etdAt},etd_at),eta_at=#{etaAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}")
int updateBookingDetails(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
@Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo,
@Param("etdAt") java.time.LocalDateTime etdAt, @Param("etaAt") java.time.LocalDateTime etaAt,
@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);
@@ -95,6 +95,9 @@ public class SailingCalendarRepository {
public int update(SailingEntity value, int version, long operatorId, String operatorName) {
return sailings.updateSailing(value, version, operatorId, operatorName);
}
public int updateBookingDetails(long id, int version, String vesselName, String voyageNo, LocalDateTime etdAt, LocalDateTime etaAt, long operatorId, String operatorName) {
return sailings.updateBookingDetails(id, version, vesselName, voyageNo, etdAt, etaAt, operatorId, operatorName);
}
public int updateStatus(long id, String status, int version, long operatorId, String operatorName) {
return sailings.updateStatus(id, status, version, operatorId, operatorName);
}
@@ -349,6 +349,9 @@ public class SailingCalendarService {
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);
if (StringUtils.hasText(booking.getVesselName()) || StringUtils.hasText(booking.getVoyageNo())) {
if (repository.updateBookingDetails(id, sailing.getVersion(), booking.getVesselName(), booking.getVoyageNo(), booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt(), operatorId, operatorName) != 1) throw conflict("船期已被其他人修改");
}
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);
}
@@ -449,7 +452,7 @@ public class SailingCalendarService {
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 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(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); 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) {
@@ -470,7 +473,7 @@ public class SailingCalendarService {
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 resolveChannel(SailingData d) { if (d.channelId() != null) return requiredChannel(d.channelId()); if (!StringUtils.hasText(d.channelCode())) throw bad("必须选择价格表渠道"); 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; }
@@ -72,8 +72,8 @@ CREATE TABLE fl_sailing (
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,
vessel_name VARCHAR(128) NULL,
voyage_no VARCHAR(64) NULL,
etd_at DATETIME(3) NOT NULL,
warehouse_cutoff_at DATETIME(3) NOT NULL,
eta_at DATETIME(3) NULL,
@@ -73,7 +73,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test
void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql();
String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 219 AS initialized_tables;";
String pass = "SELECT 'PASS' AS initialization_status, 'CN' AS region, 221 AS initialized_tables;";
assertThat(count(sql, pass)).isEqualTo(1);
assertThat(sql.trim()).endsWith(pass);
assertThat(sql)
@@ -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, 219], [overseas, overseasSchemas, domesticSchemas, 138]]) {
for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 221], [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, (219|138) AS initialized_tables;$/)
assert.match(body.trim(), /SELECT 'PASS' AS initialization_status, '(CN|OVERSEAS)' AS region, (221|138) AS initialized_tables;$/)
}
})