uto_manager/lib/domain/services/mtoe_validation_engine.dart

302 lines
10 KiB
Dart

import 'package:uto_manager/domain/entities/echelon.dart';
import 'package:uto_manager/domain/entities/personnel_record.dart';
import 'package:uto_manager/domain/entities/position_slot.dart';
import 'package:uto_manager/domain/entities/unit_node.dart';
import 'package:uto_manager/domain/value_objects/duty_mos.dart';
// ---------------------------------------------------------------------------
// Finding model
// ---------------------------------------------------------------------------
/// Severity level of a validation finding.
enum FindingSeverity {
/// Immediate mission impact — must be resolved.
critical,
/// Potential mission impact — should be resolved.
warning,
/// Informational — no immediate impact.
advisory,
}
/// A single MTOE validation finding produced by [MtoeValidationEngine].
class ValidationFinding {
const ValidationFinding({
required this.severity,
required this.findingCode,
required this.description,
this.unitId,
this.slotId,
});
/// Severity level of this finding.
final FindingSeverity severity;
/// Short machine-readable code identifying the rule that fired.
final String findingCode;
/// Human-readable description of the finding.
final String description;
/// The unit this finding applies to, if applicable.
final String? unitId;
/// The position slot this finding applies to, if applicable.
final String? slotId;
@override
String toString() => '[${severity.name.toUpperCase()}] $findingCode — $description';
}
// ---------------------------------------------------------------------------
// Engine
// ---------------------------------------------------------------------------
/// Real-time MTOE domain validation engine.
///
/// Evaluates a unit tree snapshot against 2026 doctrinal rules and returns
/// a list of [ValidationFinding]s. This service is pure domain logic with
/// no infrastructure dependencies and is safe to run in a background Isolate.
///
/// Rules evaluated:
/// - **V001** MOS mismatch: assigned soldier's primary MOS ≠ slot's duty MOS
/// - **V002** Rank mismatch: assigned soldier's pay grade < slot's authorized rank
/// - **V003** Vacant key leader: key leader slot is unfilled
/// - **V004** Missing combat medic: maneuver platoon has no 68W slot
/// - **V005** Missing fire support: maneuver platoon has no 13F slot
/// - **V006** Structural hierarchy violation: invalid parent-child echelon
/// - **V007** Manning below 70%: unit fill rate < 70% of authorized strength
class MtoeValidationEngine {
const MtoeValidationEngine();
/// Runs all validation rules against the provided [units], [slots], and
/// [personnel]. Returns a (possibly empty) list of [ValidationFinding]s.
List<ValidationFinding> validate({
required List<UnitNode> units,
required List<PositionSlot> slots,
required List<PersonnelRecord> personnel,
}) {
final findings = <ValidationFinding>[];
// Build lookup maps for efficiency.
final slotsByUnit = <String, List<PositionSlot>>{};
for (final slot in slots) {
slotsByUnit.putIfAbsent(slot.unitId, () => []).add(slot);
}
final personnelById = {for (final p in personnel) p.soldierId: p};
for (final unit in units) {
final unitSlots = slotsByUnit[unit.unitId] ?? [];
findings.addAll(_checkMosMismatches(unit, unitSlots, personnelById));
findings.addAll(_checkRankMismatches(unit, unitSlots, personnelById));
findings.addAll(_checkVacantKeyLeaders(unit, unitSlots));
findings.addAll(_checkMissingCombatMedic(unit, unitSlots));
findings.addAll(_checkMissingFireSupport(unit, unitSlots));
findings.addAll(_checkManning(unit, unitSlots));
}
findings.addAll(_checkStructuralHierarchy(units));
return findings;
}
// ---------------------------------------------------------------------------
// V001 — MOS Mismatch
// ---------------------------------------------------------------------------
List<ValidationFinding> _checkMosMismatches(
UnitNode unit,
List<PositionSlot> slots,
Map<String, PersonnelRecord> personnelById,
) {
final findings = <ValidationFinding>[];
for (final slot in slots) {
if (slot.isVacant) continue;
final soldier = personnelById[slot.assignedPersonnelId];
if (soldier == null) continue;
if (soldier.primaryMos != slot.dutyMos) {
findings.add(ValidationFinding(
severity: FindingSeverity.warning,
findingCode: 'V001',
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
'${soldier.name} has MOS ${soldier.primaryMos.code} but slot '
'requires ${slot.dutyMos.code}.',
unitId: unit.unitId,
slotId: slot.slotId,
));
}
}
return findings;
}
// ---------------------------------------------------------------------------
// V002 — Rank Mismatch
// ---------------------------------------------------------------------------
List<ValidationFinding> _checkRankMismatches(
UnitNode unit,
List<PositionSlot> slots,
Map<String, PersonnelRecord> personnelById,
) {
final findings = <ValidationFinding>[];
for (final slot in slots) {
if (slot.isVacant) continue;
final soldier = personnelById[slot.assignedPersonnelId];
if (soldier == null) continue;
if (!soldier.payGrade.meetsMinimum(slot.authorizedRank)) {
findings.add(ValidationFinding(
severity: FindingSeverity.warning,
findingCode: 'V002',
description: '${unit.name} — "${slot.positionTitle}": assigned soldier '
'${soldier.name} is ${soldier.payGrade.payGrade} but slot '
'requires minimum ${slot.authorizedRank.payGrade}.',
unitId: unit.unitId,
slotId: slot.slotId,
));
}
}
return findings;
}
// ---------------------------------------------------------------------------
// V003 — Vacant Key Leader
// ---------------------------------------------------------------------------
List<ValidationFinding> _checkVacantKeyLeaders(
UnitNode unit,
List<PositionSlot> slots,
) {
final findings = <ValidationFinding>[];
for (final slot in slots) {
if (slot.isKeyLeader && slot.isVacant) {
findings.add(ValidationFinding(
severity: FindingSeverity.critical,
findingCode: 'V003',
description: '${unit.name} — Key leader position "${slot.positionTitle}" '
'(${slot.dutyMos.code}/${slot.authorizedRank.payGrade}) is vacant.',
unitId: unit.unitId,
slotId: slot.slotId,
));
}
}
return findings;
}
// ---------------------------------------------------------------------------
// V004 — Missing Combat Medic (68W) in maneuver platoon
// ---------------------------------------------------------------------------
List<ValidationFinding> _checkMissingCombatMedic(
UnitNode unit,
List<PositionSlot> slots,
) {
if (unit.echelon != Echelon.platoon) return [];
final has68W = slots.any((s) => s.dutyMos == DutyMos.unvalidated('68W'));
if (!has68W) {
return [
ValidationFinding(
severity: FindingSeverity.advisory,
findingCode: 'V004',
description: '${unit.name} — Platoon has no Combat Medic (68W) position slot. '
'2026 doctrine requires an organic 68W at platoon level.',
unitId: unit.unitId,
),
];
}
return [];
}
// ---------------------------------------------------------------------------
// V005 — Missing Fire Support Specialist (13F) in maneuver platoon
// ---------------------------------------------------------------------------
List<ValidationFinding> _checkMissingFireSupport(
UnitNode unit,
List<PositionSlot> slots,
) {
if (unit.echelon != Echelon.platoon) return [];
final has13F = slots.any((s) => s.dutyMos == DutyMos.unvalidated('13F'));
if (!has13F) {
return [
ValidationFinding(
severity: FindingSeverity.advisory,
findingCode: 'V005',
description: '${unit.name} — Platoon has no Fire Support Specialist (13F) '
'position slot. 2026 doctrine recommends organic FSO at platoon level.',
unitId: unit.unitId,
),
];
}
return [];
}
// ---------------------------------------------------------------------------
// V006 — Structural Hierarchy Violation
// ---------------------------------------------------------------------------
List<ValidationFinding> _checkStructuralHierarchy(List<UnitNode> units) {
final findings = <ValidationFinding>[];
final unitById = {for (final u in units) u.unitId: u};
for (final unit in units) {
if (unit.parentUnitId == null) continue;
final parent = unitById[unit.parentUnitId];
if (parent == null) continue;
if (!unit.echelon.isValidParent(parent.echelon)) {
findings.add(ValidationFinding(
severity: FindingSeverity.critical,
findingCode: 'V006',
description: '"${unit.name}" (${unit.echelon.displayName}) is assigned under '
'"${parent.name}" (${parent.echelon.displayName}). '
'A ${unit.echelon.displayName} must be subordinate to a '
'${_expectedParentEchelon(unit.echelon)?.displayName ?? "root"}.',
unitId: unit.unitId,
));
}
}
return findings;
}
// ---------------------------------------------------------------------------
// V007 — Manning Below 70%
// ---------------------------------------------------------------------------
List<ValidationFinding> _checkManning(
UnitNode unit,
List<PositionSlot> slots,
) {
if (slots.isEmpty) return [];
final filled = slots.where((s) => s.isFilled).length;
final fillRate = filled / slots.length;
if (fillRate < 0.70) {
final pct = (fillRate * 100).round();
return [
ValidationFinding(
severity: FindingSeverity.warning,
findingCode: 'V007',
description: '${unit.name} — Manning at $pct% ($filled/${slots.length} slots '
'filled). Doctrine requires ≥70% fill rate for mission readiness.',
unitId: unit.unitId,
),
];
}
return [];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Echelon? _expectedParentEchelon(Echelon echelon) {
if (echelon == Echelon.brigade) return null;
return Echelon.values.firstWhere((e) => e.level == echelon.level - 1);
}
}