240 lines
7.8 KiB
Dart
240 lines
7.8 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';
|
||
|
||
/// Result of a doctrinal validation check.
|
||
class ValidationResult {
|
||
const ValidationResult({required this.isValid, this.reason});
|
||
|
||
/// 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);
|
||
|
||
/// Creates a failing validation result with a [reason].
|
||
static ValidationResult invalid(String reason) =>
|
||
ValidationResult(isValid: false, reason: reason);
|
||
}
|
||
|
||
/// 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.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();
|
||
}
|
||
}
|