feat(codec): 观测未落库的 SRVT/VIPF 集合

SRVT/VIPF 只保留在 wire/domain 并计数告警,不落明细表、不参与合并:
出现事实不再被静默丢弃,为 Q13 定案提供真实流量证据([G-SRVT-VIPF])。

- wire DTO:SRVT/SERVICEDATA、VIPF/VIPDATA 与嵌套 VIPT(OPER 为元素属性)
- 出现即留键:缺席与"出现但为空"不再等价;已落库 10 类集合语义不变
- 新增 msgx.pipeline.codec.srvt_seen.total / vipf_seen.total(reference.md 已登记)
- 一并提交此前的 MAFL 文档改动(INV-21/INV-22、flight-state §2.3、G-MAFL 措辞)
This commit is contained in:
windyboy
2026-09-13 09:21:51 +08:00
parent b8738554e6
commit 17a4bfe91b
11 changed files with 283 additions and 18 deletions
@@ -2,6 +2,7 @@ package com.gzzn.omms.msgexchange.codec
import com.gzzn.omms.msgexchange.domain.MsgKind
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
@@ -87,6 +88,79 @@ class JacksonXmlCodecTest {
assertEquals(listOf(mapOf("GTNO" to "0")), body.collections["GTDT"])
}
@Test
fun `SRVT and VIPF sections keep order attributes and nested VIPT`() {
val raw = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>7</SEQN><DTTM>20021010090311</DTTM><TYPE>SCHD</TYPE><STYP>DNLD</STYP></META>
<SCHD>
<RECS>1</RECS>
<FLTR>
<FLID>121112312</FLID>
<ALCD>CA</ALCD><FLNO>CA101</FLNO><SODT>15DEC261723</SODT>
<SRVT OPER="ADD"><SRTC>SR01</SRTC><SRQT>2</SRQT><SRPR>9001</SRPR></SRVT>
<SRVT OPER="DEL"><SRTC>SR02</SRTC></SRVT>
<VIPF OPER="ADD"><VPCD>V001</VPCD><VFES>3</VFES>
<VIPT OPER="UPD"><VSCD>VS01</VSCD><VTQY>4</VTQY><VTET>15DEC262000</VTET></VIPT>
</VIPF>
</FLTR>
</SCHD>
</MSG>
""".trimIndent()
val result = codec.decode(raw) as DecodeResult.Ok
val record = (result.message.body as ScheduleBody).records.single()
assertEquals(2, record.collections["SRVT"]!!.size)
assertEquals("ADD", record.collections["SRVT"]!![0]["OPER"]) // OPER 是元素属性
assertEquals("SR01", record.collections["SRVT"]!![0]["SRTC"])
assertEquals("2", record.collections["SRVT"]!![0]["SRQT"])
assertEquals("9001", record.collections["SRVT"]!![0]["SRPR"])
assertEquals("DEL", record.collections["SRVT"]!![1]["OPER"]) // 保留报文先后顺序
assertEquals("SR02", record.collections["SRVT"]!![1]["SRTC"])
val vip = record.collections["VIPF"]!!.single()
assertEquals("V001", vip["VPCD"])
assertEquals("3", vip["VFES"])
assertEquals("UPD", vip["VIPT_OPER"])
assertEquals("VS01", vip["VIPT_VSCD"])
assertEquals("4", vip["VIPT_VTQY"])
assertEquals("15DEC262000", vip["VIPT_VTET"])
}
@Test
fun `absent and empty SRVT VIPF stay distinguishable while mapped collections keep dropping empties`() {
val withEmpty = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>8</SEQN><DTTM>20021010090311</DTTM><TYPE>SCHD</TYPE><STYP>DNLD</STYP></META>
<SCHD><RECS>1</RECS><FLTR><FLID>121</FLID><SRVT/><VIPF></VIPF><CLDT/></FLTR></SCHD>
</MSG>
""".trimIndent()
val present = ((codec.decode(withEmpty) as DecodeResult.Ok).message.body as ScheduleBody)
.records.single().collections
// 出现但为空段保留键,不再与"未出现"混为一谈 [G-SRVT-VIPF]
assertTrue(present.containsKey("SRVT"))
assertTrue(present.containsKey("VIPF"))
assertTrue(present.getValue("SRVT").all { it.isEmpty() })
assertTrue(present.getValue("VIPF").all { it.isEmpty() })
// 已落库的 10 类集合行为未变:空元素 → 一行空行;缺席 → 没有这个键
assertEquals(listOf(emptyMap<String, String>()), present["CLDT"])
val absent = """
<MSG>
<META><SNDR>AODB</SNDR><SEQN>9</SEQN><DTTM>20021010090311</DTTM><TYPE>SCHD</TYPE><STYP>DNLD</STYP></META>
<SCHD><RECS>1</RECS><FLTR><FLID>121</FLID></FLTR></SCHD>
</MSG>
""".trimIndent()
val absentKeys = ((codec.decode(absent) as DecodeResult.Ok).message.body as ScheduleBody)
.records.single().collections
assertFalse(absentKeys.containsKey("SRVT"))
assertFalse(absentKeys.containsKey("VIPF"))
assertFalse(absentKeys.containsKey("CLDT"))
}
@Test
fun `malformed xml returns MALFORMED not CODEC_ERROR`() {
val err = codec.decode("not-xml") as DecodeResult.Err
@@ -111,6 +111,30 @@ class FlightStateEngineTest {
assertEquals(2, next.stateVersion)
}
@Test
fun `unpersisted collections are carried but never merged until their detail tables exist`() {
val current = FlightSnapshot(
"121", day, FlightState.ACTIVE, 1,
scalars = mapOf("FLNO" to "CA001"),
collections = mapOf("GTDT" to listOf(mapOf("GATE" to "G1"))),
)
val next = FlightStateEngine.snapshotState(
current,
ScheduleRecord(
"121",
scalars = mapOf("FLNO" to "CA002"),
collections = mapOf("SRVT" to listOf(mapOf("SRTC" to "SR01")), "VIPF" to emptyList()),
),
operationDay = day,
keepDeleted = false,
)
// [G-SRVT-VIPF]wire/domain 保留这两个集合,但合并层既不落库,也不因"出现"清空任何明细
assertFalse(next.collections.containsKey("SRVT"))
assertFalse(next.collections.containsKey("VIPF"))
assertEquals(listOf(mapOf("GATE" to "G1")), next.collections["GTDT"])
}
@Test
fun `validation accepts valid snapshot and rejects RECS mismatch`() {
val ok = FlightStateEngine.validateMessage(
@@ -62,6 +62,18 @@ class PipelineMetricsTest {
org.junit.jupiter.api.Assertions.assertTrue(gauge("msgx.pipeline.job.heartbeat_age_seconds") >= 0.0)
}
@Test
fun `unpersisted SRVT VIPF counters are registered and reflect decoded hits`() {
val counters = ctx.getBean(PipelineCounters::class.java)
val srvtBefore = gauge("msgx.pipeline.codec.srvt_seen.total")
val vipfBefore = gauge("msgx.pipeline.codec.vipf_seen.total")
counters.unpersistedCollectionSeenAdd(mapOf("SRVT" to 2, "VIPF" to 1))
assertEquals(srvtBefore + 2.0, gauge("msgx.pipeline.codec.srvt_seen.total"), 0.001)
assertEquals(vipfBefore + 1.0, gauge("msgx.pipeline.codec.vipf_seen.total"), 0.001)
}
private fun gauge(name: String): Double =
requireNotNull(registry.find(name).gauge()) { "gauge not registered: $name" }.value()
}