Ir al contenido

ADR-019 — Tag-Based Position Requirements and Employee Attributes

ADR-019 — Tag-Based Position Requirements and Employee Attributes

Sección titulada «ADR-019 — Tag-Based Position Requirements and Employee Attributes»

Accepted

Coveris tracks demand through positions (required_weekly_hours) and supply through employee attributes (contracts, reductions, qualifications). [[PRD]] §8.5 defers skills and certifications to a future decision.

Three problems motivated this design:

  1. Positions cannot express contract type requirements. A position declares only hours. An ICU position requiring “Jornada Insalubre” (36h, hazardous-duty regime) is indistinguishable from an administrative position requiring 36h. Without typed requirements, the system allows any employee with available hours to be assigned, regardless of contract regime — violating Argentine labor regulations (Ley 9539, Circular 4 — [[adr-021-argentine-legal-context|ADR-021]]) for restricted environments.

  2. Identical hours mask distinct regimes. “Guardia 24h” (continuous 24h block) and “Medio Tiempo 24h” (distributed half-time) both contribute 24h to the pool. Without contract type awareness, a guard station requiring continuous-presence coverage appears fully covered by a half-time administrative employee.

  3. Separate models per dimension do not scale. Modeling contract types, certifications, reductions, and qualifications as independent model hierarchies multiplies tables, serializers, viewsets, and tests for each new dimension.

The underlying insight: contracts, reductions, certifications, and skills are the same primitive — a named label with an optional hours impact. They share identical attachment and evaluation semantics and belong in a single unified system.

1. TagCatalog — the unified primitive. All employee attributes and position requirements are represented as typed tags from a centrally managed catalog.

TagCatalog:
name VARCHAR(255), UNIQUE -- "Guardia 12h", "Médico", "ACLS" (short, badge-friendly)
display_name VARCHAR(120), blank -- "Guardia Parcial 12h", "Profesional médico/a matriculado/a"
category VARCHAR(30) -- CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION
hours_delta DECIMAL(6,2), default=0 -- +12, -4, 0 (signed, per week)
description TEXT -- Human context
is_active BOOLEAN, default=True -- Soft-disable

category enables behavioral constraints per group (e.g., business rule “max 1 active CONTRACT tag” is enforced by category, not by name). Categories are an open enum — new categories can be added without schema migration.

hours_delta is the tag’s weekly hours contribution:

  • Positive: adds to pool (contracts — Contrato 36h → +36.00)
  • Negative: reduces pool (reductions — Sindicalizado → −12.00)
  • Zero: pure qualifier (certifications, skills — is_medic → 0.00)

Namespace is catalog-controlled. Tags on employees and positions must reference existing TagCatalog entries. Clinic admins can add entries. Free-text tag creation is not allowed — this prevents “is_medic” / “es_medico” / “Médico” drift.

2. EmployeeTag — what the employee IS.

EmployeeTag:
employee FK(Employee, CASCADE)
tag FK(TagCatalog, PROTECT)
start_date DateField
end_date DateField, nullable -- NULL = indefinite
status VARCHAR(20) -- ACTIVE, EXPIRED, REVOKED

[!note] API Responses include computed read-only fields tag_name (short label) and tag_display_name (long form) sourced from the related TagCatalog row. See [[API]] §employee-tags.

No unique constraint on (employee, tag). Stacking is a feature: an employee can hold two “Guardia 12h” tags (24h total pool). Each instance is a separate record with its own lifecycle.

Temporal awareness: Tags with bounded date ranges participate in pro-rating when partially overlapping an ISO-week computation period. The pro-rating logic is applied uniformly to all tags.

Lifecycle: Tags are typically assigned during ONBOARDING but can be added/removed at any employee state. start_date/end_date control when the tag is computationally active. status tracks manual revocation (REVOKED) and automated expiry (EXPIRED).

3. PositionTag — what the position REQUIRES.

PositionTag:
position FK(Position, CASCADE)
tag FK(TagCatalog, PROTECT)
is_mandatory BOOLEAN, default=True
UNIQUE(position, tag) -- A position requires a tag or it doesn't

[!note] API Responses include computed read-only fields tag_name, tag_display_name, and tag_category sourced from the related TagCatalog row. See [[API]] §position-tags.

is_mandatory = True → assignment is blocked if employee lacks this tag (default BLOCKING severity, admin-configurable to WARNING via business rule TAG_REQUIREMENT_MISMATCH).

is_mandatory = False → preference, not hard requirement. Generates WARNING on mismatch, never blocks.

Empty requirements set → any employee can be assigned (backwards compatible with current behavior).

4. Hours pool computation — additive stacking. The hours pool is computed by simple summation of active tag deltas:

active_tags = EmployeeTag.objects.filter(
employee=employee,
status='ACTIVE',
start_date__lte=period_end,
).filter(Q(end_date__isnull=True) | Q(end_date__gte=period_start))
effective_pool = max(Decimal('0'), sum(t.tag.hours_delta for t in active_tags))

No priority ordering. No decorator pattern. Tags are commutative — order of application does not affect the result. The floor-at-zero clamp is applied once after summation.

Pro-rating for partial-week overlap is preserved: a tag whose start_date or end_date falls within the ISO week contributes a proportional fraction of its hours_delta.

5. Assignment eligibility — set containment. A new business rule TAG_REQUIREMENT_MISMATCH (registered in apps/business_rules/) evaluates at assignment preview and creation:

required_tags = PositionTag.objects.filter(position=pos, is_mandatory=True)
employee_tag_names = set(
et.tag.name for et in EmployeeTag.objects.filter(
employee=emp, status='ACTIVE', ...
)
)
missing = {rt.tag.name for rt in required_tags} - employee_tag_names
if missing:
return violation(code='TAG_REQUIREMENT_MISMATCH', missing_tags=list(missing))

Default severity: BLOCKING. Clinic admins can downgrade to WARNING via the business rules control panel ([[adr-016-capacity-planning-patterns|ADR-016]] §1 pattern).

6. Coverage stays one-dimensional. Coverage remains: Σ(assigned_hours) / Σ(required_hours) × 100. Tags are eligibility filters (checked before assignment creation), not coverage dimensions. No changes to coverage aggregation, frontend coverage.service.ts, or CoverageSummaryResponse API shape.

7. Relationship with [[adr-016-capacity-planning-patterns|ADR-016]]. This ADR owns all contract, reduction, certification, and qualification modeling. ADR-016 owns the business rules engine (§1) and coverage alerts (§2) — both are extended by this ADR, not duplicated.

The tag system is the sole mechanism for employee attributes and position requirements. There are no separate contract models (ContractTemplate, ContractInstance, ContractAdjustment) — those concepts are expressed as typed tags.

8. Model mapping. The tag system unifies what would otherwise be separate model hierarchies:

ConceptTag Representation
Contract typeTagCatalog row with category=CONTRACT, positive hours_delta
Employee’s contractEmployeeTag row linking employee to contract tag
Reduction / exceptionEmployeeTag row with category=EXCEPTION, negative hours_delta
Attribute change auditTagChangeLog (generic audit for all tag mutations)

9. Seeded tags. 23 seed entries across 4 categories: 9 CONTRACT, 3 EXCEPTION, 9 QUALIFICATION, 2 CERTIFICATION.

NameDisplay NameCategoryhours_delta
Admin 40hJornada Administrativa 40hCONTRACT+40.00
MT 24hMedio Tiempo 24hCONTRACT+24.00
InsalubreJornada Insalubre 36hCONTRACT+36.00
MT 20hMedio Tiempo 20hCONTRACT+20.00
Completa 48hJornada Completa 48hCONTRACT+48.00
Admin 45hJornada Administrativa 45hCONTRACT+45.00
Asistencial 30hJornada Asistencial 30hCONTRACT+30.00
Guardia 24hGuardia Activa 24hCONTRACT+24.00
Guardia 12hGuardia Parcial 12hCONTRACT+12.00
SindicalizadoReducción gremial −12h/semEXCEPTION−12.00
LactanciaReducción por lactancia −4h/semEXCEPTION−4.00
Lic. Sin GoceLicencia sin goce de haberesEXCEPTION−36.00
MédicoProfesional médico/a matriculado/aQUALIFICATION0.00
EnfermeroProfesional enfermero/a matriculado/aQUALIFICATION0.00
ContadorProfesional contador/a matriculado/aQUALIFICATION0.00
CirujanoCirujano/a habilitado/aQUALIFICATION0.00
AnestesiólogoMédico/a anestesiólogo/aQUALIFICATION0.00
Téc. en AnestesiaTécnico/a en anestesiaQUALIFICATION0.00
InstrumentadorInstrumentador/a quirúrgico/aQUALIFICATION0.00
IntensivistaMédico/a intensivistaQUALIFICATION0.00
CamilleroCamillero/aQUALIFICATION0.00
ACLSAdvanced Cardiovascular Life SupportCERTIFICATION0.00
PALSPediatric Advanced Life SupportCERTIFICATION0.00

10. Position default. Position.required_weekly_hours gains default=Decimal('36.00'). This is the most common clinical contract base in the target market. The field remains editable — the default is a convenience, not a constraint.

11. Audit trail. Following [[adr-017-audit-trail-architecture|ADR-017]]‘s pattern (domain writes, API reads):

TagChangeLog:
employee FK(Employee, CASCADE)
tag FK(TagCatalog, PROTECT)
action VARCHAR(20) -- ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED
actor FK(User, SET_NULL, nullable)
reason TEXT, blank=True
created_at DateTimeField, auto_now_add

[!note] API Responses include computed read-only fields tag_name (short label), tag_display_name (long form), and tag_category sourced from the related TagCatalog row. See [[API]] §tag-change-log.

Append-only. Created by a Django signal on EmployeeTag post_save/post_delete. Read via apps/audit_log/ viewset ([[adr-017-audit-trail-architecture|ADR-017]] split preserved).

  • Adding a new requirement type (e.g., language proficiency, driver’s license) requires: one TagCatalog row insert. No migration, no serializer, no viewset.
  • Three tables cover all dimensions. TagCatalog + EmployeeTag + PositionTag express contracts, reductions, certifications, qualifications, and any future attribute types without additional models.
  • Hours pool computation is additive. max(0, Σ(deltas)) — no priority ordering, no decorator pipeline, no apply() methods.
  • API endpoints. TagCatalog CRUD, EmployeeTag CRUD, PositionTag CRUD. Hours balance response shape is stable (pool, consumption, balance, state).
  • [[adr-016-capacity-planning-patterns|ADR-016]] §1 (Business Rules) and §2 (Coverage Alerts) remain valid. This ADR owns all tag and attribute modeling; ADR-016 owns the rule engine and coverage alerting.
  • [[adr-011-weekly-only-compute|ADR-011]] (Weekly-Only Computation) is preserved. All tag hours_delta values are per-week. Monthly views remain read-time aggregations.
  • [[adr-017-audit-trail-architecture|ADR-017]] (Audit Trail) pattern is extended with TagChangeLog following the same write-in-domain / read-via-API split.
  • [[adr-018-orgunit-demand-scope|ADR-018]] (Demand Scope) is unchanged. Positions remain at UNIT-type leaves. PositionTag attaches to positions, not org units.
  • Coverage aggregation is unchanged. Tags filter eligibility at assignment time; coverage remains one-dimensional (hours).