feat(acm2-92): RQRD 人工登记与 POST /refdata/sync(C-12)
- 新增 POST /refdata/sync:STYP 14 类校验,RSTA 须带 RTYP - REQ_TRACK 加 styp/rtyp 列(V8 迁移);RQRD/RQFD 各自单开放槽 - OutboundRequestService 加 registerRqrdSync,派发按行目标编码 - Pump 加 REF-RESP 守卫:无在途 RQRD 时 SKIPPED - 回归测试覆盖 C-12 登记/派发/400/409/独立槽/守卫
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.gzzn.omms.msgexchange.codec
|
||||
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.RqfdTimeFilter
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightStateEngine
|
||||
@@ -17,12 +18,27 @@ class JacksonXmlCodecTest {
|
||||
private val codec = JacksonXmlCodec()
|
||||
|
||||
@Test
|
||||
fun `encode outbound RQFD uses OMMS meta and empty body per C-4`() {
|
||||
fun `encode outbound RQFD uses OMMS meta and omits time filters that were not sent`() {
|
||||
val xml = codec.encodeOutboundRqfd(1243L, 20021010090311L)
|
||||
assertTrue(xml.contains("<SNDR>OMMS</SNDR>"))
|
||||
assertTrue(xml.contains("<TYPE>RQFD</TYPE>"))
|
||||
assertTrue(xml.contains("<STYP>NONE</STYP>"))
|
||||
assertTrue(xml.contains("<RQFD"))
|
||||
assertFalse(xml.contains("STDB"))
|
||||
assertFalse(xml.contains("ETDE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode outbound RQFD writes only the time filters the page sent`() {
|
||||
val xml = codec.encodeOutboundRqfd(
|
||||
1243L,
|
||||
20021010090311L,
|
||||
RqfdTimeFilter(stdb = "12JAN041730", etde = "13JAN042359"),
|
||||
)
|
||||
assertTrue(xml.contains("<STDB>12JAN041730</STDB>"))
|
||||
assertTrue(xml.contains("<ETDE>13JAN042359</ETDE>"))
|
||||
assertFalse(xml.contains("STDE"))
|
||||
assertFalse(xml.contains("ETDB"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+11
-1
@@ -11,7 +11,7 @@ import java.sql.DriverManager
|
||||
|
||||
/**
|
||||
* 在真实 PostgreSQL 上跑一遍迁移链,确认结果符合预期:`V1__flight_state_baseline.sql`
|
||||
* 基线加 V2~V6 增量迁移
|
||||
* 基线加 V2~V8 增量迁移
|
||||
* 依序执行成功,该建的表和 PIPELINE_LOCK 单行种子都在,回填事实落在基线里,`INBOX_CURSOR`、
|
||||
* `BACKFILL_TODO`、`idx_evt_flid`、`PROC_STATE` 的处理开始时间列都不复存在;FLIGHT_CHUTE
|
||||
* 的类字段列已由 V2 更名为 CCLS/CTYP(SIS 口径)。
|
||||
@@ -59,6 +59,8 @@ class FlywayMigrationTest {
|
||||
"4" to "V4__req_track_outbound_seqn.sql",
|
||||
"5" to "V5__unmapped_field_srvt_vipf.sql",
|
||||
"6" to "V6__basicdata_ref_data.sql",
|
||||
"7" to "V7__req_track_rqfd_window.sql",
|
||||
"8" to "V8__req_track_rqrd_target.sql",
|
||||
),
|
||||
records.map { it.first to it.second },
|
||||
)
|
||||
@@ -133,6 +135,14 @@ class FlywayMigrationTest {
|
||||
assertTrue(indexDef.contains("UNIQUE"), "uq_req_open 必须是唯一索引:$indexDef")
|
||||
assertTrue(indexDef.contains("WHERE"), "uq_req_open 必须是仅约束开放态的部分索引:$indexDef")
|
||||
}
|
||||
stmt.executeQuery(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'req_track' " +
|
||||
"AND column_name IN ('stdb', 'stde', 'etdb', 'etde', 'styp', 'rtyp')",
|
||||
).use { rs ->
|
||||
val cols = mutableSetOf<String>()
|
||||
while (rs.next()) cols.add(rs.getString("column_name"))
|
||||
assertEquals(setOf("stdb", "stde", "etdb", "etde", "styp", "rtyp"), cols)
|
||||
}
|
||||
stmt.executeUpdate(
|
||||
"INSERT INTO req_track (req_type, operation_day, sender, state, created_at) " +
|
||||
"VALUES ('RQFD-NONE', DATE '2026-09-12', 'RMS', 'PENDING', now())",
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.gzzn.omms.msgexchange.codec.JacksonXmlCodec
|
||||
import com.gzzn.omms.msgexchange.config.OperationDayProps
|
||||
import com.gzzn.omms.msgexchange.config.PipelineProps
|
||||
import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
|
||||
import com.gzzn.omms.msgexchange.domain.RqfdTimeFilter
|
||||
import com.gzzn.omms.msgexchange.ingress.SchdSyncService
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.CoutmsgOutboxRepository
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.ReqTrackRepository
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubCoutmsgOutbox
|
||||
@@ -45,6 +47,28 @@ class OutboundRequestTest {
|
||||
assertTrue(outbox.messages.values.first().contains("<TYPE>RQFD</TYPE>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `schd sync writes the page time filter into COUTMSGS and leaves out the rest`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val outbox = StubCoutmsgOutbox()
|
||||
val svc = service(req, outbox)
|
||||
|
||||
svc.registerRqfdSync(RqfdTimeFilter(stdb = "12JAN041730"))
|
||||
val xml = outbox.messages.values.first()
|
||||
assertTrue(xml.contains("<STDB>12JAN041730</STDB>"))
|
||||
assertFalse(xml.contains("STDE"))
|
||||
assertFalse(xml.contains("ETDB"))
|
||||
assertFalse(xml.contains("ETDE"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid time filter is rejected before registration`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val sync = SchdSyncService(service(req, StubCoutmsgOutbox()))
|
||||
assertEquals(SchdSyncService.Outcome.InvalidTime, sync.trigger(stdb = "today"))
|
||||
assertTrue(req.rows.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open slot rejects duplicate schd sync registration`() {
|
||||
val req = StubReqTrack(clock)
|
||||
@@ -108,4 +132,71 @@ class OutboundRequestTest {
|
||||
assertTrue(svc.failFromEror(42L, "RQFD", "NONE"))
|
||||
assertEquals(ReqTrackRepository.ReqState.FAILED, req.rows[reqId]!!.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refdata sync registers AIRL and dispatches RQRD into COUTMSGS`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val outbox = StubCoutmsgOutbox()
|
||||
val sync = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, outbox))
|
||||
|
||||
val outcome = sync.trigger("AIRL", null)
|
||||
assertTrue(outcome is com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.Registered)
|
||||
val xml = outbox.messages.values.first()
|
||||
assertTrue(xml.contains("<TYPE>RQRD</TYPE>"))
|
||||
assertTrue(xml.contains("<STYP>AIRL</STYP>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refdata sync writes RTYP only for RSTA`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val outbox = StubCoutmsgOutbox()
|
||||
val svc = service(req, outbox)
|
||||
|
||||
svc.registerRqrdSync(com.gzzn.omms.msgexchange.domain.RqrdTarget("RSTA", "GATE"))
|
||||
val xml = outbox.messages.values.first()
|
||||
assertTrue(xml.contains("<STYP>RSTA</STYP>"))
|
||||
assertTrue(xml.contains("<RTYP>GATE</RTYP>"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refdata sync rejects unknown styp and rtyp misuse before registration`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val sync = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, StubCoutmsgOutbox()))
|
||||
|
||||
assertEquals(
|
||||
com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.UnknownStyp,
|
||||
sync.trigger("NOPE", null),
|
||||
)
|
||||
assertEquals(
|
||||
com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.MissingRtyp,
|
||||
sync.trigger("RSTA", null),
|
||||
)
|
||||
assertEquals(
|
||||
com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.UnexpectedRtyp,
|
||||
sync.trigger("AIRL", "GATE"),
|
||||
)
|
||||
assertTrue(req.rows.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open slot rejects duplicate refdata sync registration`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val sync = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, StubCoutmsgOutbox()))
|
||||
|
||||
sync.trigger("AIRL", null)
|
||||
assertEquals(
|
||||
com.gzzn.omms.msgexchange.ingress.RefdataSyncService.Outcome.OpenExists,
|
||||
sync.trigger("AIRL", null),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RQRD and RQFD use separate open slots`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val rqrd = com.gzzn.omms.msgexchange.ingress.RefdataSyncService(service(req, StubCoutmsgOutbox()))
|
||||
val rqfd = SchdSyncService(service(req, StubCoutmsgOutbox()))
|
||||
|
||||
rqrd.trigger("AIRL", null)
|
||||
assertTrue(rqfd.trigger() is SchdSyncService.Outcome.Registered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import com.gzzn.omms.msgexchange.domain.DecodedMessage
|
||||
import com.gzzn.omms.msgexchange.domain.MetaFields
|
||||
import com.gzzn.omms.msgexchange.domain.MsgKind
|
||||
import com.gzzn.omms.msgexchange.domain.OutboundRequestKeys
|
||||
import com.gzzn.omms.msgexchange.domain.RqfdTimeFilter
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot
|
||||
import com.gzzn.omms.msgexchange.domain.flight.FlightState
|
||||
import com.gzzn.omms.msgexchange.domain.flight.ScheduleRecord
|
||||
import com.gzzn.omms.msgexchange.domain.ProcState
|
||||
import com.gzzn.omms.msgexchange.domain.ProcStatus
|
||||
import com.gzzn.omms.msgexchange.infra.metrics.PipelineCounters
|
||||
@@ -25,6 +29,7 @@ import com.gzzn.omms.msgexchange.infra.stub.StubReqTrack
|
||||
import com.gzzn.omms.msgexchange.infra.stub.StubSnapshotLog
|
||||
import com.gzzn.omms.msgexchange.MutableClock
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
@@ -49,10 +54,10 @@ class RespGuardTest {
|
||||
inbox: StubInbox,
|
||||
req: StubReqTrack,
|
||||
decoded: DecodedMessage,
|
||||
flights: StubFlightState = StubFlightState(),
|
||||
): MessageProcessor {
|
||||
val props = PipelineProps()
|
||||
val opDay = OperationDayProps().apply { zone = "Asia/Shanghai"; cutoffHour = 0 }
|
||||
val flights = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val commit = testCommit(procState = proc, msgEvents = events, projection = StubFlightProjectionPort(), clock = clock)
|
||||
return MessageProcessor(
|
||||
@@ -94,6 +99,41 @@ class RespGuardTest {
|
||||
assertEquals("resp-guard:no-open-req", proc.find(msgId)!!.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filtered SCHD-RESP replaces flights in the reply and does not delete the rest`() {
|
||||
val req = StubReqTrack(clock)
|
||||
val reqId = req.insert(
|
||||
OutboundRequestKeys.RQFD_REQ_TYPE,
|
||||
day,
|
||||
OutboundRequestKeys.SENDER,
|
||||
RqfdTimeFilter(stdb = "15DEC261700"),
|
||||
)
|
||||
req.linkCoutmsgs(reqId, 1L, 1L)
|
||||
req.markSent(reqId, clock.instant())
|
||||
|
||||
val flights = StubFlightState()
|
||||
val flightDay = LocalDate.of(2026, 12, 15)
|
||||
listOf("121", "122").forEach { flid ->
|
||||
flights.persistFullState(
|
||||
FlightSnapshot(flid, flightDay, FlightState.ACTIVE, 1, mapOf("SODT" to "15DEC261723"), emptyMap()),
|
||||
msgId = 1,
|
||||
now = Instant.EPOCH,
|
||||
)
|
||||
}
|
||||
val body = ScheduleBody(1, listOf(ScheduleRecord("121", mapOf("SODT" to "15DEC261800"))))
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<x/>"
|
||||
val p = processor(proc, inbox, req, schdMsg(MsgKind.SchdSubtype.RESP, body), flights)
|
||||
|
||||
p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
|
||||
|
||||
assertEquals(ProcStatus.SUCCEEDED, proc.find(msgId)!!.state)
|
||||
assertEquals(FlightState.ACTIVE, flights.findMainRow("122")!!.state)
|
||||
assertTrue(flights.findMainRow("121")!!.stateVersion > 1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SCHD-DNLD does not require open request`() {
|
||||
val proc = StubProcState()
|
||||
@@ -125,4 +165,23 @@ class RespGuardTest {
|
||||
|
||||
assertEquals(ProcStatus.SKIPPED, proc.find(msgId)!!.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `REF-RESP without open RQRD is SKIPPED`() {
|
||||
val proc = StubProcState()
|
||||
proc.insertIfAbsent(msgId, null)
|
||||
val inbox = StubInbox()
|
||||
inbox.raws[msgId] = "<x/>"
|
||||
val decoded = DecodedMessage(
|
||||
meta = MetaFields("AODB", "AIRL", "RESP", 1L, 1L),
|
||||
kind = MsgKind.RefData("AIRL"),
|
||||
rawXml = "<MSG/>",
|
||||
body = com.gzzn.omms.msgexchange.domain.ref.RefDataBody(category = "AIRL", styp = "RESP", records = emptyList()),
|
||||
)
|
||||
val p = processor(proc, inbox, StubReqTrack(clock), decoded)
|
||||
p.processOne(ProcState(msgId, ProcStatus.PENDING, updatedAt = Instant.EPOCH))
|
||||
|
||||
assertEquals(ProcStatus.SKIPPED, proc.find(msgId)!!.state)
|
||||
assertEquals("resp-guard:no-open-req", proc.find(msgId)!!.lastError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,34 @@ class ScheduleProcessorTest {
|
||||
assertTrue(log.entries.single().flags.contains(SnapshotFlag.SCHD_ABSENT_DELETED))
|
||||
}
|
||||
|
||||
/** 带了时间条件的应答不是完整名单:回信里的航班整份替换,其余航班不删(`US-07` AC2)。 */
|
||||
@Test
|
||||
fun `partial reply replaces flights in the message and leaves the others`() {
|
||||
val flights = StubFlightState()
|
||||
val events = StubMsgEvents()
|
||||
val projection = StubFlightProjectionPort()
|
||||
seed(flights, "122", LocalDate.of(2026, 12, 15))
|
||||
projection.snapshots["122"] = """{"flid":"122"}"""
|
||||
flights.persistFullState(
|
||||
com.gzzn.omms.msgexchange.domain.flight.FlightSnapshot(
|
||||
"121", LocalDate.of(2026, 12, 15), FlightState.ACTIVE, 1,
|
||||
mapOf("SODT" to "15DEC261723", "REMC" to "old-note"), emptyMap(),
|
||||
),
|
||||
msgId = 1,
|
||||
now = java.time.Instant.now(),
|
||||
)
|
||||
|
||||
val result = processor(flights = flights, events = events, projection = projection)
|
||||
.applyScheduleRecords(head(), message(makeBody("121" to "15DEC261800")), deleteAbsent = false)
|
||||
|
||||
assertEquals(ApplyResult.Succeeded, result)
|
||||
assertEquals(FlightState.ACTIVE, flights.findMainRow("122")!!.state)
|
||||
assertEquals(1L, flights.findMainRow("122")!!.stateVersion)
|
||||
assertTrue(projection.snapshots.containsKey("122"))
|
||||
assertFalse(events.rows.values.any { it.partitionKey == "122" })
|
||||
assertFalse(flights.loadFullSnapshot("121")!!.scalars.containsKey("REMC"))
|
||||
}
|
||||
|
||||
/** 还没被日计划收录的航班(运营日为空)不属于任何覆盖范围,缺席清扫碰不到它。 */
|
||||
@Test
|
||||
fun `flights without an operation day are out of every coverage range`() {
|
||||
|
||||
Reference in New Issue
Block a user