Ir al contenido

ADR-016 — Capacity Planning Patterns

Accepted

Coveris is a capacity planning system with three interconnected domains:

  • Structure (demand): positions declare required_weekly_hours per org unit.
  • Roster (supply): employees carry typed tags ([[adr-019-tag-based-requirements|ADR-019]]) whose hours_delta values sum to an effective weekly pool.
  • Assignments (bridge): each assignment declares effective_hours linking one employee to one position.

The central equation is:

Coverage = SUM(active assignment hours) / position.required_weekly_hours
Balance = employee.effective_pool - SUM(employee's active assignment hours)

Two engineering problems arise:

  1. Configurable business rules: [[adr-010-rule-severity-levels|ADR-010]] established three severity levels (BLOCKING, WARNING, INFO), but no decision was made on how rules are stored, evaluated, or toggled. Assignment preview must validate against rules like MAX_WEEKLY_HOURS (60h cap per Argentine law, Ley 9539 — [[adr-021-argentine-legal-context|ADR-021]]) without hardcoding the cap value or severity.

  2. Coverage alerting: positions have four discrete states (VACANT, PARTIAL, COVERED, OVER_COVERED), but there is no mechanism to define thresholds that trigger alerts (e.g., “warn when any department drops below 80% coverage”).

1. Business Rules — Specification Pattern + Rule Engine. Business rules live in a dedicated Django app: apps/business_rules/. This app has two components:

Catalogue — The data layer. A BusinessRule model stores the rule definitions:

FieldTypePurpose
codeVARCHAR(50), uniqueRule identifier (e.g., MAX_WEEKLY_HOURS)
nameVARCHAR(255)Human-readable label
severityENUM per [[adr-010-rule-severity-levelsADR-010]]
thresholdDECIMAL(10,2), nullableConfigurable numeric value (e.g., 60.00 for hour cap)
enabledBOOLEANToggle on/off without code changes
descriptionTEXTExplanation for the control panel UI

The catalogue includes a read-only REST endpoint (GET /api/v1/business-rules/, non-paginated) so the frontend can display rule information, and Django admin registration so admins can toggle/configure rules.

Engine — The evaluation layer. A registry maps each rule code to an evaluator function (Specification pattern — stateless predicates):

apps/business_rules/engine.py
def evaluate_rules(context: RuleContext) -> RuleResults:
results = RuleResults()
for rule in BusinessRule.objects.filter(enabled=True):
evaluator = RULE_REGISTRY.get(rule.code)
if evaluator:
result = evaluator(rule, context)
results.add(rule.severity, result)
return results

Other apps consume rules by importing evaluate_rules from the engine — they never query the BusinessRule model directly for evaluation:

# In apps/assignments/views.py (preview endpoint)
from apps.business_rules.engine import evaluate_rules, RuleContext
context = RuleContext(employee=emp, position=pos, effective_hours=hours)
results = evaluate_rules(context)
# results.is_valid → True if no BLOCKING violations
# results.blocking → list of BLOCKING violations
# results.warnings → list of WARNING violations

Rules are seeded in data migrations (e.g., MAX_WEEKLY_HOURS with threshold=60, severity=BLOCKING) and editable in the Control Panel by ADMIN/MANAGER roles. Adding a new rule requires one data migration (seed row) and one evaluator function — no changes to existing code (Open/Closed). Toggling severity or disabling a rule requires one admin panel action — no deployment.

2. Coverage Alerts — Configurable Thresholds. Coverage states (VACANT, PARTIAL, COVERED, OVER_COVERED) are computed at read time from positions and assignments. Never cached, never stale (consistent with [[adr-011-weekly-only-compute|ADR-011]]‘s stateless computation philosophy).

For alerting, a coverage_thresholds configuration defines when each state triggers a visual indicator:

StateConditionDefault ColorDefault Severity
VACANTratio = 0RedBLOCKING
PARTIAL0 < ratio < 1AmberWARNING
COVEREDratio = 1Green
OVER_COVEREDratio > 1BlueINFO

MVP: thresholds are evaluated at frontend render time. The TreeTable and Drawer use COVERAGE_STATE_CONFIG to map states to PrimeNG tag severities and progress bar colors.

Future: configurable per org-unit. A coverage_alert_config table allows admins to set custom thresholds (e.g., “Guardia must be ≥90% covered, warn at 80%”). Django signals can emit notifications when coverage drops below a configured floor.

  • Adding a new business rule requires: one migration (seed row in business_rules), one evaluate() method. No changes to existing rules.
  • Toggling rule severity or disabling a rule requires: one admin action in Control Panel. No deployment.
  • Coverage thresholds are hardcoded for MVP (four discrete states). Future ADR may introduce configurable thresholds per org-unit.
  • [[adr-010-rule-severity-levels|ADR-010]]‘s severity levels (BLOCKING, WARNING, INFO) are reused directly — no new severity enum.
  • [[adr-011-weekly-only-compute|ADR-011]]‘s weekly-only computation is preserved — all values are per-week.
  • [[adr-019-tag-based-requirements|ADR-019]] (Tag-Based Requirements) handles contract types, reductions, qualifications, and certifications as typed tags with hours_delta. This ADR focuses on the business rule engine and coverage alerting that consume those tags.