uto_manager/lib/domain/services/unit_tree_service.dart

240 lines
7.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
/// Result of a doctrinal validation check.
class ValidationResult {
/// Creates a [ValidationResult] with explicit [isValid] and optional [reason].
const ValidationResult({required this.isValid, this.reason});
/// Creates a failing validation result with a [reason].
ValidationResult.invalid(this.reason) : isValid = false;
/// Whether the operation is doctrinally valid.
final bool isValid;
/// Human-readable reason if [isValid] is false.
final String? reason;
/// A passing validation result.
static const valid = ValidationResult(isValid: true);
}
/// Represents a node in the unit tree with its children and slots.
class UnitTreeNode {
const UnitTreeNode({
required this.unit,
required this.children,
required this.slots,
});
/// The unit node at this position in the tree.
final UnitNode unit;
/// Direct child [UnitTreeNode]s.
final List<UnitTreeNode> children;
/// Position slots belonging to this unit.
final List<PositionSlot> slots;
/// Total number of slots in this subtree (inclusive).
int get totalSlots => slots.length + children.fold(0, (sum, c) => sum + c.totalSlots);
/// Total number of filled slots in this subtree (inclusive).
int get filledSlots =>
slots.where((s) => s.isFilled).length + children.fold(0, (sum, c) => sum + c.filledSlots);
/// Fill rate as a percentage (0–100).
double get fillRate => totalSlots == 0 ? 0.0 : (filledSlots / totalSlots) * 100.0;
}
/// Domain service for building and validating the unit tree structure.
///
/// Provides tree construction from flat lists, doctrinal validation,
/// and subtree operations. This service is pure domain logic with no
/// infrastructure dependencies.
class UnitTreeService {
const UnitTreeService();
// ---------------------------------------------------------------------------
// Tree Construction
// ---------------------------------------------------------------------------
/// Builds a hierarchical [UnitTreeNode] tree from flat [units] and [slots].
///
/// Returns a list of root [UnitTreeNode]s (units with no parent).
List<UnitTreeNode> buildTree(
List<UnitNode> units,
List<PositionSlot> slots,
) {
final slotsByUnit = <String, List<PositionSlot>>{};
for (final slot in slots) {
slotsByUnit.putIfAbsent(slot.unitId, () => []).add(slot);
}
final nodeMap = <String, UnitTreeNode>{};
// First pass: create leaf nodes (no children yet).
for (final unit in units) {
nodeMap[unit.unitId] = UnitTreeNode(
unit: unit,
children: [],
slots: slotsByUnit[unit.unitId] ?? [],
);
}
final roots = <UnitTreeNode>[];
// Second pass: wire parent-child relationships.
for (final unit in units) {
if (unit.parentUnitId == null) {
roots.add(nodeMap[unit.unitId]!);
} else {
final parent = nodeMap[unit.parentUnitId];
if (parent != null) {
parent.children.add(nodeMap[unit.unitId]!);
}
}
}
return roots;
}
/// Returns all units in the subtree rooted at [unitId] (inclusive).
List<UnitNode> getSubtree(String unitId, List<UnitNode> allUnits) {
final result = <UnitNode>[];
final queue = <String>[unitId];
while (queue.isNotEmpty) {
final current = queue.removeAt(0);
final node = allUnits.firstWhere(
(u) => u.unitId == current,
orElse: () => throw StateError('Unit $current not found'),
);
result.add(node);
final children = allUnits.where((u) => u.parentUnitId == current).map((u) => u.unitId);
queue.addAll(children);
}
return result;
}
/// Returns all ancestors of [unitId] from immediate parent up to root.
List<UnitNode> getAncestors(String unitId, List<UnitNode> allUnits) {
final ancestors = <UnitNode>[];
var current = allUnits.firstWhere(
(u) => u.unitId == unitId,
orElse: () => throw StateError('Unit $unitId not found'),
);
while (current.parentUnitId != null) {
final parent = allUnits.firstWhere(
(u) => u.unitId == current.parentUnitId,
orElse: () => throw StateError('Parent ${current.parentUnitId} not found'),
);
ancestors.add(parent);
current = parent;
}
return ancestors;
}
// ---------------------------------------------------------------------------
// Doctrinal Validation
// ---------------------------------------------------------------------------
/// Validates whether [child] can be re-parented under [newParent].
///
/// Checks echelon hierarchy rules and prevents circular references.
ValidationResult validateReparent(
UnitNode child,
UnitNode newParent,
List<UnitNode> allUnits,
) {
// Cannot parent a unit to itself.
if (child.unitId == newParent.unitId) {
return ValidationResult.invalid('A unit cannot be its own parent.');
}
// Check echelon hierarchy: parent must be exactly one level higher.
if (!child.echelon.isValidParent(newParent.echelon)) {
return ValidationResult.invalid(
'${child.echelon.displayName} cannot be a child of '
'${newParent.echelon.displayName}. '
'Expected parent echelon: ${_expectedParentEchelon(child.echelon)?.displayName ?? "none"}.',
);
}
// Prevent circular reference: newParent must not be in child's subtree.
final subtreeIds = getSubtree(child.unitId, allUnits).map((u) => u.unitId).toSet();
if (subtreeIds.contains(newParent.unitId)) {
return ValidationResult.invalid(
'Cannot re-parent: ${newParent.name} is a descendant of ${child.name}.',
);
}
return ValidationResult.valid;
}
/// Validates whether [slot] can accept [soldier] as an assignment.
ValidationResult validateAssignment(
PositionSlot slot,
PersonnelRecord soldier,
) {
if (slot.isFilled) {
return ValidationResult.invalid(
'Position "${slot.positionTitle}" is already filled.',
);
}
if (soldier.isAssigned) {
return ValidationResult.invalid(
'${soldier.name} is already assigned to another position.',
);
}
if (soldier.primaryMos != slot.dutyMos) {
// Allow assignment but flag as a warning (not a hard block).
// The UI layer decides whether to warn or block.
}
return ValidationResult.valid;
}
/// Returns the expected parent echelon for [echelon], or null for Brigade.
Echelon? _expectedParentEchelon(Echelon echelon) {
if (echelon == Echelon.brigade) return null;
return Echelon.values.firstWhere((e) => e.level == echelon.level - 1);
}
// ---------------------------------------------------------------------------
// Statistics
// ---------------------------------------------------------------------------
/// Calculates the fill rate for a list of [slots] (0.0–1.0).
double calculateFillRate(List<PositionSlot> slots) {
if (slots.isEmpty) return 0;
final filled = slots.where((s) => s.isFilled).length;
return filled / slots.length;
}
/// Returns all vacant key leader slots from [slots].
List<PositionSlot> getVacantKeyLeaderSlots(List<PositionSlot> slots) {
return slots.where((s) => s.isKeyLeader && s.isVacant).toList();
}
/// Returns all slots with MOS mismatches given [personnel].
List<PositionSlot> getMosMismatches(
List<PositionSlot> slots,
List<PersonnelRecord> personnel,
) {
final personnelById = {for (final p in personnel) p.soldierId: p};
return slots.where((slot) {
if (slot.isVacant) return false;
final soldier = personnelById[slot.assignedPersonnelId];
if (soldier == null) return false;
return soldier.primaryMos != slot.dutyMos;
}).toList();
}
}