# Especificacion Tecnica Backend - Modulo Purchases (MGN-012) ## METADATOS | Campo | Valor | |-------|-------| | **Modulo** | MGN-012 | | **Nombre** | Purchases (Compras) | | **Version** | 1.0.0 | | **Fecha** | 2026-01-10 | | **Estado** | Implementado | | **Backend Path** | `backend/src/modules/purchases/` | | **Schema BD** | `purchase` | --- ## SERVICIOS ### 1. PurchasesService **Archivo:** `purchases.service.ts` **Descripcion:** Servicio para gestion de ordenes de compra (Purchase Orders). #### Metodos | Metodo | Parametros | Retorno | Descripcion | |--------|------------|---------|-------------| | `findAll` | `tenantId: string`, `filters: PurchaseOrderFilters` | `Promise<{ data: PurchaseOrder[]; total: number }>` | Lista ordenes de compra con paginacion y filtros | | `findById` | `id: string`, `tenantId: string` | `Promise` | Obtiene una orden por ID con sus lineas | | `create` | `dto: CreatePurchaseOrderDto`, `tenantId: string`, `userId: string` | `Promise` | Crea nueva orden de compra con lineas (transaccion) | | `update` | `id: string`, `dto: UpdatePurchaseOrderDto`, `tenantId: string`, `userId: string` | `Promise` | Actualiza orden en estado draft | | `confirm` | `id: string`, `tenantId: string`, `userId: string` | `Promise` | Confirma orden (draft -> confirmed) | | `cancel` | `id: string`, `tenantId: string`, `userId: string` | `Promise` | Cancela orden (no aplica a done) | | `delete` | `id: string`, `tenantId: string` | `Promise` | Elimina orden en estado draft | #### Tipos de Estado (OrderStatus) ```typescript type OrderStatus = 'draft' | 'sent' | 'confirmed' | 'done' | 'cancelled'; ``` #### Filtros Disponibles (PurchaseOrderFilters) ```typescript interface PurchaseOrderFilters { company_id?: string; partner_id?: string; status?: OrderStatus; date_from?: string; date_to?: string; search?: string; page?: number; // default: 1 limit?: number; // default: 20 } ``` --- ### 2. RfqsService **Archivo:** `rfqs.service.ts` **Descripcion:** Servicio para gestion de Solicitudes de Cotizacion (Request for Quotation - RFQ). #### Metodos | Metodo | Parametros | Retorno | Descripcion | |--------|------------|---------|-------------| | `findAll` | `tenantId: string`, `filters: RfqFilters` | `Promise<{ data: Rfq[]; total: number }>` | Lista RFQs con paginacion y filtros | | `findById` | `id: string`, `tenantId: string` | `Promise` | Obtiene RFQ por ID con lineas y partners | | `create` | `dto: CreateRfqDto`, `tenantId: string`, `userId: string` | `Promise` | Crea nueva RFQ con lineas (transaccion) | | `update` | `id: string`, `dto: UpdateRfqDto`, `tenantId: string`, `userId: string` | `Promise` | Actualiza RFQ en estado draft | | `addLine` | `rfqId: string`, `dto: CreateRfqLineDto`, `tenantId: string` | `Promise` | Agrega linea a RFQ en draft | | `updateLine` | `rfqId: string`, `lineId: string`, `dto: UpdateRfqLineDto`, `tenantId: string` | `Promise` | Actualiza linea de RFQ en draft | | `removeLine` | `rfqId: string`, `lineId: string`, `tenantId: string` | `Promise` | Elimina linea (minimo 1 linea requerida) | | `send` | `id: string`, `tenantId: string`, `userId: string` | `Promise` | Envia RFQ (draft -> sent) | | `markResponded` | `id: string`, `tenantId: string`, `userId: string` | `Promise` | Marca como respondida (sent -> responded) | | `accept` | `id: string`, `tenantId: string`, `userId: string` | `Promise` | Acepta RFQ (sent/responded -> accepted) | | `reject` | `id: string`, `tenantId: string`, `userId: string` | `Promise` | Rechaza RFQ (sent/responded -> rejected) | | `cancel` | `id: string`, `tenantId: string`, `userId: string` | `Promise` | Cancela RFQ (no aplica a accepted) | | `delete` | `id: string`, `tenantId: string` | `Promise` | Elimina RFQ en estado draft | #### Tipos de Estado (RfqStatus) ```typescript type RfqStatus = 'draft' | 'sent' | 'responded' | 'accepted' | 'rejected' | 'cancelled'; ``` #### Filtros Disponibles (RfqFilters) ```typescript interface RfqFilters { company_id?: string; status?: RfqStatus; date_from?: string; date_to?: string; search?: string; page?: number; // default: 1 limit?: number; // default: 20 } ``` --- ## ENTIDADES ### Schema: `purchase` #### Tabla: purchase_orders | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NO | gen_random_uuid() | Clave primaria | | `tenant_id` | UUID | NO | - | FK a auth.tenants | | `company_id` | UUID | NO | - | FK a auth.companies | | `name` | VARCHAR(100) | NO | - | Numero de orden (unico por company) | | `ref` | VARCHAR(100) | SI | - | Referencia del proveedor | | `partner_id` | UUID | NO | - | FK a core.partners (proveedor) | | `order_date` | DATE | NO | - | Fecha de la orden | | `expected_date` | DATE | SI | - | Fecha esperada de recepcion | | `effective_date` | DATE | SI | - | Fecha efectiva de recepcion | | `currency_id` | UUID | NO | - | FK a core.currencies | | `payment_term_id` | UUID | SI | - | FK a financial.payment_terms | | `amount_untaxed` | DECIMAL(15,2) | NO | 0 | Monto sin impuestos | | `amount_tax` | DECIMAL(15,2) | NO | 0 | Monto de impuestos | | `amount_total` | DECIMAL(15,2) | NO | 0 | Monto total | | `status` | order_status | NO | 'draft' | Estado de la orden | | `receipt_status` | VARCHAR(20) | SI | 'pending' | Estado de recepcion | | `invoice_status` | VARCHAR(20) | SI | 'pending' | Estado de facturacion | | `picking_id` | UUID | SI | - | FK a inventory.pickings | | `invoice_id` | UUID | SI | - | FK a financial.invoices | | `notes` | TEXT | SI | - | Notas adicionales | | `dest_address_id` | UUID | SI | - | Direccion de envio (dropship) | | `locked` | BOOLEAN | SI | FALSE | Bloqueo de orden | | `approval_required` | BOOLEAN | SI | FALSE | Requiere aprobacion | | `amount_approval_threshold` | DECIMAL(15,2) | SI | - | Umbral de aprobacion | | `created_at` | TIMESTAMP | NO | CURRENT_TIMESTAMP | Fecha creacion | | `created_by` | UUID | SI | - | Usuario creador | | `updated_at` | TIMESTAMP | SI | - | Fecha actualizacion | | `updated_by` | UUID | SI | - | Usuario actualizacion | | `confirmed_at` | TIMESTAMP | SI | - | Fecha confirmacion | | `confirmed_by` | UUID | SI | - | Usuario confirmacion | | `approved_at` | TIMESTAMP | SI | - | Fecha aprobacion | | `approved_by` | UUID | SI | - | Usuario aprobacion | | `cancelled_at` | TIMESTAMP | SI | - | Fecha cancelacion | | `cancelled_by` | UUID | SI | - | Usuario cancelacion | **Indices:** - `idx_purchase_orders_tenant_id` - `idx_purchase_orders_company_id` - `idx_purchase_orders_partner_id` - `idx_purchase_orders_name` - `idx_purchase_orders_status` - `idx_purchase_orders_order_date` - `idx_purchase_orders_expected_date` **Constraint UNIQUE:** `(company_id, name)` --- #### Tabla: purchase_order_lines | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NO | gen_random_uuid() | Clave primaria | | `tenant_id` | UUID | NO | - | FK a auth.tenants | | `order_id` | UUID | NO | - | FK a purchase.purchase_orders | | `product_id` | UUID | NO | - | FK a inventory.products | | `description` | TEXT | NO | - | Descripcion del producto | | `quantity` | DECIMAL(12,4) | NO | - | Cantidad solicitada | | `qty_received` | DECIMAL(12,4) | SI | 0 | Cantidad recibida | | `qty_invoiced` | DECIMAL(12,4) | SI | 0 | Cantidad facturada | | `uom_id` | UUID | NO | - | FK a core.uom | | `price_unit` | DECIMAL(15,4) | NO | - | Precio unitario | | `discount` | DECIMAL(5,2) | SI | 0 | Porcentaje descuento | | `tax_ids` | UUID[] | SI | '{}' | Array de impuestos | | `amount_untaxed` | DECIMAL(15,2) | NO | - | Subtotal sin impuestos | | `amount_tax` | DECIMAL(15,2) | NO | - | Monto impuestos | | `amount_total` | DECIMAL(15,2) | NO | - | Total linea | | `expected_date` | DATE | SI | - | Fecha esperada | | `analytic_account_id` | UUID | SI | - | FK a analytics.analytic_accounts | | `created_at` | TIMESTAMP | NO | CURRENT_TIMESTAMP | Fecha creacion | | `updated_at` | TIMESTAMP | SI | - | Fecha actualizacion | **Constraints:** - `chk_purchase_order_lines_quantity`: quantity > 0 - `chk_purchase_order_lines_discount`: discount >= 0 AND discount <= 100 --- #### Tabla: rfqs | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NO | gen_random_uuid() | Clave primaria | | `tenant_id` | UUID | NO | - | FK a auth.tenants | | `company_id` | UUID | NO | - | FK a auth.companies | | `name` | VARCHAR(100) | NO | - | Numero RFQ (ej: RFQ-000001) | | `partner_ids` | UUID[] | NO | - | Array de proveedores | | `request_date` | DATE | NO | - | Fecha de solicitud | | `deadline_date` | DATE | SI | - | Fecha limite respuesta | | `response_date` | DATE | SI | - | Fecha de respuesta | | `status` | rfq_status | NO | 'draft' | Estado del RFQ | | `description` | TEXT | SI | - | Descripcion | | `notes` | TEXT | SI | - | Notas adicionales | | `created_at` | TIMESTAMP | NO | CURRENT_TIMESTAMP | Fecha creacion | | `created_by` | UUID | SI | - | Usuario creador | | `updated_at` | TIMESTAMP | SI | - | Fecha actualizacion | | `updated_by` | UUID | SI | - | Usuario actualizacion | **Constraint UNIQUE:** `(company_id, name)` --- #### Tabla: rfq_lines | Columna | Tipo | Nullable | Default | Descripcion | |---------|------|----------|---------|-------------| | `id` | UUID | NO | gen_random_uuid() | Clave primaria | | `tenant_id` | UUID | NO | - | FK a auth.tenants | | `rfq_id` | UUID | NO | - | FK a purchase.rfqs | | `product_id` | UUID | SI | - | FK a inventory.products | | `description` | TEXT | NO | - | Descripcion del producto | | `quantity` | DECIMAL(12,4) | NO | - | Cantidad solicitada | | `uom_id` | UUID | NO | - | FK a core.uom | | `created_at` | TIMESTAMP | NO | CURRENT_TIMESTAMP | Fecha creacion | **Constraint:** `chk_rfq_lines_quantity`: quantity > 0 --- ## DTOs ### Purchase Orders #### CreatePurchaseOrderDto ```typescript interface CreatePurchaseOrderDto { company_id: string; // UUID, requerido name: string; // min: 1, max: 100 ref?: string; // max: 100 partner_id: string; // UUID, requerido order_date: string; // formato: YYYY-MM-DD expected_date?: string; // formato: YYYY-MM-DD currency_id: string; // UUID, requerido payment_term_id?: string; // UUID notes?: string; lines: PurchaseOrderLineDto[]; // min: 1 linea } interface PurchaseOrderLineDto { product_id: string; // UUID, requerido description: string; // min: 1 quantity: number; // positive uom_id: string; // UUID, requerido price_unit: number; // min: 0 discount?: number; // 0-100, default: 0 amount_untaxed: number; // min: 0 } ``` #### UpdatePurchaseOrderDto ```typescript interface UpdatePurchaseOrderDto { ref?: string | null; partner_id?: string; order_date?: string; expected_date?: string | null; currency_id?: string; payment_term_id?: string | null; notes?: string | null; lines?: PurchaseOrderLineDto[]; // min: 1 si se proporciona } ``` ### RFQs #### CreateRfqDto ```typescript interface CreateRfqDto { company_id: string; // UUID, requerido partner_ids: string[]; // Array UUID, min: 1 request_date?: string; // YYYY-MM-DD, default: hoy deadline_date?: string; // YYYY-MM-DD description?: string; notes?: string; lines: CreateRfqLineDto[]; // min: 1 linea } interface CreateRfqLineDto { product_id?: string; // UUID, opcional description: string; // min: 1 quantity: number; // positive uom_id: string; // UUID, requerido } ``` #### UpdateRfqDto ```typescript interface UpdateRfqDto { partner_ids?: string[]; // Array UUID, min: 1 si se proporciona deadline_date?: string | null; description?: string | null; notes?: string | null; } ``` #### UpdateRfqLineDto ```typescript interface UpdateRfqLineDto { product_id?: string | null; description?: string; quantity?: number; uom_id?: string; } ``` --- ## ENDPOINTS ### Base Path: `/api/purchases` #### Purchase Orders | Metodo | Ruta | Roles Permitidos | Descripcion | |--------|------|------------------|-------------| | GET | `/` | admin, manager, warehouse, accountant, super_admin | Listar ordenes de compra | | GET | `/:id` | admin, manager, warehouse, accountant, super_admin | Obtener orden por ID | | POST | `/` | admin, manager, warehouse, super_admin | Crear orden de compra | | PUT | `/:id` | admin, manager, warehouse, super_admin | Actualizar orden | | POST | `/:id/confirm` | admin, manager, super_admin | Confirmar orden | | POST | `/:id/cancel` | admin, manager, super_admin | Cancelar orden | | DELETE | `/:id` | admin, super_admin | Eliminar orden (solo draft) | #### RFQs (Request for Quotation) | Metodo | Ruta | Roles Permitidos | Descripcion | |--------|------|------------------|-------------| | GET | `/rfqs` | admin, manager, warehouse, super_admin | Listar RFQs | | GET | `/rfqs/:id` | admin, manager, warehouse, super_admin | Obtener RFQ por ID | | POST | `/rfqs` | admin, manager, warehouse, super_admin | Crear RFQ | | PUT | `/rfqs/:id` | admin, manager, warehouse, super_admin | Actualizar RFQ | | DELETE | `/rfqs/:id` | admin, super_admin | Eliminar RFQ (solo draft) | #### RFQ Lines | Metodo | Ruta | Roles Permitidos | Descripcion | |--------|------|------------------|-------------| | POST | `/rfqs/:id/lines` | admin, manager, warehouse, super_admin | Agregar linea a RFQ | | PUT | `/rfqs/:id/lines/:lineId` | admin, manager, warehouse, super_admin | Actualizar linea | | DELETE | `/rfqs/:id/lines/:lineId` | admin, manager, warehouse, super_admin | Eliminar linea | #### RFQ Workflow | Metodo | Ruta | Roles Permitidos | Descripcion | |--------|------|------------------|-------------| | POST | `/rfqs/:id/send` | admin, manager, super_admin | Enviar RFQ a proveedores | | POST | `/rfqs/:id/responded` | admin, manager, super_admin | Marcar como respondida | | POST | `/rfqs/:id/accept` | admin, manager, super_admin | Aceptar RFQ | | POST | `/rfqs/:id/reject` | admin, manager, super_admin | Rechazar RFQ | | POST | `/rfqs/:id/cancel` | admin, manager, super_admin | Cancelar RFQ | ### Formato de Respuesta #### Exito (Listado) ```json { "success": true, "data": [...], "meta": { "total": 100, "page": 1, "limit": 20, "totalPages": 5 } } ``` #### Exito (Entidad) ```json { "success": true, "data": { ... }, "message": "Operacion exitosa" } ``` #### Error ```json { "success": false, "error": { "code": "VALIDATION_ERROR", "message": "Descripcion del error", "details": [...] } } ``` --- ## TESTS ### Archivo: `__tests__/purchases.service.spec.ts` #### Casos de Prueba - PurchasesService | Suite | Test Case | Estado | |-------|-----------|--------| | findAll | should return paginated orders for a tenant | PASS | | findAll | should enforce tenant isolation | PASS | | findAll | should apply pagination correctly | PASS | | findById | should return order with lines by id | PASS | | findById | should throw NotFoundError when order does not exist | PASS | | findById | should enforce tenant isolation | PASS | | create | should create order with lines in transaction | PASS | | create | should rollback transaction on error | PASS | | update | should update draft order successfully | PASS | | update | should throw ValidationError when updating confirmed order | PASS | | delete | should delete draft order successfully | PASS | | delete | should throw ValidationError when deleting confirmed order | PASS | | confirm | should confirm draft order successfully | PASS | | confirm | should throw ValidationError when confirming order without lines | PASS | | confirm | should throw ValidationError when confirming non-draft order | PASS | | cancel | should cancel draft order successfully | PASS | | cancel | should throw ValidationError when cancelling done order | PASS | | cancel | should throw ValidationError when cancelling already cancelled order | PASS | ### Archivo: `__tests__/rfqs.service.spec.ts` #### Casos de Prueba - RfqsService | Suite | Test Case | Estado | |-------|-----------|--------| | findAll | should return paginated RFQs for a tenant | PASS | | findAll | should enforce tenant isolation | PASS | | findAll | should apply pagination correctly | PASS | | findAll | should filter by company_id, status, date range, search | PASS | | findById | should return RFQ with lines by id | PASS | | findById | should return partner names when partner_ids exist | PASS | | findById | should throw NotFoundError when RFQ does not exist | PASS | | create | should create RFQ with lines in transaction | PASS | | create | should generate sequential RFQ name | PASS | | create | should rollback transaction on error | PASS | | create | should throw ValidationError when lines or partner_ids empty | PASS | | update | should update draft RFQ successfully | PASS | | update | should throw ValidationError when updating non-draft RFQ | PASS | | delete | should delete draft RFQ successfully | PASS | | delete | should throw ValidationError when deleting non-draft RFQ | PASS | | addLine | should add line to draft RFQ successfully | PASS | | updateLine | should update line in draft RFQ successfully | PASS | | removeLine | should remove line from draft RFQ (min 1 required) | PASS | | send | should send draft RFQ successfully | PASS | | markResponded | should mark sent RFQ as responded | PASS | | accept | should accept responded/sent RFQ | PASS | | reject | should reject responded/sent RFQ | PASS | | cancel | should cancel draft/sent/responded/rejected RFQ | PASS | | Status Transitions | Validates all valid/invalid state transitions | PASS | | Tenant Isolation | should not access RFQs from different tenant | PASS | | Error Handling | should propagate database errors | PASS | --- ## DEPENDENCIAS ### Internas (Modulos del Sistema) | Modulo | Uso | |--------|-----| | `config/database` | Conexion a BD (query, queryOne, getClient) | | `shared/errors` | NotFoundError, ConflictError, ValidationError | | `shared/middleware/auth.middleware` | authenticate, requireRoles, AuthenticatedRequest | ### Externas (npm) | Paquete | Uso | |---------|-----| | `express` | Router, Request, Response, NextFunction | | `zod` | Validacion de DTOs y schemas | | `pg` | Cliente PostgreSQL (via config/database) | ### Tablas Relacionadas | Schema | Tabla | Relacion | |--------|-------|----------| | auth | tenants | tenant_id (multi-tenant) | | auth | companies | company_id | | auth | users | created_by, updated_by, confirmed_by | | core | partners | partner_id (proveedores) | | core | currencies | currency_id | | core | uom | uom_id (unidades de medida) | | inventory | products | product_id | | inventory | pickings | picking_id (recepciones) | | financial | payment_terms | payment_term_id | | financial | invoices | invoice_id | | analytics | analytic_accounts | analytic_account_id | --- ## DIAGRAMAS ### Flujo de Estados - Purchase Order ``` +--------+ | draft | +---+----+ | +--------------+---------------+ | | | v v v +-------+ +---------+ +-----------+ | sent | --> | confirmed| | cancelled | +-------+ +----+----+ +-----------+ | v +--------+ | done | +--------+ ``` ### Flujo de Estados - RFQ ``` +--------+ | draft | +---+----+ | v +-------+ | sent | +---+---+ | +--------------+---------------+ | | | v v v +-----------+ +----------+ +-----------+ | responded | | accepted | | rejected | +-----+-----+ +----+-----+ +-----+-----+ | | | v | | +-----------+ | | | accepted |<------+ | +-----------+ | | | v v +-----------+ +-----------+ | (end) | | cancelled | +-----------+ +-----------+ ``` --- ## NOTAS ADICIONALES ### Seguridad 1. **Multi-tenant:** Todas las consultas incluyen filtro por `tenant_id` 2. **RLS (Row Level Security):** Habilitado en todas las tablas del schema `purchase` 3. **Autenticacion:** Todos los endpoints requieren autenticacion JWT 4. **Autorizacion:** Roles especificos por endpoint ### Validaciones de Negocio 1. **Ordenes de Compra:** - Solo se pueden modificar/eliminar ordenes en estado `draft` - Minimo 1 linea requerida para confirmar - No se puede cancelar una orden `done` 2. **RFQs:** - Solo se pueden modificar/eliminar RFQs en estado `draft` - Minimo 1 proveedor (partner_id) requerido - Minimo 1 linea requerida - No se puede cancelar un RFQ `accepted` ### Transacciones - Creacion de ordenes/RFQs con lineas utiliza transacciones (BEGIN/COMMIT/ROLLBACK) - Rollback automatico en caso de error --- ## CHANGELOG | Version | Fecha | Cambios | |---------|-------|---------| | 1.0.0 | 2026-01-10 | Version inicial - PurchasesService y RfqsService implementados |