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 validate({ required List units, required List slots, required List personnel, }) { final findings = []; // Build lookup maps for efficiency. final slotsByUnit = >{}; 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)) ..addAll(_checkRankMismatches(unit, unitSlots, personnelById)) ..addAll(_checkVacantKeyLeaders(unit, unitSlots)) ..addAll(_checkMissingCombatMedic(unit, unitSlots)) ..addAll(_checkMissingFireSupport(unit, unitSlots)) ..addAll(_checkManning(unit, unitSlots)); } findings.addAll(_checkStructuralHierarchy(units)); return findings; } // --------------------------------------------------------------------------- // V001 — MOS Mismatch // --------------------------------------------------------------------------- List _checkMosMismatches( UnitNode unit, List slots, Map personnelById, ) { final findings = []; 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 _checkRankMismatches( UnitNode unit, List slots, Map personnelById, ) { final findings = []; 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 _checkVacantKeyLeaders( UnitNode unit, List slots, ) { final findings = []; 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 _checkMissingCombatMedic( UnitNode unit, List 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 _checkMissingFireSupport( UnitNode unit, List 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 _checkStructuralHierarchy(List units) { final findings = []; 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 _checkManning( UnitNode unit, List 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); } }