311 lines
11 KiB
Dart
311 lines
11 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))
|
|
..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<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);
|
|
}
|
|
}
|