687 lines
15 KiB
Markdown
687 lines
15 KiB
Markdown
# ESTANDARES: API REST Generico ERP
|
|
|
|
**Proyecto:** ERP Core - Base Generica Reutilizable
|
|
**Version:** 1.0.0
|
|
**Fecha:** 2025-12-05
|
|
**Aplicable a:** ERP Core y todas las verticales
|
|
**Estado:** OBLIGATORIO
|
|
|
|
---
|
|
|
|
## PRINCIPIOS FUNDAMENTALES
|
|
|
|
> **APIs consistentes, predecibles y autodocumentadas**
|
|
|
|
Todas las APIs del ERP siguen patrones RESTful estandar para facilitar integracion y mantenimiento.
|
|
|
|
---
|
|
|
|
## ESTRUCTURA DE URLs
|
|
|
|
### Patron Base
|
|
|
|
```
|
|
/api/{version}/{recurso}
|
|
/api/{version}/{recurso}/{id}
|
|
/api/{version}/{recurso}/{id}/{sub-recurso}
|
|
```
|
|
|
|
### Ejemplos
|
|
|
|
```
|
|
# Core
|
|
GET /api/v1/partners # Lista partners
|
|
POST /api/v1/partners # Crear partner
|
|
GET /api/v1/partners/:id # Obtener partner
|
|
PATCH /api/v1/partners/:id # Actualizar parcial
|
|
PUT /api/v1/partners/:id # Actualizar completo
|
|
DELETE /api/v1/partners/:id # Eliminar (soft delete)
|
|
|
|
# Sub-recursos
|
|
GET /api/v1/partners/:id/contacts # Contactos de partner
|
|
POST /api/v1/partners/:id/contacts # Crear contacto
|
|
GET /api/v1/sale-orders/:id/lines # Lineas de orden
|
|
POST /api/v1/sale-orders/:id/lines # Agregar linea
|
|
|
|
# Acciones
|
|
POST /api/v1/sale-orders/:id/confirm # Confirmar orden
|
|
POST /api/v1/sale-orders/:id/cancel # Cancelar orden
|
|
POST /api/v1/invoices/:id/validate # Validar factura
|
|
|
|
# Verticales (namespace propio)
|
|
GET /api/v1/construccion/proyectos-obra
|
|
POST /api/v1/construccion/avances
|
|
GET /api/v1/vidrio/ordenes-produccion
|
|
```
|
|
|
|
---
|
|
|
|
## CONVENCIONES DE NOMBRES
|
|
|
|
### URLs
|
|
- **Sustantivos en plural:** `/partners`, `/products`, `/sale-orders`
|
|
- **Kebab-case:** `/sale-orders`, `/product-categories`
|
|
- **Sin verbos en URL:** `/partners` no `/getPartners`
|
|
- **Jerarquia logica:** `/partners/:id/contacts`
|
|
|
|
### Query Parameters
|
|
- **snake_case:** `?page=1&per_page=20&sort_by=name`
|
|
- **Filtros con prefijo:** `?filter[status]=active&filter[type]=customer`
|
|
- **Busqueda:** `?search=texto`
|
|
- **Ordenamiento:** `?sort_by=created_at&sort_order=desc`
|
|
|
|
### Body (Request/Response)
|
|
- **camelCase:** `{ "partnerId": "uuid", "taxId": "RFC123" }`
|
|
|
|
---
|
|
|
|
## PAGINACION
|
|
|
|
### Request
|
|
|
|
```
|
|
GET /api/v1/partners?page=2&per_page=20&sort_by=name&sort_order=asc
|
|
```
|
|
|
|
### Response
|
|
|
|
```json
|
|
{
|
|
"data": [
|
|
{ "id": "uuid-1", "name": "Partner 1", ... },
|
|
{ "id": "uuid-2", "name": "Partner 2", ... }
|
|
],
|
|
"meta": {
|
|
"currentPage": 2,
|
|
"perPage": 20,
|
|
"totalPages": 15,
|
|
"totalCount": 287,
|
|
"hasNextPage": true,
|
|
"hasPrevPage": true
|
|
},
|
|
"links": {
|
|
"self": "/api/v1/partners?page=2&per_page=20",
|
|
"first": "/api/v1/partners?page=1&per_page=20",
|
|
"prev": "/api/v1/partners?page=1&per_page=20",
|
|
"next": "/api/v1/partners?page=3&per_page=20",
|
|
"last": "/api/v1/partners?page=15&per_page=20"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Limites
|
|
|
|
| Parametro | Default | Maximo |
|
|
|-----------|---------|--------|
|
|
| per_page | 20 | 100 |
|
|
| offset | 0 | - |
|
|
|
|
---
|
|
|
|
## FILTRADO
|
|
|
|
### Sintaxis
|
|
|
|
```
|
|
GET /api/v1/partners?filter[status]=active&filter[type]=customer
|
|
GET /api/v1/partners?filter[created_at][gte]=2025-01-01
|
|
GET /api/v1/partners?filter[name][like]=Constructora
|
|
```
|
|
|
|
### Operadores
|
|
|
|
| Operador | Descripcion | Ejemplo |
|
|
|----------|-------------|---------|
|
|
| (default) | Igual | `filter[status]=active` |
|
|
| `[ne]` | No igual | `filter[status][ne]=cancelled` |
|
|
| `[gt]` | Mayor que | `filter[amount][gt]=1000` |
|
|
| `[gte]` | Mayor o igual | `filter[date][gte]=2025-01-01` |
|
|
| `[lt]` | Menor que | `filter[amount][lt]=5000` |
|
|
| `[lte]` | Menor o igual | `filter[date][lte]=2025-12-31` |
|
|
| `[like]` | Contiene | `filter[name][like]=Corp` |
|
|
| `[in]` | En lista | `filter[status][in]=draft,confirmed` |
|
|
| `[null]` | Es nulo | `filter[deleted_at][null]=true` |
|
|
|
|
### Ejemplo de Implementacion
|
|
|
|
```typescript
|
|
// Query builder con filtros dinamicos
|
|
@Injectable()
|
|
export class FilterService {
|
|
applyFilters<T>(
|
|
qb: SelectQueryBuilder<T>,
|
|
filters: Record<string, any>,
|
|
allowedFields: string[]
|
|
): SelectQueryBuilder<T> {
|
|
for (const [key, value] of Object.entries(filters)) {
|
|
if (!allowedFields.includes(key.split('[')[0])) continue;
|
|
|
|
if (typeof value === 'object') {
|
|
// Operador especificado
|
|
const [field, operator] = key.split('[');
|
|
const op = operator?.replace(']', '');
|
|
this.applyOperator(qb, field, op, value);
|
|
} else {
|
|
// Igualdad simple
|
|
qb.andWhere(`entity.${key} = :${key}`, { [key]: value });
|
|
}
|
|
}
|
|
return qb;
|
|
}
|
|
|
|
private applyOperator(qb: SelectQueryBuilder<any>, field: string, op: string, value: any) {
|
|
switch (op) {
|
|
case 'ne':
|
|
qb.andWhere(`entity.${field} != :${field}`, { [field]: value });
|
|
break;
|
|
case 'gt':
|
|
qb.andWhere(`entity.${field} > :${field}`, { [field]: value });
|
|
break;
|
|
case 'gte':
|
|
qb.andWhere(`entity.${field} >= :${field}`, { [field]: value });
|
|
break;
|
|
case 'lt':
|
|
qb.andWhere(`entity.${field} < :${field}`, { [field]: value });
|
|
break;
|
|
case 'lte':
|
|
qb.andWhere(`entity.${field} <= :${field}`, { [field]: value });
|
|
break;
|
|
case 'like':
|
|
qb.andWhere(`entity.${field} ILIKE :${field}`, { [field]: `%${value}%` });
|
|
break;
|
|
case 'in':
|
|
const values = value.split(',');
|
|
qb.andWhere(`entity.${field} IN (:...${field})`, { [field]: values });
|
|
break;
|
|
case 'null':
|
|
if (value === 'true') {
|
|
qb.andWhere(`entity.${field} IS NULL`);
|
|
} else {
|
|
qb.andWhere(`entity.${field} IS NOT NULL`);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## ORDENAMIENTO
|
|
|
|
### Request
|
|
|
|
```
|
|
GET /api/v1/partners?sort_by=name&sort_order=asc
|
|
GET /api/v1/partners?sort=-created_at,name # Alternativa: - para desc
|
|
```
|
|
|
|
### Campos Ordenables por Defecto
|
|
|
|
- `id`
|
|
- `name`
|
|
- `created_at`
|
|
- `updated_at`
|
|
|
|
Cada endpoint debe documentar campos ordenables adicionales.
|
|
|
|
---
|
|
|
|
## RESPUESTAS
|
|
|
|
### Estructura Exitosa
|
|
|
|
```json
|
|
// GET /api/v1/partners/:id (Recurso individual)
|
|
{
|
|
"data": {
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"name": "Constructora ABC",
|
|
"taxId": "CAB123456AB1",
|
|
"isCompany": true,
|
|
"email": "contacto@constructoraabc.com",
|
|
"createdAt": "2025-01-15T10:30:00Z",
|
|
"updatedAt": "2025-06-20T14:45:00Z"
|
|
}
|
|
}
|
|
|
|
// POST /api/v1/partners (Creacion)
|
|
{
|
|
"data": {
|
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
|
"name": "Nuevo Partner",
|
|
...
|
|
},
|
|
"message": "Partner creado exitosamente"
|
|
}
|
|
|
|
// DELETE /api/v1/partners/:id
|
|
{
|
|
"data": null,
|
|
"message": "Partner eliminado exitosamente"
|
|
}
|
|
```
|
|
|
|
### Estructura de Error
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "VALIDATION_ERROR",
|
|
"message": "Los datos proporcionados no son validos",
|
|
"details": [
|
|
{
|
|
"field": "email",
|
|
"message": "Formato de email invalido",
|
|
"code": "INVALID_FORMAT"
|
|
},
|
|
{
|
|
"field": "taxId",
|
|
"message": "RFC ya registrado",
|
|
"code": "DUPLICATE_VALUE"
|
|
}
|
|
],
|
|
"timestamp": "2025-12-05T10:30:00Z",
|
|
"path": "/api/v1/partners",
|
|
"requestId": "req-123-456-789"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CODIGOS HTTP
|
|
|
|
### Exito (2xx)
|
|
|
|
| Codigo | Uso |
|
|
|--------|-----|
|
|
| 200 OK | GET exitoso, PATCH/PUT exitoso |
|
|
| 201 Created | POST exitoso (recurso creado) |
|
|
| 204 No Content | DELETE exitoso |
|
|
|
|
### Error Cliente (4xx)
|
|
|
|
| Codigo | Uso |
|
|
|--------|-----|
|
|
| 400 Bad Request | Validacion fallida, formato invalido |
|
|
| 401 Unauthorized | No autenticado |
|
|
| 403 Forbidden | Sin permisos |
|
|
| 404 Not Found | Recurso no existe |
|
|
| 409 Conflict | Conflicto (duplicado, estado invalido) |
|
|
| 422 Unprocessable Entity | Logica de negocio fallida |
|
|
| 429 Too Many Requests | Rate limit excedido |
|
|
|
|
### Error Servidor (5xx)
|
|
|
|
| Codigo | Uso |
|
|
|--------|-----|
|
|
| 500 Internal Server Error | Error no manejado |
|
|
| 502 Bad Gateway | Servicio externo fallo |
|
|
| 503 Service Unavailable | Servicio temporalmente no disponible |
|
|
|
|
---
|
|
|
|
## CODIGOS DE ERROR
|
|
|
|
### Catalogo de Errores
|
|
|
|
```typescript
|
|
export enum ErrorCode {
|
|
// Validacion (VAL-*)
|
|
VALIDATION_ERROR = 'VAL-001',
|
|
INVALID_FORMAT = 'VAL-002',
|
|
REQUIRED_FIELD = 'VAL-003',
|
|
INVALID_LENGTH = 'VAL-004',
|
|
|
|
// Autenticacion (AUTH-*)
|
|
UNAUTHORIZED = 'AUTH-001',
|
|
TOKEN_EXPIRED = 'AUTH-002',
|
|
INVALID_TOKEN = 'AUTH-003',
|
|
SESSION_EXPIRED = 'AUTH-004',
|
|
|
|
// Autorizacion (AUTHZ-*)
|
|
FORBIDDEN = 'AUTHZ-001',
|
|
INSUFFICIENT_PERMISSIONS = 'AUTHZ-002',
|
|
TENANT_MISMATCH = 'AUTHZ-003',
|
|
|
|
// Recursos (RES-*)
|
|
NOT_FOUND = 'RES-001',
|
|
ALREADY_EXISTS = 'RES-002',
|
|
CONFLICT = 'RES-003',
|
|
DELETED = 'RES-004',
|
|
|
|
// Negocio (BIZ-*)
|
|
BUSINESS_RULE_VIOLATION = 'BIZ-001',
|
|
INVALID_STATE_TRANSITION = 'BIZ-002',
|
|
BUDGET_EXCEEDED = 'BIZ-003',
|
|
STOCK_INSUFFICIENT = 'BIZ-004',
|
|
|
|
// Sistema (SYS-*)
|
|
INTERNAL_ERROR = 'SYS-001',
|
|
DATABASE_ERROR = 'SYS-002',
|
|
EXTERNAL_SERVICE_ERROR = 'SYS-003',
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## AUTENTICACION
|
|
|
|
### Headers Requeridos
|
|
|
|
```http
|
|
Authorization: Bearer <jwt_token>
|
|
X-Tenant-Id: <tenant_uuid>
|
|
```
|
|
|
|
### Respuesta 401
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "AUTH-001",
|
|
"message": "Token no proporcionado o invalido",
|
|
"timestamp": "2025-12-05T10:30:00Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## MULTI-TENANCY
|
|
|
|
### Header Obligatorio
|
|
|
|
```http
|
|
X-Tenant-Id: 550e8400-e29b-41d4-a716-446655440000
|
|
```
|
|
|
|
### Validacion en Middleware
|
|
|
|
```typescript
|
|
@Injectable()
|
|
export class TenantMiddleware implements NestMiddleware {
|
|
use(req: Request, res: Response, next: NextFunction) {
|
|
const tenantId = req.headers['x-tenant-id'] as string;
|
|
|
|
if (!tenantId) {
|
|
throw new BadRequestException({
|
|
code: 'AUTHZ-003',
|
|
message: 'Header X-Tenant-Id requerido',
|
|
});
|
|
}
|
|
|
|
// Validar que tenant existe y usuario tiene acceso
|
|
// ...
|
|
|
|
req['tenantId'] = tenantId;
|
|
next();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Filtrado Automatico
|
|
|
|
```typescript
|
|
// Base repository con filtro de tenant
|
|
@Injectable()
|
|
export class TenantAwareRepository<T extends BaseEntity> {
|
|
constructor(
|
|
private repository: Repository<T>,
|
|
private tenantId: string,
|
|
) {}
|
|
|
|
async find(options?: FindManyOptions<T>): Promise<T[]> {
|
|
return this.repository.find({
|
|
...options,
|
|
where: {
|
|
...options?.where,
|
|
tenantId: this.tenantId,
|
|
} as FindOptionsWhere<T>,
|
|
});
|
|
}
|
|
|
|
async findOne(id: string): Promise<T | null> {
|
|
return this.repository.findOne({
|
|
where: {
|
|
id,
|
|
tenantId: this.tenantId,
|
|
} as FindOptionsWhere<T>,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## ACCIONES (RPC-style)
|
|
|
|
Para operaciones que no son CRUD, usar POST con accion en URL:
|
|
|
|
```
|
|
POST /api/v1/sale-orders/:id/confirm
|
|
POST /api/v1/sale-orders/:id/cancel
|
|
POST /api/v1/invoices/:id/validate
|
|
POST /api/v1/invoices/:id/send-email
|
|
POST /api/v1/stock-moves/:id/process
|
|
```
|
|
|
|
### Request/Response
|
|
|
|
```json
|
|
// POST /api/v1/sale-orders/:id/confirm
|
|
// Request body (opcional)
|
|
{
|
|
"skipValidation": false,
|
|
"notifyCustomer": true
|
|
}
|
|
|
|
// Response
|
|
{
|
|
"data": {
|
|
"id": "uuid",
|
|
"state": "confirmed",
|
|
"confirmedAt": "2025-12-05T10:30:00Z",
|
|
"confirmedBy": "user-uuid"
|
|
},
|
|
"message": "Orden confirmada exitosamente"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## BULK OPERATIONS
|
|
|
|
### Crear Multiples
|
|
|
|
```
|
|
POST /api/v1/partners/bulk
|
|
```
|
|
|
|
```json
|
|
{
|
|
"items": [
|
|
{ "name": "Partner 1", "email": "p1@test.com" },
|
|
{ "name": "Partner 2", "email": "p2@test.com" }
|
|
]
|
|
}
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{
|
|
"data": {
|
|
"created": 2,
|
|
"failed": 0,
|
|
"items": [
|
|
{ "id": "uuid-1", "name": "Partner 1", "status": "created" },
|
|
{ "id": "uuid-2", "name": "Partner 2", "status": "created" }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
### Actualizar Multiples
|
|
|
|
```
|
|
PATCH /api/v1/partners/bulk
|
|
```
|
|
|
|
```json
|
|
{
|
|
"ids": ["uuid-1", "uuid-2", "uuid-3"],
|
|
"update": {
|
|
"isActive": false
|
|
}
|
|
}
|
|
```
|
|
|
|
### Eliminar Multiples
|
|
|
|
```
|
|
DELETE /api/v1/partners/bulk?ids=uuid-1,uuid-2,uuid-3
|
|
```
|
|
|
|
---
|
|
|
|
## DOCUMENTACION SWAGGER
|
|
|
|
### Decoradores Obligatorios
|
|
|
|
```typescript
|
|
@Controller('partners')
|
|
@ApiTags('Partners')
|
|
@ApiBearerAuth()
|
|
export class PartnersController {
|
|
|
|
@Get()
|
|
@ApiOperation({ summary: 'Lista paginada de partners' })
|
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
|
@ApiQuery({ name: 'per_page', required: false, type: Number })
|
|
@ApiQuery({ name: 'filter[status]', required: false, type: String })
|
|
@ApiResponse({ status: 200, description: 'Lista de partners', type: PartnerListResponseDto })
|
|
@ApiResponse({ status: 401, description: 'No autenticado' })
|
|
async findAll(@Query() query: PartnerQueryDto): Promise<PaginatedResponse<PartnerDto>> {
|
|
// ...
|
|
}
|
|
|
|
@Post()
|
|
@ApiOperation({ summary: 'Crear nuevo partner' })
|
|
@ApiBody({ type: CreatePartnerDto })
|
|
@ApiResponse({ status: 201, description: 'Partner creado', type: PartnerResponseDto })
|
|
@ApiResponse({ status: 400, description: 'Datos invalidos' })
|
|
@ApiResponse({ status: 409, description: 'Partner ya existe' })
|
|
async create(@Body() dto: CreatePartnerDto): Promise<PartnerDto> {
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
### DTOs Documentados
|
|
|
|
```typescript
|
|
export class CreatePartnerDto {
|
|
@ApiProperty({
|
|
description: 'Nombre o razon social del partner',
|
|
example: 'Constructora ABC S.A. de C.V.',
|
|
minLength: 3,
|
|
maxLength: 200,
|
|
})
|
|
@IsString()
|
|
@Length(3, 200)
|
|
name: string;
|
|
|
|
@ApiProperty({
|
|
description: 'RFC del partner (Mexico)',
|
|
example: 'CAB123456AB1',
|
|
required: false,
|
|
})
|
|
@IsOptional()
|
|
@IsString()
|
|
@Matches(/^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$/, {
|
|
message: 'RFC con formato invalido',
|
|
})
|
|
taxId?: string;
|
|
|
|
@ApiProperty({
|
|
description: 'Indica si es persona moral',
|
|
example: true,
|
|
default: false,
|
|
})
|
|
@IsBoolean()
|
|
@IsOptional()
|
|
isCompany?: boolean = false;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## VERSIONAMIENTO
|
|
|
|
### Estrategia: URL Path
|
|
|
|
```
|
|
/api/v1/partners # Version actual
|
|
/api/v2/partners # Nueva version (cambios breaking)
|
|
```
|
|
|
|
### Cuando Crear Nueva Version
|
|
|
|
- Cambios en estructura de respuesta
|
|
- Eliminar campos existentes
|
|
- Cambiar tipos de datos
|
|
- Cambiar comportamiento de endpoints
|
|
|
|
### Header de Deprecacion
|
|
|
|
```http
|
|
Deprecation: true
|
|
Sunset: Sat, 01 Jun 2026 00:00:00 GMT
|
|
Link: </api/v2/partners>; rel="successor-version"
|
|
```
|
|
|
|
---
|
|
|
|
## RATE LIMITING
|
|
|
|
### Headers de Respuesta
|
|
|
|
```http
|
|
X-RateLimit-Limit: 1000
|
|
X-RateLimit-Remaining: 987
|
|
X-RateLimit-Reset: 1701792000
|
|
```
|
|
|
|
### Respuesta 429
|
|
|
|
```json
|
|
{
|
|
"error": {
|
|
"code": "RATE_LIMIT_EXCEEDED",
|
|
"message": "Demasiadas solicitudes. Intente nuevamente en 60 segundos.",
|
|
"retryAfter": 60
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## REFERENCIAS
|
|
|
|
### Directivas Relacionadas
|
|
- `DIRECTIVA-PATRONES-ODOO.md` - Patrones de datos
|
|
- `DIRECTIVA-MULTI-TENANT.md` - Aislamiento de datos
|
|
- `core/orchestration/directivas/DIRECTIVA-CALIDAD-CODIGO.md`
|
|
|
|
### Estandares Externos
|
|
- [JSON:API Specification](https://jsonapi.org/)
|
|
- [REST API Guidelines (Microsoft)](https://github.com/microsoft/api-guidelines)
|
|
- [OpenAPI 3.0](https://spec.openapis.org/oas/v3.0.3)
|
|
|
|
---
|
|
|
|
**Version:** 1.0.0
|
|
**Ultima actualizacion:** 2025-12-05
|
|
**Estado:** ACTIVA Y OBLIGATORIA
|