feat(tools): FS7 v2 dual-read diff — detail tables vs legacy slots (P2-1)
Extract FlightSchdReadAssembler for detail-only, legacy-slot, and merged read paths. Extend FlightStoreDiffTool with diffDualRead strict field comparison. Add counterexample tests (G28/G33/G23, GTNO=3, CKDT dedupe, ABTM ABDG, dual DELY) per review-flight-state-2026-09-08.
This commit is contained in:
+200
@@ -0,0 +1,200 @@
|
||||
package com.gzzn.omms.msgexchange.infra.persistence.jdbc
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.FlightFields
|
||||
|
||||
/**
|
||||
* FLIGHT_SCHD 读侧视图组装(v2 双读对拍 FS7)。
|
||||
*
|
||||
* - [assembleMerged]:生产读路径,明细表优先、槽位列回退(与 legacy flightInfo hash 同构)。
|
||||
* - [assembleDetailOnly]:仅标量 + 明细表集合(v2 权威读)。
|
||||
* - [assembleLegacySlotsOnly]:仅标量 + V1.2.0 槽位/里程碑/航路列重建(legacy 回退读)。
|
||||
*/
|
||||
object FlightSchdReadAssembler {
|
||||
|
||||
/** SCHD.FLTR 标量列 + legacy 派生列(与 V1.1.0 迁移一致)。 */
|
||||
val SCALAR_COLUMNS: List<String> = listOf(
|
||||
"ALCD", "ALSC", "FLNO", "MVIN", "SODT", "FLTY", "FLIN", "ACFT", "RENO",
|
||||
"TAOP", "TAFL", "TAID", "TRML", "MAXP", "CSOP", "CSFT", "MAID",
|
||||
"ESTT", "ACTT", "STND", "PHAG", "CNCL", "REMC", "BOTM", "LACL", "FINT",
|
||||
"APPT", "EGSR", "EGST", "FHAG", "MHAG", "VIPP", "VIPR", "LBNO", "LBWT",
|
||||
"PAXC", "EXSC", "EXSR", "FTSS", "PEDT", "NEAT", "PADT", "NAAT",
|
||||
"ABDG", "LPSDT", "ABN",
|
||||
)
|
||||
|
||||
val EXCEPTION_COLUMNS: List<String> = listOf(
|
||||
"FDIV_DDES", "FDIV_DDIR", "FDIV_REMC", "FRET_REID", "FRET_RSN",
|
||||
"FLAB_ARES", "FLAB_RSN",
|
||||
)
|
||||
|
||||
data class SlotCollection(
|
||||
val key: String,
|
||||
val seqAttr: String,
|
||||
val dedupeAttr: String,
|
||||
val slots: List<Map<String, String>>,
|
||||
)
|
||||
|
||||
private fun slotCollection(
|
||||
key: String,
|
||||
seqAttr: String,
|
||||
dedupeAttr: String,
|
||||
attrs: List<String>,
|
||||
count: Int,
|
||||
renames: Map<String, String> = emptyMap(),
|
||||
): SlotCollection = SlotCollection(
|
||||
key = key,
|
||||
seqAttr = seqAttr,
|
||||
dedupeAttr = dedupeAttr,
|
||||
slots = (1..count).map { i -> attrs.associateWith { attr -> (renames[attr] ?: attr) + i } },
|
||||
)
|
||||
|
||||
val OCCURRENCE_COLLECTIONS: List<SlotCollection> = listOf(
|
||||
slotCollection("GTDT", "GTNO", "GATE", listOf("GATE", "PGOT", "PGCT", "GOTM", "GCTM", "GTYP"), 2),
|
||||
slotCollection(
|
||||
"CKDT", "CKNO", "CHKC",
|
||||
listOf("CHKC", "CCLS", "PCOT", "PCCT", "COTM", "CCTM", "CTYP"), 3,
|
||||
),
|
||||
slotCollection(
|
||||
"CLDT", "CLNO", "BELT",
|
||||
listOf("BELT", "BCLS", "PCOT", "PCCT", "FBAG", "LBAG", "BTYP"), 2,
|
||||
renames = mapOf("PCOT" to "BPCOT", "PCCT" to "BPCCT"),
|
||||
),
|
||||
slotCollection("PSDT", "PSNO", "PSST", listOf("PSST", "STST", "STET"), 2),
|
||||
slotCollection(
|
||||
"CHDT", "CHNO", "CHUT",
|
||||
listOf("CHUT", "CCLS", "PCBT", "PCET", "CBTM", "CETM", "CTYP"), 2,
|
||||
renames = mapOf("CCLS" to "CHCLS", "CTYP" to "CHTYP"),
|
||||
),
|
||||
)
|
||||
|
||||
val MILESTONE_COLUMNS: List<String> = listOf(
|
||||
"DELY_CODE", "DELY_STRT", "DELY_DURA", "DELY_REMC",
|
||||
"ABTM_A", "ABTM_D", "CHOT_ON", "CHOT_OFF",
|
||||
)
|
||||
|
||||
val PATH_COLUMNS: List<String> = listOf("ROUT_PATH", "ERUT_PATH")
|
||||
|
||||
val TEXT_COLUMNS: List<String> = listOf("SRVT_TEXT", "VIPF_TEXT", "MAFL_TEXT")
|
||||
|
||||
val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS +
|
||||
OCCURRENCE_COLLECTIONS.flatMap { c -> c.slots.flatMap(Map<String, String>::values) } +
|
||||
MILESTONE_COLUMNS + PATH_COLUMNS + TEXT_COLUMNS
|
||||
|
||||
private val mapper = ObjectMapper()
|
||||
|
||||
/** 生产读路径:明细表优先,缺失时由槽位/里程碑列回退重建。 */
|
||||
fun assembleMerged(
|
||||
flid: String,
|
||||
row: Map<String, String?>,
|
||||
detailCollections: Map<String, String>,
|
||||
): FlightFields {
|
||||
val fields = assembleScalarsAndExceptions(flid, row)
|
||||
val detailKeys = detailCollections.keys
|
||||
if ("DELY" !in detailKeys) delayView(row)?.let { fields["DELY"] = it }
|
||||
if ("ABTM" !in detailKeys) airbridgeView(row)?.let { fields["ABTM"] = it }
|
||||
if ("CHOT" !in detailKeys) chocksView(row)?.let { fields["CHOT"] = it }
|
||||
if ("ROUT" !in detailKeys) row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) }
|
||||
if ("ERUT" !in detailKeys) row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) }
|
||||
OCCURRENCE_COLLECTIONS.forEach { c ->
|
||||
if (c.key !in detailKeys) slotView(c, row)?.let { fields[c.key] = it }
|
||||
}
|
||||
detailCollections.forEach { (key, value) -> fields[key] = value }
|
||||
return fields
|
||||
}
|
||||
|
||||
/** v2 权威读:标量 + 明细表集合,不读槽位/里程碑列。 */
|
||||
fun assembleDetailOnly(
|
||||
flid: String,
|
||||
row: Map<String, String?>,
|
||||
detailCollections: Map<String, String>,
|
||||
): FlightFields {
|
||||
val fields = assembleScalarsAndExceptions(flid, row)
|
||||
detailCollections.forEach { (key, value) -> fields[key] = value }
|
||||
return fields
|
||||
}
|
||||
|
||||
/** legacy 回退读:标量 + 槽位/里程碑/航路列重建,不叠加明细表。 */
|
||||
fun assembleLegacySlotsOnly(flid: String, row: Map<String, String?>): FlightFields {
|
||||
val fields = assembleScalarsAndExceptions(flid, row)
|
||||
delayView(row)?.let { fields["DELY"] = it }
|
||||
airbridgeView(row)?.let { fields["ABTM"] = it }
|
||||
chocksView(row)?.let { fields["CHOT"] = it }
|
||||
row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) }
|
||||
row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) }
|
||||
OCCURRENCE_COLLECTIONS.forEach { c -> slotView(c, row)?.let { fields[c.key] = it } }
|
||||
return fields
|
||||
}
|
||||
|
||||
private fun assembleScalarsAndExceptions(flid: String, row: Map<String, String?>): LinkedHashMap<String, String> {
|
||||
val fields = linkedMapOf<String, String>()
|
||||
fields["FLID"] = flid
|
||||
SCALAR_COLUMNS.forEach { column -> row[column]?.let { fields[column] = it } }
|
||||
TEXT_COLUMNS.forEach { column -> row[column]?.let { fields[column.removeSuffix("_TEXT")] = it } }
|
||||
exceptionView("FDIV", listOf("DDES" to "FDIV_DDES", "DDIR" to "FDIV_DDIR", "REMC" to "FDIV_REMC"), row)
|
||||
?.let { fields["FDIV"] = it }
|
||||
exceptionView("FRET", listOf("REID" to "FRET_REID", "RSN" to "FRET_RSN"), row)
|
||||
?.let { fields["FRET"] = it }
|
||||
exceptionView("FLAB", listOf("ARES" to "FLAB_ARES", "RSN" to "FLAB_RSN"), row)
|
||||
?.let { fields["FLAB"] = it }
|
||||
return fields
|
||||
}
|
||||
|
||||
private fun exceptionView(key: String, attrs: List<Pair<String, String>>, row: Map<String, String?>): String? {
|
||||
val node = mapper.createObjectNode()
|
||||
attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
|
||||
return if (node.size() == 0) null else mapper.writeValueAsString(node)
|
||||
}
|
||||
|
||||
private fun delayView(row: Map<String, String?>): String? {
|
||||
val node = mapper.createObjectNode()
|
||||
listOf("CODE" to "DELY_CODE", "STRT" to "DELY_STRT", "DURA" to "DELY_DURA", "REMC" to "DELY_REMC")
|
||||
.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
|
||||
return if (node.size() == 0) null else mapper.writeValueAsString(arrayOf(node))
|
||||
}
|
||||
|
||||
private fun airbridgeView(row: Map<String, String?>): String? {
|
||||
val items = mutableListOf<ObjectNode>()
|
||||
row["ABTM_A"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("ASNO", "1"); put("ABOP", "A"); put("AOTM", it) }
|
||||
}
|
||||
row["ABTM_D"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("ASNO", "2"); put("ABOP", "D"); put("AOTM", it) }
|
||||
}
|
||||
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
|
||||
}
|
||||
|
||||
private fun chocksView(row: Map<String, String?>): String? {
|
||||
val items = mutableListOf<ObjectNode>()
|
||||
row["CHOT_ON"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "ON"); put("CHTM", it) }
|
||||
}
|
||||
row["CHOT_OFF"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "OFF"); put("CHTM", it) }
|
||||
}
|
||||
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
|
||||
}
|
||||
|
||||
private fun routeView(path: String): String {
|
||||
val items = path.split(",").mapIndexed { index, segment ->
|
||||
val parts = segment.split("/", limit = 3)
|
||||
mapper.createObjectNode().apply {
|
||||
put("RTNO", (index + 1).toString())
|
||||
listOf("APCD", "SCAT", "SCDT").forEachIndexed { i, attr ->
|
||||
if (i < parts.size && parts[i].isNotEmpty()) put(attr, parts[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapper.writeValueAsString(items)
|
||||
}
|
||||
|
||||
private fun slotView(c: SlotCollection, row: Map<String, String?>): String? {
|
||||
val items = c.slots.mapIndexed { index, slot ->
|
||||
mapper.createObjectNode().apply {
|
||||
put(c.seqAttr, (index + 1).toString())
|
||||
slot.forEach { (attr, column) -> row[column]?.let { put(attr, it) } }
|
||||
}
|
||||
}.filterNot { it.size() <= 1 }
|
||||
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
|
||||
}
|
||||
}
|
||||
+3
-92
@@ -398,9 +398,7 @@ class JdbcFlightSchdRepository(
|
||||
private val SCALAR_WRITE_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS + TEXT_COLUMNS
|
||||
|
||||
/** 库列名全集(读侧 SELECT 与视图重建的稳定顺序,与 V1.2.0 迁移一致)。 */
|
||||
private val ALL_COLUMNS: List<String> = SCALAR_COLUMNS + EXCEPTION_COLUMNS +
|
||||
OCCURRENCE_COLLECTIONS.flatMap { c -> c.slots.flatMap(Map<String, String>::values) } +
|
||||
MILESTONE_COLUMNS + PATH_COLUMNS + TEXT_COLUMNS
|
||||
private val ALL_COLUMNS: List<String> = FlightSchdReadAssembler.ALL_COLUMNS
|
||||
|
||||
private val SCALAR_KEY_SET: Set<String> = SCALAR_COLUMNS.toSet()
|
||||
|
||||
@@ -659,102 +657,15 @@ class JdbcFlightSchdRepository(
|
||||
return totalDeleted
|
||||
}
|
||||
|
||||
/** 行 → 字段视图:标量列直读,16 个集合键由平铺列重建,与 legacy flightInfo hash 同构
|
||||
* (KAFKA_SCHD 线格式与 FS7 对拍逐字段比对均依赖该同构视图)。 */
|
||||
/** 行 → 字段视图:标量列直读,集合键由明细表或槽位列重建(FS7 双读对拍见 FlightSchdReadAssembler)。 */
|
||||
private fun mapFlightRow(rs: java.sql.ResultSet): Pair<String, FlightFields> {
|
||||
val flid = rs.getString("flid")
|
||||
val fields = linkedMapOf<String, String>()
|
||||
fields["FLID"] = flid // 视图与 legacy flightInfo hash 同构:FLID 字段常在
|
||||
val row = linkedMapOf<String, String?>()
|
||||
for (column in ALL_COLUMNS) {
|
||||
row[column] = rs.getString(column)
|
||||
}
|
||||
|
||||
SCALAR_COLUMNS.forEach { column -> row[column]?.let { fields[column] = it } }
|
||||
TEXT_COLUMNS.forEach { column -> row[column]?.let { fields[column.removeSuffix("_TEXT")] = it } }
|
||||
|
||||
exceptionView("FDIV", listOf("DDES" to "FDIV_DDES", "DDIR" to "FDIV_DDIR", "REMC" to "FDIV_REMC"), row)
|
||||
?.let { fields["FDIV"] = it }
|
||||
exceptionView("FRET", listOf("REID" to "FRET_REID", "RSN" to "FRET_RSN"), row)
|
||||
?.let { fields["FRET"] = it }
|
||||
exceptionView("FLAB", listOf("ARES" to "FLAB_ARES", "RSN" to "FLAB_RSN"), row)
|
||||
?.let { fields["FLAB"] = it }
|
||||
val detailCollections = FlightDetailTables.loadCollections(ds, flid)
|
||||
val detailKeys = detailCollections.keys
|
||||
if ("DELY" !in detailKeys) delayView(row)?.let { fields["DELY"] = it }
|
||||
if ("ABTM" !in detailKeys) airbridgeView(row)?.let { fields["ABTM"] = it }
|
||||
if ("CHOT" !in detailKeys) chocksView(row)?.let { fields["CHOT"] = it }
|
||||
if ("ROUT" !in detailKeys) row["ROUT_PATH"]?.let { fields["ROUT"] = routeView(it) }
|
||||
if ("ERUT" !in detailKeys) row["ERUT_PATH"]?.let { fields["ERUT"] = routeView(it) }
|
||||
OCCURRENCE_COLLECTIONS.forEach { c ->
|
||||
if (c.key !in detailKeys) slotView(c, row)?.let { fields[c.key] = it }
|
||||
}
|
||||
detailCollections.forEach { (key, value) -> fields[key] = value }
|
||||
return flid to fields
|
||||
}
|
||||
|
||||
/** 前缀标量列 → 单值异常对象视图(缺失属性省略;全空 → 无键)。 */
|
||||
private fun exceptionView(key: String, attrs: List<Pair<String, String>>, row: Map<String, String?>): String? {
|
||||
val node = mapper.createObjectNode()
|
||||
attrs.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
|
||||
return if (node.size() == 0) null else mapper.writeValueAsString(node)
|
||||
}
|
||||
|
||||
/** 延误列 → 0..1 条延误对象数组视图(legacy DELY 为可重复标签的数组形状)。 */
|
||||
private fun delayView(row: Map<String, String?>): String? {
|
||||
val node = mapper.createObjectNode()
|
||||
listOf("CODE" to "DELY_CODE", "STRT" to "DELY_STRT", "DURA" to "DELY_DURA", "REMC" to "DELY_REMC")
|
||||
.forEach { (attr, column) -> row[column]?.let { node.put(attr, it) } }
|
||||
return if (node.size() == 0) null else mapper.writeValueAsString(arrayOf(node))
|
||||
}
|
||||
|
||||
/** 靠撤桥时刻 → 保障里程碑数组视图(ASNO/ABOP 为操作序合成;桥号见派生列 ABDG)。 */
|
||||
private fun airbridgeView(row: Map<String, String?>): String? {
|
||||
val items = mutableListOf<ObjectNode>()
|
||||
row["ABTM_A"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("ASNO", "1"); put("ABOP", "A"); put("AOTM", it) }
|
||||
}
|
||||
row["ABTM_D"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("ASNO", "2"); put("ABOP", "D"); put("AOTM", it) }
|
||||
}
|
||||
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
|
||||
}
|
||||
|
||||
/** 轮挡时刻 → 保障里程碑数组视图(CHID 标识上/下轮挡;机位见派生列 STND)。 */
|
||||
private fun chocksView(row: Map<String, String?>): String? {
|
||||
val items = mutableListOf<ObjectNode>()
|
||||
row["CHOT_ON"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "ON"); put("CHTM", it) }
|
||||
}
|
||||
row["CHOT_OFF"]?.let {
|
||||
items += mapper.createObjectNode().apply { put("CSNO", "1"); put("CHID", "OFF"); put("CHTM", it) }
|
||||
}
|
||||
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
|
||||
}
|
||||
|
||||
/** 紧凑航路字串 → legacy ROUTEDAILY 数组视图(RTNO 按段序合成;空槽位属性省略)。 */
|
||||
private fun routeView(path: String): String {
|
||||
val items = path.split(",").mapIndexed { index, segment ->
|
||||
val parts = segment.split("/", limit = 3)
|
||||
mapper.createObjectNode().apply {
|
||||
put("RTNO", (index + 1).toString())
|
||||
listOf("APCD", "SCAT", "SCDT").forEachIndexed { i, attr ->
|
||||
if (i < parts.size && parts[i].isNotEmpty()) put(attr, parts[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapper.writeValueAsString(items)
|
||||
}
|
||||
|
||||
/** 出现槽位列 → legacy 集合数组视图(空槽位跳过;序号属性按槽位合成)。 */
|
||||
private fun slotView(c: SlotCollection, row: Map<String, String?>): String? {
|
||||
val items = c.slots.mapIndexed { index, slot ->
|
||||
mapper.createObjectNode().apply {
|
||||
put(c.seqAttr, (index + 1).toString())
|
||||
slot.forEach { (attr, column) -> row[column]?.let { put(attr, it) } }
|
||||
}
|
||||
}.filterNot { it.size() <= 1 }
|
||||
return if (items.isEmpty()) null else mapper.writeValueAsString(items)
|
||||
return flid to FlightSchdReadAssembler.assembleMerged(flid, row, detailCollections)
|
||||
}
|
||||
|
||||
override fun findByFlid(flid: String): FlightFields? =
|
||||
|
||||
@@ -38,6 +38,10 @@ class FlightStoreDiffTool(
|
||||
MISSING_IN_PG,
|
||||
/** 非预期偏离:Redis 缺失且非已知迁移保护行 */
|
||||
UNEXPECTED_EXTRA_IN_PG,
|
||||
/** v2 双读:明细表读路径缺失该航班 */
|
||||
MISSING_IN_DETAIL,
|
||||
/** v2 双读:legacy 槽位读路径缺失该航班 */
|
||||
MISSING_IN_LEGACY,
|
||||
}
|
||||
|
||||
data class Deviation(
|
||||
@@ -86,6 +90,83 @@ class FlightStoreDiffTool(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* FS7 v2 双读对拍:明细表组装视图 vs legacy 槽位/里程碑列组装视图。
|
||||
* 严格逐字段比对,禁止通过忽略 GTNO/第三资源/ABDG 等规则洗绿。
|
||||
*/
|
||||
fun diffDualRead(
|
||||
detailViews: Map<String, FlightFields>,
|
||||
legacySlotViews: Map<String, FlightFields>,
|
||||
): DiffReport = diffFlightFieldMaps(detailViews, legacySlotViews, missingInPrimary = DeviationKind.MISSING_IN_DETAIL, missingInReference = DeviationKind.MISSING_IN_LEGACY)
|
||||
|
||||
/** 两侧均为 FlightFields 的严格逐字段比对(供双读对拍与单测复用)。 */
|
||||
fun diffFlightFieldMaps(
|
||||
primaryFlights: Map<String, FlightFields>,
|
||||
referenceFlights: Map<String, FlightFields>,
|
||||
missingInPrimary: DeviationKind = DeviationKind.MISSING_IN_PG,
|
||||
missingInReference: DeviationKind = DeviationKind.UNEXPECTED_EXTRA_IN_PG,
|
||||
): DiffReport {
|
||||
val allFlids = (primaryFlights.keys + referenceFlights.keys).toSortedSet()
|
||||
var matched = 0
|
||||
val unexpected = mutableListOf<Deviation>()
|
||||
|
||||
for (flid in allFlids) {
|
||||
val primary = primaryFlights[flid]
|
||||
val reference = referenceFlights[flid]
|
||||
|
||||
if (primary == null) {
|
||||
unexpected += Deviation(
|
||||
flid = flid,
|
||||
kind = missingInPrimary,
|
||||
legacyValue = reference,
|
||||
detail = "Flight present in reference view but missing in primary view",
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (reference == null) {
|
||||
unexpected += Deviation(
|
||||
flid = flid,
|
||||
kind = missingInReference,
|
||||
pgValue = primary,
|
||||
detail = "Flight present in primary view but missing in reference view",
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
val mismatches = compareFlightFields(flid, primary, reference)
|
||||
if (mismatches.isEmpty()) matched++ else unexpected.addAll(mismatches)
|
||||
}
|
||||
|
||||
return DiffReport(
|
||||
totalPg = primaryFlights.size,
|
||||
totalLegacy = referenceFlights.size,
|
||||
matchedCount = matched,
|
||||
knownDeviations = emptyList(),
|
||||
unexpectedDeviations = unexpected,
|
||||
)
|
||||
}
|
||||
|
||||
/** 单航班 FlightFields 对称比对(集合值为 JSON 文本时递归解析)。 */
|
||||
internal fun compareFlightFields(flid: String, left: FlightFields, right: FlightFields): List<Deviation> {
|
||||
val mismatches = mutableListOf<Deviation>()
|
||||
val allKeys = (left.keys + right.keys).toSortedSet()
|
||||
for (key in allKeys) {
|
||||
compareFieldValues(flid, key, left[key], right[key], mismatches)
|
||||
}
|
||||
return mismatches
|
||||
}
|
||||
|
||||
private fun compareFieldValues(flid: String, path: String, left: String?, right: String?, acc: MutableList<Deviation>) {
|
||||
val leftNode = left?.let { parseFieldValue(it) }
|
||||
val rightNode = right?.let { parseFieldValue(it) }
|
||||
compareNodes(flid, path, leftNode, rightNode, acc)
|
||||
}
|
||||
|
||||
private fun parseFieldValue(value: String): JsonNode =
|
||||
runCatching { mapper.readTree(value) }.getOrNull()
|
||||
?: mapper.nodeFactory.textNode(value)
|
||||
|
||||
/**
|
||||
* 执行全量比对。
|
||||
* @param pgFlights nextgen FLIGHT_SCHD 行(flid -> 宽表列值映射)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.gzzn.omms.msgexchange.tools
|
||||
|
||||
import com.gzzn.omms.msgexchange.infra.persistence.jdbc.FlightSchdReadAssembler
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* FS7 v2 双读对拍:明细表组装 vs legacy 槽位列组装。
|
||||
* 反例来自 review-flight-state-2026-09-08 §12.1 / SIS §3.34.3(G28/G33/G23)。
|
||||
*/
|
||||
class FlightStoreDualReadTest {
|
||||
|
||||
private val tool = FlightStoreDiffTool()
|
||||
private val flid = "TEST_DUAL_READ"
|
||||
|
||||
private fun detailView(vararg collections: Pair<String, String>) = mapOf(
|
||||
flid to linkedMapOf(
|
||||
"FLID" to flid,
|
||||
*collections,
|
||||
),
|
||||
)
|
||||
|
||||
private fun legacyView(vararg collections: Pair<String, String>) = mapOf(
|
||||
flid to linkedMapOf(
|
||||
"FLID" to flid,
|
||||
*collections,
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `Dual read GREEN when detail and legacy slot views agree within slot capacity`() {
|
||||
val gtdt = """[{"GTNO":"1","GATE":"G28","PGOT":"07SEP260800"},{"GTNO":"2","GATE":"G33"}]"""
|
||||
val detail = detailView("GTDT" to gtdt)
|
||||
val legacy = legacyView("GTDT" to gtdt)
|
||||
|
||||
val report = tool.diffDualRead(detail, legacy)
|
||||
assertTrue(report.isGreen)
|
||||
assertEquals(1, report.matchedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dual read RED for SIS three-gate sample G28 G33 G23 - third gate must not be ignored`() {
|
||||
val detailGtdt = """[
|
||||
{"GTNO":"1","GATE":"G28"},
|
||||
{"GTNO":"2","GATE":"G33"},
|
||||
{"GTNO":"3","GATE":"G23"}
|
||||
]""".replace("\n", "").replace(" ", "")
|
||||
val legacyGtdt = """[{"GTNO":"1","GATE":"G28"},{"GTNO":"2","GATE":"G33"}]"""
|
||||
|
||||
val report = tool.diffDualRead(
|
||||
detailView("GTDT" to detailGtdt),
|
||||
legacyView("GTDT" to legacyGtdt),
|
||||
)
|
||||
assertFalse(report.isGreen)
|
||||
assertTrue(report.unexpectedDeviations.any { it.path == "GTDT" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dual read RED for non-contiguous GTNO=3 - must not rewrite to GTNO=1`() {
|
||||
val detailGtdt = """[{"GTNO":"3","GATE":"G28"}]"""
|
||||
val legacyGtdt = """[{"GTNO":"1","GATE":"G28"}]"""
|
||||
|
||||
val report = tool.diffDualRead(
|
||||
detailView("GTDT" to detailGtdt),
|
||||
legacyView("GTDT" to legacyGtdt),
|
||||
)
|
||||
assertFalse(report.isGreen)
|
||||
assertTrue(report.unexpectedDeviations.any { it.path?.startsWith("GTDT") == true })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dual read RED for duplicate CHKC different cabin classes - legacy dedupe loses row`() {
|
||||
val detailCkdt = """[
|
||||
{"CKNO":"1","CHKC":"01","CCLS":"F"},
|
||||
{"CKNO":"2","CHKC":"01","CCLS":"Y"}
|
||||
]""".replace("\n", "").replace(" ", "")
|
||||
val legacyCkdt = """[{"CKNO":"1","CHKC":"01","CCLS":"F"}]"""
|
||||
|
||||
val report = tool.diffDualRead(
|
||||
detailView("CKDT" to detailCkdt),
|
||||
legacyView("CKDT" to legacyCkdt),
|
||||
)
|
||||
assertFalse(report.isGreen)
|
||||
assertTrue(report.unexpectedDeviations.any { it.path == "CKDT" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dual read RED for two bridge ops with ABDG - legacy milestone view drops bridge id`() {
|
||||
val detailAbtm = """[
|
||||
{"ASNO":"1","ABDG":"B12","ABOP":"A","AOTM":"07SEP260730"},
|
||||
{"ASNO":"2","ABDG":"B15","ABOP":"D","AOTM":"07SEP261200"}
|
||||
]""".replace("\n", "").replace(" ", "")
|
||||
val legacyAbtm = """[
|
||||
{"ASNO":"1","ABOP":"A","AOTM":"07SEP260730"},
|
||||
{"ASNO":"2","ABOP":"D","AOTM":"07SEP261200"}
|
||||
]""".replace("\n", "").replace(" ", "")
|
||||
|
||||
val report = tool.diffDualRead(
|
||||
detailView("ABTM" to detailAbtm),
|
||||
legacyView("ABTM" to legacyAbtm),
|
||||
)
|
||||
assertFalse(report.isGreen)
|
||||
assertTrue(report.unexpectedDeviations.any { it.path?.contains("ABDG") == true || it.path?.startsWith("ABTM") == true })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dual read RED for two DELY reasons - legacy keeps only first`() {
|
||||
val detailDely = """[
|
||||
{"DLNO":"1","CODE":"01","STRT":"07SEP260800","DURA":"30"},
|
||||
{"DLNO":"2","CODE":"02","STRT":"07SEP260900","DURA":"15"}
|
||||
]""".replace("\n", "").replace(" ", "")
|
||||
val legacyDely = """[{"CODE":"01","STRT":"07SEP260800","DURA":"30"}]"""
|
||||
|
||||
val report = tool.diffDualRead(
|
||||
detailView("DELY" to detailDely),
|
||||
legacyView("DELY" to legacyDely),
|
||||
)
|
||||
assertFalse(report.isGreen)
|
||||
assertTrue(report.unexpectedDeviations.any { it.path == "DELY" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FlightSchdReadAssembler exposes lossy legacy slot view for three-gate SIS sample`() {
|
||||
val row = mapOf(
|
||||
"GATE1" to "G28",
|
||||
"GATE2" to "G33",
|
||||
)
|
||||
val detailCollections = mapOf(
|
||||
"GTDT" to """[
|
||||
{"GTNO":"1","GATE":"G28"},
|
||||
{"GTNO":"2","GATE":"G33"},
|
||||
{"GTNO":"3","GATE":"G23"}
|
||||
]""".replace("\n", "").replace(" ", ""),
|
||||
)
|
||||
|
||||
val detailView = FlightSchdReadAssembler.assembleDetailOnly(flid, row, detailCollections)
|
||||
val legacyView = FlightSchdReadAssembler.assembleLegacySlotsOnly(flid, row)
|
||||
|
||||
val report = tool.diffDualRead(mapOf(flid to detailView), mapOf(flid to legacyView))
|
||||
assertFalse(report.isGreen)
|
||||
assertTrue(legacyView["GTDT"]!!.contains("G23").not())
|
||||
assertTrue(detailView["GTDT"]!!.contains("G23"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FlightSchdReadAssembler merged view follows detail table when present`() {
|
||||
val row = mapOf("GATE1" to "OLD")
|
||||
val detailCollections = mapOf(
|
||||
"GTDT" to """[{"GTNO":"1","GATE":"G28"}]""",
|
||||
)
|
||||
val merged = FlightSchdReadAssembler.assembleMerged(flid, row, detailCollections)
|
||||
assertEquals(detailCollections["GTDT"], merged["GTDT"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user