API — Contratos de Endpoints HTTP
API.md — HTTP Endpoint Contracts
Sección titulada «API.md — HTTP Endpoint Contracts»SSOT for all HTTP communication between Angular 21 and Django DRF. Every endpoint here is a contract; if implementation differs, this file wins ([[adr-005-api-contract-ssot|ADR-005]]).
All cross-cutting rules — URL structure, pagination (page_size default 25 / max 100), HTTP methods, naming, dates, IDs, auth & cookies, throttling, CSRF, and the error/status-code format — live in [[api-conventions]] and are not restated per endpoint. This file lists only what is specific to each endpoint.
Health & Status
Sección titulada «Health & Status»GET /api/health/
Sección titulada «GET /api/health/»Health check endpoint. No authentication required. Use to verify API is alive and database connectivity.
Response 200 OK:
{ "status": "healthy", "database": "connected", "timestamp": "2026-03-13T14:30:45.123456Z", "version": "0.1.0"}| Field | Type | Notes |
|---|---|---|
status | string | "healthy" or "unhealthy" |
database | string | "connected" or "unreachable" |
timestamp | string | ISO 8601 timestamp |
version | string | Only included when authenticated or DEBUG=True |
Response 503 Service Unavailable (database unreachable):
{ "status": "unhealthy", "database": "unreachable"}Authentication Endpoints
Sección titulada «Authentication Endpoints»Base path: /api/v1/auth/.
POST /api/v1/auth/login/
Sección titulada «POST /api/v1/auth/login/»Authenticate user. Returns user profile in response body. Tokens are set as httpOnly cookies — not in the response body.
Throttle: login scope (5 requests/hour per IP).
Request:
{ "password": "secret"}Response 200 OK:
{ "user": { "sub": "550e8400-e29b-41d4-a716-446655440000", "given_name": "John", "family_name": "Doe", "role": "VIEWER", "email_verified": true, "is_staff": false }}Set-Cookie headers (httpOnly, not visible to JS):
Set-Cookie: access_token=<jwt>; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600Set-Cookie: refresh_token=<jwt>; HttpOnly; SameSite=Lax; Path=/api/v1/auth/token/refresh/; Max-Age=604800Response 400 Bad Request:
{ "non_field_errors": ["Invalid email or password."]}Response 429 Too Many Requests:
{ "detail": "Request was throttled. Expected available in 720 seconds."}POST /api/v1/auth/logout/
Sección titulada «POST /api/v1/auth/logout/»Blacklists the refresh token and clears both auth cookies server-side.
Requires authentication (valid access_token cookie).
Response 204 No Content (empty body, cookies cleared via Set-Cookie with Max-Age=0)
POST /api/v1/auth/token/refresh/
Sección titulada «POST /api/v1/auth/token/refresh/»Reads the refresh_token cookie and issues a new access_token cookie.
ROTATE_REFRESH_TOKENS=True — also issues a new refresh_token cookie and blacklists the old one.
No request body required.
Throttle: token_refresh scope (20 requests/hour).
Response 200 OK:
{ "message": "Token refreshed"}Response 401 Unauthorized (no or invalid refresh cookie):
{ "detail": "Refresh token not found."}GET /api/v1/auth/me/
Sección titulada «GET /api/v1/auth/me/»Retrieve authenticated user’s profile. Auth via access_token cookie.
Response includes Cache-Control: no-store.
Response 200 OK (UserSerializer):
{ "sub": "550e8400-e29b-41d4-a716-446655440000", "given_name": "John", "family_name": "Doe", "role": "VIEWER", "email_verified": true, "is_staff": false}| Field | Type | Source | Notes |
|---|---|---|---|
sub | UUID | User.sub | OIDC claim — Cognito-compatible |
email | string | User.email | Unique, used as username |
given_name | string | User.given_name | OIDC claim |
family_name | string | User.family_name | OIDC claim |
role | string | User.role | Enum: ADMIN, MANAGER, SUPERVISOR, VIEWER, EMPLOYEE |
email_verified | boolean | User.email_verified | OIDC claim |
is_staff | boolean | User.is_staff | Django admin access |
Response 401 Unauthorized (missing/invalid cookie):
{ "detail": "Authentication credentials were not provided."}GDPR Endpoints
Sección titulada «GDPR Endpoints»DELETE /api/v1/auth/account/
Sección titulada «DELETE /api/v1/auth/account/»Deactivates the authenticated user’s account (GDPR Art. 17 — Right to erasure).
Sets is_active=False. Clears auth cookies.
Soft-delete retention: This endpoint deactivates the account (
is_active=False) but does not destroy the user record or any associated data. User data (profile, assignments, audit logs) is retained in the database and remains accessible to administrators for recovery purposes. A future hard-delete sweep may be scheduled per the project’s data retention policy.
Response 204 No Content (empty body, cookies cleared)
Response 401 Unauthorized:
{ "detail": "Authentication credentials were not provided."}GET /api/v1/auth/export/
Sección titulada «GET /api/v1/auth/export/»Exports the authenticated user’s personal data as JSON (GDPR Art. 20 — Data portability).
Returns Content-Disposition: attachment; filename="my-data.json".
Response includes Cache-Control: no-store.
Response 200 OK:
{ "given_name": "John", "family_name": "Doe", "role": "VIEWER", "date_joined": "2026-01-15T10:30:00+00:00", "last_login": "2026-03-14T08:15:30+00:00"}Response 401 Unauthorized:
{ "detail": "Authentication credentials were not provided."}Employee Endpoints (Nomina)
Sección titulada «Employee Endpoints (Nomina)»All employee endpoints require authentication. Base path: /api/v1/employees/
GET /api/v1/employees/
Sección titulada «GET /api/v1/employees/»List employees (paginated).
Query parameters: (plus standard page, page_size, ordering — see [[api-conventions]])
| Param | Type | Description |
|---|---|---|
status | string | Filter by FSM state: ONBOARDING, ACTIVE, PROPOSAL_PENDING, ON_LEAVE, DEACTIVATED, TERMINATED |
search | string | Full-text on first_name, last_name, employee_number (icontains) |
ordering | string | Default: last_name |
Response 200 OK (EmployeeListSerializer):
| Field | Type | Source | Notes |
|---|---|---|---|
id | UUID | Employee.id | Primary key |
employee_number | string | Employee.employee_number | Unique |
first_name | string | Employee.first_name | |
last_name | string | Employee.last_name | |
email | string | Employee.email | |
status | string | Employee.status | FSM state (see below) |
hire_date | date | null | Employee.hire_date | |
photo | string | null | Employee.photo | Avatar URL/path |
created_at | datetime | Employee.created_at |
POST /api/v1/employees/
Sección titulada «POST /api/v1/employees/»Create a new employee (EmployeeWriteSerializer). Status defaults to ONBOARDING.
Request:
{ "employee_number": "EMP-001", "first_name": "María", "last_name": "García", "document_number": "30123456", "hire_date": "2026-03-17"}Response 201 Created — returns EmployeeDetailSerializer.
GET /api/v1/employees/{id}/
Sección titulada «GET /api/v1/employees/{id}/»Retrieve employee detail (EmployeeDetailSerializer). Includes current_proposal when status is PROPOSAL_PENDING.
Response 200 OK:
| Field | Type | Source | Notes |
|---|---|---|---|
id | UUID | Employee.id | Primary key |
employee_number | string | Employee.employee_number | Unique |
first_name | string | Employee.first_name | |
last_name | string | Employee.last_name | |
email | string | Employee.email | |
document_number | string | Employee.document_number | |
status | string | Employee.status | FSM state |
date_of_birth | date | null | Employee.date_of_birth | |
hire_date | date | null | Employee.hire_date | |
termination_date | date | null | Employee.termination_date | Set by terminate() |
leave_started_at | datetime | null | Employee.leave_started_at | Set by go_on_leave() |
current_proposal | object | null | Computed | See below |
photo | string | null | Employee.photo | Avatar URL/path |
created_at | datetime | Employee.created_at | |
updated_at | datetime | Employee.updated_at |
current_proposal (only when status == PROPOSAL_PENDING):
{ "proposal_type": "ASSIGNMENT", "notes": "Reassign to ER unit", "expires_at": "2026-03-24T14:30:00Z", "previous_status": "ACTIVE"}| Field | Type | Notes |
|---|---|---|
proposal_type | string | Enum: ASSIGNMENT, TRANSFER |
notes | string | Context provided by the proposer |
expires_at | datetime | null | Auto-expiry timestamp (set by propose()) |
previous_status | string | FSM state before proposal (used for reject/cancel rollback) |
PATCH /api/v1/employees/{id}/
Sección titulada «PATCH /api/v1/employees/{id}/»Partial update (EmployeeWriteSerializer). Cannot modify status (FSM-controlled).
Writable fields: employee_number, first_name, last_name, email, document_number, date_of_birth, hire_date, photo
Response 200 OK — returns EmployeeDetailSerializer.
DELETE /api/v1/employees/{id}/
Sección titulada «DELETE /api/v1/employees/{id}/»Response 405 Method Not Allowed. Hard delete is not supported. Use FSM transitions to manage the employee lifecycle (terminate/, rehire/, etc.).
Employee FSM Transitions
Sección titulada «Employee FSM Transitions»All FSM endpoints are POST, return full employee detail (EmployeeDetailSerializer) on success, or 409 Conflict if the transition is not allowed from the current state.
States: ONBOARDING (initial) → ACTIVE → PROPOSAL_PENDING / ON_LEAVE / DEACTIVATED → TERMINATED → ONBOARDING (rehire cycle).
| Endpoint | From | To | Actor | Body |
|---|---|---|---|---|
POST /{id}/activate/ | ONBOARDING | ACTIVE | ADMIN | {reason?} |
POST /{id}/propose/ | ACTIVE, ON_LEAVE | PROPOSAL_PENDING | MANAGER | {proposal_type, notes?, expires_in_days?} |
POST /{id}/accept-proposal/ | PROPOSAL_PENDING | (previous) | EMPLOYEE | {reason?} |
POST /{id}/reject-proposal/ | PROPOSAL_PENDING | (previous) | EMPLOYEE | {reason?} |
POST /{id}/cancel-proposal/ | PROPOSAL_PENDING | (previous) | MANAGER | {reason?} |
POST /{id}/force-accept-proposal/ | PROPOSAL_PENDING | (previous) | SUPERUSER | {reason?} |
POST /{id}/go-on-leave/ | ACTIVE | ON_LEAVE | EMPLOYEE | {reason?} |
POST /{id}/return-from-leave/ | ON_LEAVE | ACTIVE | EMPLOYEE | {reason?} |
POST /{id}/deactivate/ | ACTIVE | DEACTIVATED | ADMIN | {reason?} |
POST /{id}/reactivate/ | DEACTIVATED | ACTIVE | ADMIN | {reason?} |
POST /{id}/terminate/ | ACTIVE, ON_LEAVE, DEACTIVATED | TERMINATED | ADMIN | {reason?} |
POST /{id}/rehire/ | TERMINATED | ONBOARDING | ADMIN | {reason?} |
proposal_type enum: ASSIGNMENT, TRANSFER
Error 409 Conflict:
{ "detail": "Transition \"activate\" not allowed from state \"ACTIVE\"."}GET /api/v1/employees/{id}/transitions/
Sección titulada «GET /api/v1/employees/{id}/transitions/»Audit log for this employee (paginated, TransitionLogSerializer).
Response 200 OK:
| Field | Type | Source |
|---|---|---|
id | integer | EmployeeTransitionLog.id |
from_status | string | EmployeeTransitionLog.from_status |
to_status | string | EmployeeTransitionLog.to_status |
transition | string | EmployeeTransitionLog.transition |
actor | UUID | null | EmployeeTransitionLog.actor_id |
actor_email | string | null | Computed from actor.email |
reason | string | EmployeeTransitionLog.reason |
metadata | object | EmployeeTransitionLog.metadata |
created_at | datetime | EmployeeTransitionLog.created_at |
GET /api/v1/employees/{id}/available-transitions/
Sección titulada «GET /api/v1/employees/{id}/available-transitions/»List transitions available from the current state.
Response 200 OK:
{ "status": "ACTIVE", "transitions": ["propose", "go_on_leave", "terminate"]}Audit Log — Transition History (Historial)
Sección titulada «Audit Log — Transition History (Historial)»Global audit log served by apps/audit_log. Role-scoped: EMPLOYEE users see only their own records (matched by email); all other roles see all records. Covers two log types:
- FSM Transition Logs — Employee and Assignment state transitions (
EmployeeTransitionLog,AssignmentTransitionLog). - Tag Change Logs — Employee tag lifecycle events (
TagChangeLog): ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED.
GET /api/v1/transitions/
Sección titulada «GET /api/v1/transitions/»List all transitions (paginated). Supports query param filters.
Query Parameters:
| Param | Type | Description |
|---|---|---|
search | string | Search by employee full name, employee number, or actor full name (icontains) |
transition | string | Filter by transition name (e.g. activate, terminate) |
from_status | string | Filter by origin status |
to_status | string | Filter by target status |
employee | UUID | Filter by employee ID |
date_from | date | Filter created_at >= date (YYYY-MM-DD) |
date_to | date | Filter created_at <= date (YYYY-MM-DD) |
exclude_admin_actors | string | true or 1 to hide transitions performed by ADMIN-role users |
Response 200 OK:
{ "count": 42, "next": "http://localhost:8000/api/v1/transitions/?page=2", "previous": null, "results": [ { "id": 1, "employee": "550e8400-e29b-41d4-a716-446655440000", "employee_name": "Perez, Juan", "employee_number": "EMP-001", "from_status": "ONBOARDING", "to_status": "ACTIVE", "transition": "activate", "actor": "660e8400-e29b-41d4-a716-446655440001", "actor_name": "Boss, Admin", "reason": "Onboarding complete", "metadata": {}, "created_at": "2026-03-18T10:30:00Z" } ]}| Field | Type | Notes |
|---|---|---|
id | integer | Primary key |
employee | UUID | Employee FK |
employee_name | string | ”LastName, FirstName” |
employee_number | string | Unique employee identifier |
from_status | string | Origin FSM state |
to_status | string | Target FSM state |
transition | string | Transition name |
actor | UUID | null | User who triggered the transition |
actor_name | string | ”FamilyName, GivenName”, email fallback, or “Sistema” |
reason | string | Reason for transition |
metadata | object | Extra context set by the view before transition (e.g. proposal details) |
created_at | datetime | When the transition occurred |
GET /api/v1/transitions/{id}/
Sección titulada «GET /api/v1/transitions/{id}/»Retrieve a single transition log entry.
Response 200 OK — same shape as a single result above.
Response 404 Not Found.
GET /api/v1/tag-change-logs/
Sección titulada «GET /api/v1/tag-change-logs/»List all tag change log entries (paginated). Same architectural pattern as /api/v1/transitions/: append-only, read-only viewset in apps/audit_log.
Query Parameters:
| Param | Type | Description |
|---|---|---|
search | string | Search by employee full name, employee number, or tag name (icontains) |
employee | UUID | Filter by employee ID |
tag | UUID | Filter by TagCatalog ID |
action | string | Filter by action: ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED |
date_from | date | Filter created_at >= date (YYYY-MM-DD) |
date_to | date | Filter created_at <= date (YYYY-MM-DD) |
Response 200 OK:
{ "count": 15, "next": null, "previous": null, "results": [ { "id": 1, "employee": "550e8400-e29b-41d4-a716-446655440000", "employee_name": "Perez, Juan", "employee_number": "EMP-001", "tag": "770e8400-e29b-41d4-a716-446655440002", "tag_name": "Guardia 12h", "tag_category": "CONTRACT", "action": "ASSIGNED", "actor": "660e8400-e29b-41d4-a716-446655440001", "actor_name": "Boss, Admin", "reason": "", "created_at": "2026-03-18T10:30:00Z" } ]}| Field | Type | Notes |
|---|---|---|
id | integer | Primary key |
employee | UUID | Employee FK |
employee_name | string | ”LastName, FirstName” |
employee_number | string | Unique employee identifier |
tag | UUID | TagCatalog FK |
tag_name | string | Computed from tag (short label) |
tag_display_name | string | Computed from tag (long human-readable form) |
tag_category | string | Computed from tag: CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION |
action | string | Enum: ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED |
actor | UUID | null | User who triggered the change, or null for system (e.g. EXPIRED by cron) |
actor_name | string | ”FamilyName, GivenName”, email fallback, or “Sistema” |
reason | string | Optional reason for the change |
created_at | datetime | When the change occurred |
GET /api/v1/tag-change-logs/{id}/
Sección titulada «GET /api/v1/tag-change-logs/{id}/»Retrieve a single tag change log entry.
Response 200 OK — same shape as a single result above.
Response 404 Not Found.
Assignment Endpoints (Asignaciones)
Sección titulada «Assignment Endpoints (Asignaciones)»All assignment endpoints require authentication. Base path: /api/v1/assignments/
Assignments are the bridge between Employees (supply) and Positions (demand). An assignment says: “Employee X covers Y hours of Position Z.” Only ACTIVE assignments contribute to coverage and hours calculations.
GET /api/v1/assignments/
Sección titulada «GET /api/v1/assignments/»List assignments (paginated).
Query Parameters:
| Param | Type | Description |
|---|---|---|
employee | UUID | Filter by employee ID |
position_id | UUID | Filter by position UUID |
status | string | Filter by status (ACTIVE, SUSPENDED, ENDED) |
employee__employee_number | string | Filter by employee number (exact match) |
search | string | Search by employee name or number (icontains) |
Response 200 OK (AssignmentListSerializer):
| Field | Type | Source | Notes |
|---|---|---|---|
id | UUID | Assignment.id | Primary key |
employee | UUID | Assignment.employee_id | Employee FK |
employee_name | string | Computed | ”LastName, FirstName” |
position_id | UUID | Assignment.position_id | Position UUID |
position_title | string | Computed | Title of the assigned position |
org_unit_name | string | Computed | Name of the org unit containing the position |
effective_hours | decimal(6,2) | Assignment.effective_hours | Hours this assignment covers |
is_reinforcement | boolean | Assignment.is_reinforcement | true if this assignment adds reinforcement hours above the position’s base demand |
status | string | Assignment.status | FSM state (see below) |
effective_date | date | null | Assignment.effective_date | Date assignment becomes effective |
end_date | date | null | Assignment.end_date | Set by end() transition |
created_at | datetime | Assignment.created_at |
POST /api/v1/assignments/
Sección titulada «POST /api/v1/assignments/»Create a new assignment (AssignmentWriteSerializer). Status defaults to ACTIVE.
Request:
{ "employee": "550e8400-e29b-41d4-a716-446655440000", "position_id": "660e8400-e29b-41d4-a716-446655440001", "effective_hours": "8.00", "effective_date": "2026-03-17", "notes": "Primary assignment", "is_reinforcement": false}Writable fields: employee, position_id, effective_hours, effective_date, notes, is_reinforcement
Validation rules:
effective_hoursmust be > 0.- Employee must be in
ACTIVEorON_LEAVEstatus. Other statuses are rejected. - One active assignment per employee-position pair (unique constraint).
Response 201 Created — returns AssignmentDetailSerializer.
Response 400 Bad Request (validation failure):
{ "employee": ["Cannot assign an employee in ONBOARDING state. Only ACTIVE and ON_LEAVE employees can receive assignments."]}GET /api/v1/assignments/{id}/
Sección titulada «GET /api/v1/assignments/{id}/»Retrieve assignment detail (AssignmentDetailSerializer).
Response 200 OK:
| Field | Type | Source | Notes |
|---|---|---|---|
id | UUID | Assignment.id | Primary key |
employee | UUID | Assignment.employee_id | Employee FK |
employee_name | string | Computed | ”LastName, FirstName” |
position_id | UUID | Assignment.position_id | Position UUID |
effective_hours | decimal(6,2) | Assignment.effective_hours | |
is_reinforcement | boolean | Assignment.is_reinforcement | true if this assignment adds reinforcement hours above the position’s base demand |
status | string | Assignment.status | FSM state |
effective_date | date | null | Assignment.effective_date | |
end_date | date | null | Assignment.end_date | Set by end() |
end_reason | string | Assignment.end_reason | Set by end() |
suspended_at | datetime | null | Assignment.suspended_at | Set by suspend() |
notes | string | Assignment.notes | |
created_at | datetime | Assignment.created_at | |
updated_at | datetime | Assignment.updated_at |
PATCH /api/v1/assignments/{id}/
Sección titulada «PATCH /api/v1/assignments/{id}/»Partial update (AssignmentWriteSerializer). Cannot modify status (FSM-controlled).
Writable fields: employee, position_id, effective_hours, effective_date, notes
Response 200 OK — returns AssignmentDetailSerializer.
DELETE /api/v1/assignments/{id}/
Sección titulada «DELETE /api/v1/assignments/{id}/»Response 405 Method Not Allowed. Hard delete is not supported. Use the end/ FSM transition to close an assignment.
Assignment FSM Transitions
Sección titulada «Assignment FSM Transitions»All FSM endpoints are POST, return full assignment detail (AssignmentDetailSerializer) on success, or 409 Conflict if the transition is not allowed from the current state.
States: ACTIVE (initial) → SUSPENDED ↔ ACTIVE (resume) | ACTIVE / SUSPENDED → ENDED (terminal).
AssignmentStatus enum: ACTIVE, SUSPENDED, ENDED
| Endpoint | From | To | Body |
|---|---|---|---|
POST /{id}/suspend/ | ACTIVE | SUSPENDED | {reason?} |
POST /{id}/resume/ | SUSPENDED | ACTIVE | {reason?} |
POST /{id}/end/ | ACTIVE, SUSPENDED | ENDED | {end_reason?, notes?} |
EndReason enum: RESIGNATION, TRANSFER, POSITION_CLOSED, TERMINATED, OTHER
Error 409 Conflict:
{ "detail": "Transition \"suspend\" not allowed from state \"ENDED\"."}GET /api/v1/assignments/{id}/available-transitions/
Sección titulada «GET /api/v1/assignments/{id}/available-transitions/»List transitions available from the current state.
Response 200 OK:
{ "status": "ACTIVE", "transitions": ["suspend", "end"]}GET /api/v1/assignments/{id}/transitions/
Sección titulada «GET /api/v1/assignments/{id}/transitions/»Audit log for this assignment (paginated, AssignmentTransitionLogSerializer).
Response 200 OK:
| Field | Type | Source |
|---|---|---|
id | integer | AssignmentTransitionLog.id |
from_status | string | AssignmentTransitionLog.from_status |
to_status | string | AssignmentTransitionLog.to_status |
transition | string | AssignmentTransitionLog.transition |
actor | UUID | null | AssignmentTransitionLog.actor_id |
actor_email | string | null | Computed from actor.email |
reason | string | AssignmentTransitionLog.reason |
metadata | object | AssignmentTransitionLog.metadata |
created_at | datetime | AssignmentTransitionLog.created_at |
POST /api/v1/assignments/preview/
Sección titulada «POST /api/v1/assignments/preview/»Validation-only endpoint: evaluates all enabled business rules against the proposed assignment without persisting. The employee’s hours balance is already visible on the Roster detail card — this endpoint does NOT recalculate hours.
Request:
{ "employee": "550e8400-e29b-41d4-a716-446655440000", "position_id": "660e8400-e29b-41d4-a716-446655440001", "effective_hours": "8.00"}Response 200 OK:
{ "assignment": { "employee": "550e8400-e29b-41d4-a716-446655440000", "employee_name": "García, María", "position_id": "660e8400-e29b-41d4-a716-446655440001", "effective_hours": "8.00" }, "is_valid": true, "violations": { "blocking": [], "warnings": [ { "rule_code": "COVERAGE_EXCEEDED", "message": "Puesto quedaría en excedente de horas" } ], "info": [] }}| Field | Type | Notes |
|---|---|---|
assignment.employee | UUID | Employee ID |
assignment.employee_name | string | ”LastName, FirstName” |
assignment.position_id | UUID | Position UUID |
assignment.effective_hours | decimal | Requested hours |
is_valid | boolean | false if any blocking violations |
violations.blocking | array | BLOCKING violations — prevent the assignment |
violations.warnings | array | WARNING violations — allow with advisory |
violations.info | array | INFO violations — audit trail only |
Violation object shape:
{ "rule_code": "MAX_WEEKLY_HOURS", "message": "Total semanal sería 68h, excede el tope de 60h"}Rule codes (from apps/business_rules, see Business Rules section):
| Code | Severity | When it fires |
|---|---|---|
EMPLOYEE_TERMINATED | BLOCKING | Employee status is TERMINATED |
DUPLICATE_ASSIGNMENT | BLOCKING | Active assignment already exists for (employee, position) |
MAX_WEEKLY_HOURS | BLOCKING | SUM(active assignments) + effective_hours > 60h threshold |
TAG_REQUIREMENT_MISMATCH | BLOCKING* | Employee lacks required tags for this position. Response includes missing_tags array. *Default BLOCKING; admin-configurable to WARNING via business rules panel. |
MAX_CONSECUTIVE_SHIFTS | WARNING | Employee approaching consecutive-day limit |
COVERAGE_EXCEEDED | WARNING | Position would become OVER_COVERED |
CONTRACT_NEAR_EXPIRY | INFO | Employee’s contract expires within 30 days |
Design note: The preview does NOT return employee_hours_before/after or position_hours_before/after. The employee’s balance (DEFICIT/BALANCED/SURPLUS) is already computed by the Hours Ledger pipeline and displayed on the employee card. The preview is a validation gate, not a calculator.
Tag Catalog
Sección titulada «Tag Catalog»Base path: /api/v1/tags/. All endpoints require authentication. Tags are the unified primitive for contracts, certifications, exceptions, and qualifications (ADR-019).
GET /api/v1/tags/
Sección titulada «GET /api/v1/tags/»List tag catalog entries (paginated).
Response 200 OK:
| Field | Type | Notes |
|---|---|---|
id | UUID | Primary key |
name | string | Short badge-friendly label (5-15 chars), UNIQUE. E.g. “Guardia 24h”, “Médico”, “ACLS” |
display_name | string | Longer human-readable form for detail views and tooltips. E.g. “Guardia Activa 24h”, “Profesional médico/a matriculado/a” |
category | string | Enum: CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION |
hours_delta | decimal(6,2) | Weekly hours impact. Positive = adds to pool, negative = reduces, zero = pure qualifier. |
description | string | Human-readable context (blank allowed) |
is_active | boolean | Whether this tag can be assigned to new employees/positions |
created_at | datetime | |
updated_at | datetime |
POST /api/v1/tags/
Sección titulada «POST /api/v1/tags/»Create a tag catalog entry.
Request:
{ "name": "Guardia 24h", "display_name": "Guardia Activa 24h", "category": "CONTRACT", "hours_delta": "24.00", "description": "Bloque continuo de 24h, 1 día/semana. Distinto de Medio Tiempo 24h.", "is_active": true}Response 201 Created — returns full tag object.
GET /api/v1/tags/{id}/
Sección titulada «GET /api/v1/tags/{id}/»Retrieve a single tag catalog entry.
PATCH /api/v1/tags/{id}/
Sección titulada «PATCH /api/v1/tags/{id}/»Partial update. Writable fields: name, display_name, category, hours_delta, description, is_active.
DELETE /api/v1/tags/{id}/
Sección titulada «DELETE /api/v1/tags/{id}/»Response 204 No Content. Only tags with no active employee or position references can be deleted.
Response 409 Conflict (tag in use):
{ "detail": "Cannot delete tag with active references. Deactivate it instead.", "code": "CONFLICT_HAS_DEPENDENCIES"}Employee Tags
Sección titulada «Employee Tags»Base path: /api/v1/employee-tags/. All endpoints require authentication. Employee tags describe what an employee IS — their contracts, qualifications, certifications, and exceptions.
Stacking: An employee CAN hold multiple instances of the same tag (e.g., two “Guardia 12h” tags = 24h pool). Each instance is a separate record with its own lifecycle.
GET /api/v1/employee-tags/?employee_id={uuid}
Sección titulada «GET /api/v1/employee-tags/?employee_id={uuid}»List employee tags (paginated). Optional employee_id filter.
Query parameters:
| Param | Type | Description |
|---|---|---|
employee_id | UUID | Filter by employee |
category | string | Filter by tag category: CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION |
status | string | Filter by status: ACTIVE, EXPIRED, REVOKED |
Response 200 OK:
| Field | Type | Notes |
|---|---|---|
id | UUID | Primary key |
employee | UUID | Employee FK |
tag | UUID | TagCatalog FK |
tag_name | string | Computed from tag (short label) |
tag_display_name | string | Computed from tag (long human-readable form) |
tag_category | string | Computed from tag |
hours_delta | decimal(6,2) | Computed from tag |
start_date | date | Tag effective start |
end_date | date | null | Null = indefinite |
status | string | Enum: ACTIVE, EXPIRED, REVOKED |
created_at | datetime |
GET /api/v1/employee-tags/{id}/
Sección titulada «GET /api/v1/employee-tags/{id}/»Retrieve a single employee tag assignment by ID.
Response 200 OK: Same fields as the list response (see above).
Response 404 Not Found if the record does not exist.
POST /api/v1/employee-tags/
Sección titulada «POST /api/v1/employee-tags/»Assign a tag to an employee.
Request:
{ "employee": "550e8400-...", "tag": "660e8400-...", "start_date": "2026-01-01", "end_date": null}Response 201 Created. Side effect: creates TagChangeLog entry (action: ASSIGNED).
PATCH /api/v1/employee-tags/{id}/
Sección titulada «PATCH /api/v1/employee-tags/{id}/»Partial update. Writable fields: start_date, end_date, status, reason.
Note: Changing the tag FK is not allowed — revoke this tag and assign a new one.
Side effects (TagChangeLog):
| Field changed | Condition | Action logged |
|---|---|---|
status → REVOKED | Any | REVOKED |
status → EXPIRED | Any | EXPIRED |
start_date or end_date | status unchanged | MODIFIED |
Each mutation creates exactly one TagChangeLog entry. Status changes take precedence over date changes in the same request (i.e., a PATCH that sets status=REVOKED and end_date=2026-03-27 logs REVOKED, not MODIFIED).
Implementation constraint: Must use instance-level .save(), not queryset .update(), to ensure post_save signals fire for audit capture (ADR-017 pattern).
DELETE /api/v1/employee-tags/{id}/
Sección titulada «DELETE /api/v1/employee-tags/{id}/»Soft revoke. Sets status to REVOKED. Side effect: creates TagChangeLog entry (action: REVOKED).
Response 204 No Content.
Position Tags (Requirements)
Sección titulada «Position Tags (Requirements)»Base path: /api/v1/position-tags/. All endpoints require authentication. Position tags declare what a position REQUIRES from employees.
GET /api/v1/position-tags/?position_id={uuid}
Sección titulada «GET /api/v1/position-tags/?position_id={uuid}»List position tag requirements (paginated).
Query parameters:
| Param | Type | Description |
|---|---|---|
position_id | UUID | Filter by position |
Response 200 OK:
| Field | Type | Notes |
|---|---|---|
id | UUID | Primary key |
position | UUID | Position FK |
tag | UUID | TagCatalog FK |
tag_name | string | Computed from tag (short label) |
tag_display_name | string | Computed from tag (long human-readable form) |
tag_category | string | Computed from tag |
is_mandatory | boolean | true = blocks assignment (default), false = warning only |
created_at | datetime |
GET /api/v1/position-tags/{id}/
Sección titulada «GET /api/v1/position-tags/{id}/»Retrieve a single position tag requirement by ID.
Response 200 OK: Same fields as the list response (see above).
Response 404 Not Found if the record does not exist.
POST /api/v1/position-tags/
Sección titulada «POST /api/v1/position-tags/»Add a tag requirement to a position.
Request:
{ "position": "660e8400-...", "tag": "770e8400-...", "is_mandatory": true}Validation: Unique constraint on (position, tag). Duplicate returns 400 Bad Request.
Response 201 Created.
PATCH /api/v1/position-tags/{id}/
Sección titulada «PATCH /api/v1/position-tags/{id}/»Partial update of a position tag requirement. Writable fields: is_mandatory.
Note: Changing position or tag FKs is not allowed — delete this requirement and create a new one.
Response 200 OK: Updated position tag object (same fields as list response).
DELETE /api/v1/position-tags/{id}/
Sección titulada «DELETE /api/v1/position-tags/{id}/»Remove a tag requirement from a position.
Response 204 No Content.
Offer — Hours Ledger (Stateless Computation)
Sección titulada «Offer — Hours Ledger (Stateless Computation)»The hours balance is never persisted (ADR-011, ADR-019). Every request triggers fresh computation from employee tags and assignments. Pool = max(0, Σ(active tag hours_delta)). See ADR-019.
GET /api/v1/offer/employees/{employee_id}/balance/?reference_date=YYYY-MM-DD
Sección titulada «GET /api/v1/offer/employees/{employee_id}/balance/?reference_date=YYYY-MM-DD»Compute hours balance for a single employee. If reference_date is omitted, defaults to today.
Response 200 OK:
{ "employee_id": "550e8400-...", "period": { "start_date": "2026-03-16", "end_date": "2026-03-22" }, "pool": { "base_hours": "40.00", "adjustment_delta": "-10.00", "effective_hours": "30.00" }, "consumption": { "assigned_hours": "32.00", "assignment_count": 2 }, "balance": "-2.00", "state": "DEFICIT", "computed_at": "2026-03-18T14:30:45.123456Z", "error": null}| Field | Type | Notes |
|---|---|---|
employee_id | UUID | |
period | object | ISO week (Mon–Sun) resolved from reference_date |
pool.base_hours | decimal | Σ(positive tag deltas) — always weekly per ADR-011 |
pool.adjustment_delta | decimal | Σ(negative tag deltas, pro-rated for partial-week overlap) |
pool.effective_hours | decimal | max(0, base + delta) |
consumption.assigned_hours | decimal | Sum of ACTIVE assignment hours |
consumption.assignment_count | integer | |
balance | decimal | effective_hours - assigned_hours |
state | string | DEFICIT (> 0), BALANCED (= 0), SURPLUS (< 0) |
computed_at | datetime | Timestamp of computation |
tags | array | List of active tag names for this employee during the period |
error | string | null | NO_ACTIVE_TAGS if no tags with hours_delta > 0 are active for the date |
Response 404 Not Found — employee does not exist.
POST /api/v1/offer/balance/batch/
Sección titulada «POST /api/v1/offer/balance/batch/»Batch balance computation for up to 500 employees.
Request:
{ "employee_ids": ["550e8400-...", "660e8400-..."], "reference_date": "2026-03-18"}Response 200 OK — array of HoursBalance objects (same shape as single balance).
POST /api/v1/offer/employees/{employee_id}/simulate/
Sección titulada «POST /api/v1/offer/employees/{employee_id}/simulate/»What-if analysis: projected balance if additional hours were assigned. Does not modify data.
Request:
{ "additional_hours": "8.00", "reference_date": "2026-03-18"}Response 200 OK:
{ "current_balance": { /* HoursBalance object */ }, "projected_balance": "-2.00", "projected_state": "SURPLUS", "would_exceed_pool": true}Response 404 Not Found — employee does not exist.
Org Unit Endpoints (Estructura)
Sección titulada «Org Unit Endpoints (Estructura)»All org unit endpoints require authentication. Base path: /api/v1/org-units/
Org units define the hierarchical structure of the clinic: CLINIC → DEPARTMENT → SERVICE → UNIT. Each unit has a parent_id forming an adjacency list. Only UNIT-type nodes hold positions.
GET /api/v1/org-units/
Sección titulada «GET /api/v1/org-units/»List org units (paginated).
Query parameters:
| Param | Type | Notes |
|---|---|---|
unit_type | string | Filter by OrgUnitTypeCode: CLINIC, DEPARTMENT, SERVICE, UNIT |
is_active | boolean | Filter active/inactive units |
parent_id | UUID | Filter direct children of a parent |
search | string | Full-text on name and code |
ordering | string | Default: sort_order |
Response 200 OK (OrgUnitListSerializer):
| Field | Type | Source | Notes |
|---|---|---|---|
id | UUID | OrgUnit.id | Primary key |
parent_id | UUID | null | OrgUnit.parent_id | Null for root (CLINIC) |
unit_type | string | OrgUnit.unit_type | CLINIC, DEPARTMENT, SERVICE, UNIT |
code | string | OrgUnit.code | Hierarchical code (e.g., BIE-MED-GUA) |
name | string | OrgUnit.name | Full display name |
short_name | string | OrgUnit.short_name | Abbreviated name |
is_active | boolean | OrgUnit.is_active | Soft-delete flag |
is_management | boolean | OrgUnit.is_management | False by default. Whether this UNIT is a management role. Set by admin. Only meaningful on UNIT type. |
sort_order | integer | OrgUnit.sort_order | Display order among siblings |
max_weekly_hours | decimal | null | OrgUnit.max_weekly_hours | Clinic-level override for max weekly hours (default legal cap: 60h). Only meaningful on CLINIC type. |
position_count | integer | computed | Count of active positions in this unit |
created_at | datetime | OrgUnit.created_at | |
updated_at | datetime | OrgUnit.updated_at |
OrgUnitTypeCode enum: CLINIC, DEPARTMENT, SERVICE, UNIT
POST /api/v1/org-units/
Sección titulada «POST /api/v1/org-units/»Create a new org unit (OrgUnitWriteSerializer).
Request:
{ "parent_id": "550e8400-e29b-41d4-a716-446655440001", "unit_type": "DEPARTMENT", "code": "BIE-NUE", "name": "Nuevo Departamento", "short_name": "Nuevo", "sort_order": 6}Writable fields: parent_id, unit_type, code, name, short_name, sort_order, is_active, max_weekly_hours
Validation rules:
codemust be unique across all org units.parent_idmust reference an existing org unit (or null for root).- Only one
CLINIC-type unit allowed (root node). unit_typemust follow nesting rules:CLINIChas no parent;DEPARTMENTunderCLINIC;SERVICEunderDEPARTMENT;UNITunderSERVICEorDEPARTMENT.
Response 201 Created — returns OrgUnitDetailSerializer.
GET /api/v1/org-units/{id}/
Sección titulada «GET /api/v1/org-units/{id}/»Retrieve org unit detail (OrgUnitDetailSerializer).
Additional fields beyond list serializer:
| Field | Type | Source | Notes |
|---|---|---|---|
children_count | integer | computed | Count of direct child units |
descendant_count | integer | computed | Count of all descendants |
total_position_count | integer | computed | Positions in this unit + all descendants |
Response 200 OK — returns OrgUnitDetailSerializer.
Response 404 Not Found — org unit does not exist.
PATCH /api/v1/org-units/{id}/
Sección titulada «PATCH /api/v1/org-units/{id}/»Partial update (OrgUnitWriteSerializer). Cannot modify unit_type after creation.
Writable fields: parent_id, code, name, short_name, sort_order, is_active, max_weekly_hours
Response 200 OK — returns OrgUnitDetailSerializer.
DELETE /api/v1/org-units/{id}/
Sección titulada «DELETE /api/v1/org-units/{id}/»Soft-delete (sets is_active=False). Only units with no active children and no active positions can be deactivated. Records are retained for audit history (PRD §7.4).
Response 204 No Content — soft-deleted (is_active=False).
Response 409 Conflict (has active children or positions):
{ "detail": "Cannot deactivate org unit with active child units.", "code": "CONFLICT_HAS_DEPENDENCIES"}or
{ "detail": "Cannot deactivate org unit with active positions.", "code": "CONFLICT_HAS_DEPENDENCIES"}Demand — Position Endpoints (Puestos)
Sección titulada «Demand — Position Endpoints (Puestos)»All position endpoints require authentication. Base path: /api/v1/demand/
Positions define the demand side: “This org unit needs N hours of this role.” Positions belong to UNIT-type org units. Coverage is computed from active assignments against the position’s required_weekly_hours.
GET /api/v1/demand/
Sección titulada «GET /api/v1/demand/»List positions (paginated).
Query parameters:
| Param | Type | Notes |
|---|---|---|
org_unit_id | UUID | Filter by org unit |
is_active | boolean | Filter active/inactive |
coverage_state | string | Filter by computed coverage: VACANT, PARTIAL, COVERED, OVER_COVERED |
search | string | Full-text on title |
eligible_for_employee | UUID | Annotate each position with tag-based eligibility for this employee. Adds required_tags, is_eligible, missing_tags to response. |
Response 200 OK (PositionListSerializer):
| Field | Type | Source | Notes |
|---|---|---|---|
id | UUID | Position.id | Primary key |
org_unit_id | UUID | Position.org_unit_id | Parent org unit |
org_unit_name | string | computed | Org unit name (read-only) |
title | string | Position.title | Position title (e.g., “Médico de Guardia”) |
required_weekly_hours | decimal(6,2) | Position.required_weekly_hours | Hours this position needs per week |
is_active | boolean | Position.is_active | |
coverage_state | string | computed | VACANT, PARTIAL, COVERED, OVER_COVERED |
assigned_hours | decimal(6,2) | computed | Sum of active assignment hours |
assignment_count | integer | computed | Count of active assignments |
required_tags | array|null | computed | Only when eligible_for_employee is set. Each entry: {name, category, is_mandatory} |
is_eligible | boolean|null | computed | Only when eligible_for_employee is set. True if employee has all mandatory tags |
missing_tags | array|null | computed | Only when eligible_for_employee is set. List of mandatory tag names the employee lacks |
notes | string | Position.notes | |
created_at | datetime | Position.created_at | |
updated_at | datetime | Position.updated_at |
CoverageState enum: VACANT (0% assigned), PARTIAL (1–99%), COVERED (100%), OVER_COVERED (>100%)
Coverage computation:
coverage_ratio = assigned_hours / required_weekly_hoursVACANT: coverage_ratio == 0PARTIAL: 0 < coverage_ratio < 1COVERED: coverage_ratio == 1OVER_COVERED: coverage_ratio > 1POST /api/v1/demand/
Sección titulada «POST /api/v1/demand/»Create a new position (PositionWriteSerializer).
Request:
{ "org_unit_id": "660e8400-e29b-41d4-a716-446655440001", "title": "Médico de Guardia", "required_weekly_hours": "36.00", "notes": "Cobertura turno noche"}Writable fields: org_unit_id, title, required_weekly_hours, is_active, notes
Validation:
org_unit_idmust reference an existing, active,UNIT-type org unit.required_weekly_hoursmust be > 0.
Response 201 Created — returns PositionListSerializer.
GET /api/v1/demand/{id}/
Sección titulada «GET /api/v1/demand/{id}/»Retrieve position detail (PositionDetailSerializer).
Additional fields beyond list serializer:
| Field | Type | Source | Notes |
|---|---|---|---|
assignments | array | computed | List of active assignments (AssignmentListSerializer) |
required_tags | array | PositionTag | List of {id, tag_id, tag_name, tag_category, is_mandatory} |
Response 200 OK — returns PositionDetailSerializer.
PATCH /api/v1/demand/{id}/
Sección titulada «PATCH /api/v1/demand/{id}/»Partial update (PositionWriteSerializer).
Writable fields: org_unit_id, title, required_weekly_hours, is_active, notes
Response 200 OK — returns PositionDetailSerializer.
DELETE /api/v1/demand/{id}/
Sección titulada «DELETE /api/v1/demand/{id}/»Soft-delete (sets is_active=False). Only positions with no active assignments can be deactivated. Records are retained for audit history (PRD §7.4).
Response 204 No Content — soft-deleted (is_active=False).
Response 409 Conflict (has active assignments):
{ "detail": "Cannot deactivate position with active assignments. End all assignments first.", "code": "CONFLICT_HAS_DEPENDENCIES"}POST /api/v1/demand/{id}/duplicate/
Sección titulada «POST /api/v1/demand/{id}/duplicate/»Clone a position within the same org unit. Creates a copy with “(copy)” suffix in title. Tag requirements (PositionTag rows) ARE copied. Assignments are NOT copied.
Request: (empty body or optional overrides)
{ "title": "Médico de Guardia (Noche)"}Response 201 Created — returns PositionListSerializer of the new position.
GET /api/v1/demand/coverage-summary/
Sección titulada «GET /api/v1/demand/coverage-summary/»Aggregated coverage metrics grouped by org unit. Returns global totals and per-unit breakdown with position counts by coverage state, required/assigned hours, and coverage percentage. Non-paginated — returns the full set.
Response 200 OK:
{ "global": { "total_positions": 42, "covered_positions": 12, "partial_positions": 8, "vacant_positions": 20, "over_covered_positions": 2, "total_required_hours": "1680.00", "total_assigned_hours": "840.00", "coverage_pct": "50.00" }, "by_unit": [ { "org_unit_id": "uuid", "org_unit_name": "Unidad X", "org_unit_type": "UNIT", "parent_id": "uuid-or-null", "position_count": 5, "covered": 2, "partial": 1, "vacant": 2, "over_covered": 0, "required_hours": "200.00", "assigned_hours": "120.00", "coverage_pct": "60.00", "employee_breakdown": { "active": 3, "on_leave": 1, "other": 0 } } ]}| Field | Type | Notes |
|---|---|---|
global.total_positions | integer | Total active positions across all units |
global.covered_positions | integer | Positions with coverage_state = COVERED |
global.partial_positions | integer | Positions with coverage_state = PARTIAL |
global.vacant_positions | integer | Positions with coverage_state = VACANT |
global.over_covered_positions | integer | Positions with coverage_state = OVER_COVERED |
global.total_required_hours | decimal(10,2) | Sum of required_weekly_hours across all active positions |
global.total_assigned_hours | decimal(10,2) | Sum of active assignment hours across all positions |
global.coverage_pct | decimal(5,2) | (total_assigned / total_required) * 100, or 0 if no required hours |
by_unit[].org_unit_id | UUID | Org unit primary key |
by_unit[].org_unit_name | string | Org unit name |
by_unit[].org_unit_type | string | CLINIC, DEPARTMENT, SERVICE, UNIT |
by_unit[].parent_id | UUID or null | Parent org unit (null for root) |
by_unit[].position_count | integer | Active positions in this unit |
by_unit[].covered | integer | COVERED positions |
by_unit[].partial | integer | PARTIAL positions |
by_unit[].vacant | integer | VACANT positions |
by_unit[].over_covered | integer | OVER_COVERED positions |
by_unit[].required_hours | decimal(10,2) | Sum of required_weekly_hours for this unit |
by_unit[].assigned_hours | decimal(10,2) | Sum of active assignment hours for this unit |
by_unit[].coverage_pct | decimal(5,2) | Unit-level coverage percentage |
by_unit[].employee_breakdown | object | Employee status counts for this unit’s active assignments |
by_unit[].employee_breakdown.active | integer | Employees with status ACTIVE |
by_unit[].employee_breakdown.on_leave | integer | Employees with status ON_LEAVE |
by_unit[].employee_breakdown.other | integer | Employees with any other status |
by_unit is sorted by coverage_pct ascending (worst coverage first). Only units with at least one active position are included.
Hierarchical aggregation: The by_unit array is flat — it lists only UNIT-type nodes that hold positions. Each entry includes parent_id, allowing the frontend to reconstruct the tree hierarchy and roll up coverage from Unit → Service → Department → Clinic. The backend does not perform hierarchical aggregation; the client computes parent-level metrics by traversing parent_id chains against the org-unit tree from GET /api/v1/org-units/.
Business Rules (Reference List)
Sección titulada «Business Rules (Reference List)»Base path: /api/v1/business-rules/. All endpoints require authentication.
Non-paginated reference list. Rules are seeded via migration and configurable via admin. The evaluation engine is separate — this endpoint exposes the catalogue only.
When rules are evaluated:
| Trigger | Endpoint | Behavior |
|---|---|---|
| Assignment preview | POST /api/v1/assignments/preview/ | All enabled rules evaluated; violations returned without persisting |
| Assignment creation | POST /api/v1/assignments/ | BLOCKING rules prevent creation (400); WARNING/INFO violations logged to metadata |
| Assignment update | PATCH /api/v1/assignments/{id}/ | Same as creation — re-evaluated on effective_hours change |
Rules are not evaluated on DELETE, FSM transitions (suspend/resume/end), or read operations.
GET /api/v1/business-rules/
Sección titulada «GET /api/v1/business-rules/»List all business rules. Non-paginated — returns the full set.
Response 200 OK:
[ { "id": "uuid", "code": "MAX_WEEKLY_HOURS", "name": "Límite máximo de horas semanales", "severity": "BLOCKING", "threshold": "60.00", "enabled": true, "description": "No exceder el tope legal de horas semanales", "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" }]| Field | Type | Notes |
|---|---|---|
id | UUID | Primary key |
code | string | Unique identifier (e.g., MAX_WEEKLY_HOURS) |
name | string | Human-readable label |
severity | string | Enum: BLOCKING, WARNING, INFO (ADR-010) |
threshold | decimal(10,2) | null | Numeric value for rules that need one (e.g., 60h cap, 30 days) |
enabled | boolean | Toggle on/off without code changes |
description | string | Explanation for control panel UI |
created_at | datetime | |
updated_at | datetime |
severity semantics (BLOCKING prevents, WARNING advises, INFO logs only) are defined in [[adr-010-rule-severity-levels|ADR-010]].
GET /api/v1/business-rules/{id}/
Sección titulada «GET /api/v1/business-rules/{id}/»Retrieve a single business rule by UUID.
Response 200 OK — single object (same shape as list item).
Response 404 Not Found — rule does not exist.
MVP seed rules (7):
| Code | Severity | Threshold | Description |
|---|---|---|---|
MAX_WEEKLY_HOURS | BLOCKING | 60.00 | No exceder el tope legal de horas semanales |
DUPLICATE_ASSIGNMENT | BLOCKING | — | No duplicar asignación activa por empleado y puesto |
EMPLOYEE_TERMINATED | BLOCKING | — | No asignar empleados desvinculados |
TAG_REQUIREMENT_MISMATCH | BLOCKING | — | Empleado carece de tags requeridos por el puesto (ADR-019 §5). Admin-configurable a WARNING. |
MAX_CONSECUTIVE_SHIFTS | WARNING | — | Empleado acercándose al límite de días consecutivos |
COVERAGE_EXCEEDED | WARNING | — | Puesto quedaría en excedente de horas |
CONTRACT_NEAR_EXPIRY | INFO | 30.00 | Contrato próximo a vencer |
Scope: Health + Authentication + GDPR + Employees + Employee FSM + Audit Log (transitions, tag-change-logs) + Assignments + Assignment FSM + Hours Ledger (offer) + Tags + Employee/Position Tags + Org Units + Positions (demand) + Coverage Summary + Business Rules. Conventions: see [[api-conventions]].