Ir al contenido

Compras Cotizadas

Generada 2026-08-13, ampliada 2026-09-10 (exploración de código + navegación en vivo en QA + verificación contra el schema real de PRD, en el marco del ticket GMG-3123 “Filtro guía de pago”). Ver compras.md (mismo directorio) para la descripción base del modelo compartido App\Core\Purchases\Order (tabla orders) — este documento cubre solo lo específico de “Compras Cotizadas”.

  • Modelo: App\Core\Purchases\QuotedOrder (app/Core/Purchases/QuotedOrder.php), que extiende Order (class QuotedOrder extends Order). No es una tabla separada: sigue siendo la tabla orders.
  • Scope isQuoted sobreescrito: Order::boot() define addGlobalScope('isQuoted', fn($q) => $q->where('is_quoted', 0)) (solo compras normales). QuotedOrder::boot() (línea ~251) vuelve a registrar el mismo scope isQuoted pero con la condición invertida: $builder->where('is_quoted', 1). Efecto práctico: Order::query() únicamente ve is_quoted=0; QuotedOrder::query() únicamente ve is_quoted=1. Mismo registro físico en orders, dos “vistas” de modelo mutuamente excluyentes según por cuál clase se consulte.
  • limit_payment_date (columna date, nullable) — confirmado contra el schema real de PRD: existe en orders desde la migración 2020_06_12_113439_add_limit_payment_date_to_orders_table.php, compartida con Compras normal (ver hallazgo crítico en compras.md y más abajo).
  • $fillable adicional sobre lo heredado de Order: incluye is_quoted, unit_id, trailer_id, for_stock, output_id (relación con inventario/salidas de producto y con la unidad/remolque para la cual se compra material).
  • Relación con Unit: unit() → belongsTo(Unit::class, 'unit_id', 'id').
  • Relación con Trailer (clave para GMG-3084, ver abajo): trailer() → belongsTo(App\Core\Trailer\Trailer::class, 'trailer_id', 'id'). Es una FK real por trailer_id, no un código/número de caja de texto libre.
  • OrderProduct (app/Core/Purchases/OrderProduct.php, tabla order_product — el detalle de línea/producto de una orden, sea normal o cotizada) también tiene trailer_id en $fillable y su propia relación trailer() → belongsTo(Trailer::class). El remolque puede fijarse tanto a nivel de la orden completa (QuotedOrder.trailer_id) como a nivel de cada línea de producto (OrderProduct.trailer_id) — usado para el flujo de asignación de stock/inventario a un remolque específico (ver QuotedOrderService::create/update, que valida unitProductLockService->validateBeforeQuotedOrderCreation(...) con unit_id/trailer_id cuando for_stock es falso).
  • Servicio: App\Core\Purchases\QuotedOrder\QuotedOrderService orquesta creación/actualización, incluyendo el manejo de trailer_id/unit_id y su impacto en bloqueos de inventario (UnitProductLockService).
  • Controlador: Purchases\QuotedOrders\QuotedOrderController, acciones: index, getPaginated, store/update (vía apiResource), show, destroy, revertQuotedOrder, getStatuses, getRequesters, getStats, restore, sync, getDownloadHistory, exportToExcel.

Estados (status) de QuotedOrder — dos pasadas de código, reconciliadas

Sección titulada «Estados (status) de QuotedOrder — dos pasadas de código, reconciliadas»

Cadena de aprobación más larga y granular que la de Order normal. La máquina de estados (STATUS_LIST + checkAndUpdateStatus()) depende de aprobaciones por tipo (QuotedOrderApprovalTypes::jefeDeCompras, comprobaciones, jefeDeFinanzas, aplicacion, ejecutaPago), de si tiene póliza/beneficiario/banco, y de payment_method (pue/ppd) para decidir entre PAID y PAID_PENDING_COMPLEMENT.

Estados principales, con su tile correspondiente verificado en vivo en QA (/compras/cotizadas):

Constante Valor Tile UI (QA, 2026-09-10)
PRE_REQUEST pre-request (interno)
NEW_REQUEST new-request Nueva solicitud
REQUEST_REJECTED request-rejected Solicitud rechazada
TO_AUTHORIZE to-authorize Por autorizar
AUTHORIZATION_REJECTED authorization-rejected Autorización rechazada
TO_REQUEST to-request Por solicitar
TO_SUPPLY to-supply Por surtir
RECEIVED received Recibida
RECEPTION_REJECTED reception-rejected Recepción rechazada
PAYMENT_TO_AUTHORIZE payment-to-authorize Pago por autorizar
PAYMENT_AUTHORIZED payment-authorized Pago autorizado
PAYMENT_NOT_AUTHORIZED payment-not-authorized Pago no autorizado
VALIDATED validated Validada
NOT_VALIDATED not-validated No Validada
PAID_PENDING_COMPLEMENT paid-pending-complement Pagado, complemento pendiente
PAID paid Pagado
TO_CHECK to-check Por comprobar

Estados/valores adicionales vistos en una pasada de código anterior, más internos/de auditoría, sin tile propio confirmado en UI: -pre-request-rejected (sic, con guion inicial en el valor string), payment-to-authorize-to-check, received-to-check, y marcadores deleted, restored, reverted, inventory-adjustment. No se encontró una constante que mapee literalmente al tile “No cumple una regla” — puede ser un cálculo de frontend sobre notifiable_by_business_rule, no un valor de status. Por verificar.

⚠️ Hallazgo — filtro “Fecha de pago” también apunta a limit_payment_date aquí (ver compras.md)

Sección titulada «⚠️ Hallazgo — filtro “Fecha de pago” también apunta a limit_payment_date aquí (ver compras.md)»

QuotedOrderPage.vue (líneas 35-44) enlaza el filtro “Fecha de pago” a filter.fields.limit_payment_date — mismo nombre de campo que en Compras normal. No verificado si el backend de Compras Cotizadas (QuotedOrderController / routes/purchases/quoted-orders/quoted-orders.php) tiene el mismo bug que OrderController@getOrders (filtrar updated_at en vez de la columna real) — nadie leyó ese controller en detalle todavía. Como el Acciones de GMG-3123 ya da por resuelto este módulo (Paris confirmó que su filtro ya sirve), esto queda como nota “por verificar” a futuro, no bloqueante.

  • El bug de sincronización AS400 está en UltimaSituacionRemolque::createOrFindFromAS400(), que usa firstOrCreate sobre company_id + code + trailer_owner_id y puede terminar creando remolques duplicados (varios registros Trailer con el mismo code/número de caja pero distinto id) cuando cambia algún dato del trailer_owner_id u otra condición de matching entre corridas del sync (detalle completo del mecanismo en remolques.md).
  • Como tanto QuotedOrder.trailer_id como OrderProduct.trailer_id son FKs por id numérico (no por code/número de caja de texto), cuando el sync crea un Trailer duplicado y deja “vivo” solo uno de los duplicados (o cambia cuál se considera el “vigente”), los registros de orders/order_product que ya apuntaban al id del remolque “viejo” no se actualizan automáticamente — su FK sigue apuntando a un id de Trailer que técnicamente sigue existiendo en la tabla trailers (por eso no rompe integridad referencial: el registro no se borra, solo queda “huérfano” en el sentido de negocio) pero que ya no es el que el usuario ve como “el remolque actual” en Remolques.
  • Esto explica el síntoma reportado: ~30 registros de compras cotizadas en producción “apuntan a remolques muertos” — no es que falte el remolque (la FK es válida), sino que apunta a un Trailer.id que quedó desplazado/duplicado por el bug de sync.
  • Por verificar con datos reales de producción: no se consultó la base de datos para confirmar cuáles de los ~30 registros son QuotedOrder.trailer_id a nivel de orden vs. OrderProduct.trailer_id a nivel de línea de producto, ni si el problema se manifiesta también en Order (compras no cotizadas) — el código no lo impide, ya que trailer_id vive en las mismas tablas físicas (orders, order_product) independientemente de is_quoted. (Nota 2026-09-10: ya existe una copia schema-only de PRD en la BD local gmg_09_010_2026 — ver “Acceso al schema real” en compras.md — que podría usarse para esta verificación sin tener que consultar PRD directamente.)

Submódulos (código + verificado en vivo en QA, 2026-09-10)

Sección titulada «Submódulos (código + verificado en vivo en QA, 2026-09-10)»
Submódulo UI Modelo(s) Archivo Tabla getActionUrlAttribute()
Productos Licitados ProductTender + ProductTenderQuote app/Core/Products/Tenders/ProductTender.php product_tenders /compras/producto/licitaciones?product_tender_id=
Proveedores Supplier app/Core/Suppliers/Supplier.php suppliers /proveedores?supplier_id=
Facturas Bill app/Core/Bills/Bill.php bills (sin action_url propio encontrado)
Almacén ProductInventory + ProductInventoryMovement app/Core/Products/ProductInventory.php product_inventories — no implementa HasModule, sin permiso propio
Salidas Output + ProductOutput app/Core/Products/Output.php outputs /almacen/salidas?output_id=
Grupo Productos ProductGroup app/Core/Purchases/ProductGroup.php product_groups /compras/grupos-productos?id=
  • ProductTender (Licitación de producto): status request, in-progress, ready-to-deliberate, deliberating, rejected, to-authorize, winner. Requiere ≥3 quotes válidas (hasThreeQuotesValidated) y expiration_date vigente para llegar a winner. ProductTenderQuote es la cotización de un proveedor específico (quotes(), ordenada por position — position=0 es la ganadora, hasSelectedWinner()). En vivo (QA): filtros Concepto, Producto, Status, checkbox Activo; columnas # Concursantes, $ Actual, $ Promedio, estatus (Ganador 100%, Listo para deliberar 30% visto); 6,131 registros.
  • Supplier (Proveedor): sync opcional desde AS400 (getAs400Model() vía Proveedor::wherePropoa/wherePronum/whereProemp). linkBills() reasigna facturas por issuer_rfc cuando aún no tienen supplier_id. En vivo (QA): barra de sync AS400 (Sync: Ok, contador 13341/5/0, mismo patrón que Tractores/Remolques); 13,341 registros.
  • Bill (Factura): campos CFDI (uuid, serie, folio, invoiced_at, receiver_rfc, issuer_rfc, total, payment_method, payment_form). Relación main_bill/related_bills. order_product() es morphedByMany (polimórfica, vía tabla billable). En vivo (QA): filtros Proveedor, RFC Emisor, UUID, RFC Receptor; folio con prefijo de serie (AA 84488, RC 151402, FREF1 19197).
  • ProductInventory (Almacén): clave compuesta (team_id, product_id) vía Compoships. from_quoted_order (bool) distingue stock generado por una compra cotizada vs otro origen. En vivo (QA): columnas CUP, Artículo, IR y IET (tooltips “?”, significado no confirmado), [CC], [Salidas], Bloqueos; 6,168 registros.
  • Output (Salida): status initial, requested, authorized, rejected, delivered (+ changes-requested). adjustInventory() descuenta ProductInventory.quantity y crea un ProductInventoryMovement al entregar. Puede tener parent/children (self-referencing) — confirmado en vivo: fila con indicador 218894 ← 217918. En vivo (QA): KPIs Inicial (298), Solicitada (3), Autorizada (131), Rechazada (2), Entregado (107,897); 108,331 registros.
  • ProductGroup (Grupo Productos / “Conceptos Compras” en UI): status new, approved, not-approved. Relación orders() es belongsToMany vía pivot order_product. En vivo (QA): columnas Productos N / Para CC N (conteo total y para compras cotizadas), Solicita/Autoriza (usuario+fecha) — mayoría autorizados históricamente por Paris Gonzalez (2020), consistente con ser la responsable funcional del área hoy.

Igual que Compras normal, todo cuelga del grupo con prefijo real /orders en routes/web.php (línea 247), no de /orders/quoted-orders como sugería el nombre del archivo — el archivo routes/purchases/quoted-orders/quoted-orders.php define su propio Route::group(['prefix' => 'quoted'], ...) anidado, así que las rutas reales quedan bajo /orders/quoted/*:

Método Ruta Acción
GET /orders/quoted/paginated listado paginado (getPaginated)
GET /orders/quoted listado (index) / también expuesto vía apiResource
POST /orders/quoted/{quotedOrder}/revert revertir estatus
GET /orders/quoted/statuses catálogo de estatus
GET /orders/quoted/requesters usuarios solicitantes para filtro
GET /orders/quoted/stats conteos/estadísticas
PATCH /orders/quoted/{quotedOrderId} restaurar (soft delete)
GET /orders/quoted/sync/{order} sincronizar
GET /orders/quoted/download-history/{order} historial de descargas
— /orders/quoted/product/* grupo prefix('product') interno — no inspeccionado en detalle
POST /orders/quoted/export exportar a Excel
GET/POST/PUT/DELETE /orders/quoted, /orders/quoted/{quoted} CRUD estándar vía Route::apiResource('quoted', QuotedOrderController::class)

Archivos hermanos no inspeccionados en detalle: routes/purchases/quoted-orders/quoted-order-approval.php, quoted-order-product-approval.php, quoted-order-product-files.php.

Rutas de submódulos (bajo /product, routes/web.php:269-284):

Grupo Archivo Notas
/product/product-tender/* routes/products/tender/product-tender.php paginated, statuses, export, apiResource, sub-rutas quote/{productTenderQuote} (update/destroy/restore/files), set-order-quotes
/product/* inventario routes/products/inventory.php Almacén
/product/output/* routes/products/output/output.php + output-receiver.php + output-approval.php + output-files.php Salidas
/orders/product-groups/* routes/purchases/product-groups/product-groups.php Grupo Productos

No se ubicaron en ninguna pasada los archivos exactos de rutas para /proveedores y /facturas — por verificar.

  • Ruta /compras/cotizadas (resources/js/routes.js línea ~192-220) → componente resources/js/components/Purchases/QuotedOrders/QuotedOrderPage.vue, guard can('view-quoted-order').
  • Árbol de componentes bajo resources/js/components/Purchases/QuotedOrders/:
    • QuotedOrderPage.vue (contenedor de la vista), QuotedOrderRow.vue (fila de listado), QuotedOrderForm.vue (alta/edición).
    • FormSections/: QuotedOrderGeneralForm.vue, QuotedOrderApprovalForm.vue, QuotedOrderProofOfPaymentForm.vue, QuotedOrderProductsSection.vue, QuotedOrderPaymentForm.vue.
    • Products/: QuotedOrderProductForm.vue, QuotedOrderProductRow.vue, QuotedOrderProductApprovalForm.vue, QuotedOrderProductApprovals.vue, QuotedOrderProductFilesForm.vue, QuotedOrderProductQuoteForm.vue, QuotedOrderProductRequestForm.vue, QuotedOrderBusinessRules.vue.
    • File/QuotedOrderFileUpload.vue.
  • Filtros de la lista principal, verificados en vivo en QA 2026-09-10 (/compras/cotizadas): búsqueda global, Fecha de pago (rango) y Fecha de Creación (rango), Concepto (select), Usuarios (select), Producto (texto), Proveedor (texto), Status (select), Stock (select), checkbox Productos pendientes por entregar, checkbox No tiene comprobante de pago, checkbox Ver kilómetros en Excel, Tractor (select), Remolque (select). Columnas con sort: Empresa, Chequera, Tipo de Pago. Total registros observado: 44,964.
  • KPI tiles (estatus, clicables): No cumple una regla (1), Nueva solicitud (7), Solicitud rechazada (0), Por autorizar (1), Autorización rechazada (0), Por solicitar (34), Por surtir (5), Recibida (0), Recepción rechazada (0), Pago por autorizar (0), Pago autorizado (0), Pago no autorizado (0), Validada (13), No Validada (0), Pagado complemento pendiente (95), Pagado (44,808), Por comprobar (0).
  • Servicio: resources/js/services/purchases/quoted-orders/* — no inspeccionado el contenido, endpoints exactos por verificar contra la tabla de rutas de arriba.

Constantes confirmadas en App\Core\Security\PermissionConst (app/Core/Security/PermissionConst.php), todas de un recurso separado del de purchase (es decir, NO reutilizan view-purchase etc.):

  • viewQuotedOrder = 'view-quoted-order'
  • createQuotedOrder = 'create-quoted-order'
  • readQuotedOrder = 'read-quoted-order'
  • updateQuotedOrder = 'update-quoted-order'
  • updateComplementaryQuotedOrder = 'update-complementary-quoted-order'
  • superUpdateQuotedOrder = 'super-update-quoted-order'
  • deleteQuotedOrder = 'delete-quoted-order'
  • deleteOnBeginQuotedOrder = 'delete-on-begin-quoted-order'
  • authorizeQuotedOrder = 'authorize-quoted-order'
  • authorizeDifferentPriceQuotedOrder = 'authorize-different-price-quoted-order'
  • authorizeExpensiveQuotedOrder = 'authorize-expensive-quoted-order'
  • authorizePaymentQuotedOrder = 'authorize-payment-quoted-order'
  • executeQuotedOrder = 'execute-quoted-order'
  • executePaymentQuotedOrder = 'execute-payment-quoted-order'
  • revertQuotedOrder = 'revert-quoted-order'
  • validateQuotedOrder = 'validate-quoted-order'
  • verifyEntryQuotedOrder = 'verify-entry-quoted-order'
  • setPolicyQuotedOrder = 'set-policy-quoted-order'
  • approveExceptionQuotedOrder = 'approve-exception-quoted-order'

Además confirmado: PermissionConst::superUpdateQuotedOrder, updateComplementaryQuotedOrder y superUpdateProductTender se usan para bypassear validación de estatus editable.

Nota de bug de scope ya detectada en el módulo Remolques: GetTrailerRequest::authorize() acepta (entre otros) permisos quoted-order como suficientes para leer remolques — un usuario con permisos de Compras Cotizadas pero sin permisos explícitos de Remolques puede terminar teniendo acceso de lectura a remolques vía esa autorización cruzada. Ver remolques.md para el detalle completo.

Rol relevante en config/laratrust_seeder.php: Role::observadorComprasCotizadas (solo observación de cotizadas). No se leyó el bloque completo de permisos por rol — por verificar si se necesita el detalle exacto por rol para diseñar tests de autorización.

Módulos (app/Core/Modules/Module.php:104-146)

Sección titulada «Módulos (app/Core/Modules/Module.php:104-146)»

compras, grupos-productos, compras-cotizadas, productos-licitados, salidas, proveedores, facturas — todos con entrada en moduleModelMap. No existe módulo para Almacén/inventario — ProductInventory no implementa HasModule (consistente con no tener permiso view-inventory propio a nivel de módulo de negocio, aunque el permiso sí existe a nivel de ruta Vue).

Selectores usados por el tester (riesgo de fragilidad)

Sección titulada «Selectores usados por el tester (riesgo de fragilidad)»
  • No hay specs de Playwright para este módulo todavía (igual que Compras normal). Nada de lo siguiente fue verificado en vivo contra QA a nivel de DOM/selectores — es inferencia de código/estructura de archivos.
  • No hay data-testid en la app (patrón consistente en todo controlgmg-v2).
  • El listado usa QuotedOrderRow.vue — no se inspeccionó si es tabla HTML o divs; por verificar antes de escribir selectores de fila.
  • El formulario (QuotedOrderForm.vue + varias FormSections/*) es previsiblemente más complejo que el de Compras normal (más estados, más subformularios de producto/aprobación/pago/archivos) — por verificar estructura de pasos/secciones colapsables contra QA real antes de escribir specs.
  • Dado que la vista depende de unit_id/trailer_id para ciertos flujos, cualquier test que dependa de seleccionar un remolque en el formulario debe considerar el riesgo de datos huérfanos de GMG-3084 (un remolque duplicado podría aparecer dos veces en el selector, o el remolque esperado por el test podría no ser el id “vigente”).
  • Antes de escribir cualquier spec para este módulo, correr npm run codegen:gmg contra QA real para verificar selectores, igual que se hizo para Remolques/Tractores.
  • Rutas backend exactas de /proveedores y /facturas (no están bajo orders ni product).
  • Efecto real de field="approvals.limit_payment_date" en el sort de la columna “Fecha Límite” de Compras normal (ver compras.md) — no aplica directamente a este módulo pero vale la pena revisar si Compras Cotizadas tiene una columna equivalente con el mismo problema.
  • Por qué checkAndUpdateStatus() no tiene un estado mapeable a “No cumple una regla” (tile visto en QA).
  • Si QuotedOrderController tiene el mismo bug updated_at-vs-limit_payment_date que OrderController (ver hallazgo arriba) — no verificado.
  • Significado exacto de las columnas IR e IET en Almacén.
  • Si el formulario expone visualmente el campo trailer_id/número de caja (relevante para reproducir el síntoma de GMG-3084 en UI).
  • Cuáles de los ~30 registros con remolque “muerto” (GMG-3084) son QuotedOrder.trailer_id vs. OrderProduct.trailer_id, y si el problema también se manifiesta en Order normal — ahora se puede intentar consultar contra la copia schema-only local de PRD (gmg_09_010_2026) sin tocar PRD directamente.
  • 2026-08-12: página carga en https://qa.controlgmg.com/compras/cotizadas bajo el título “Compras cotizadas” — sin inspección de detalle visual en esa pasada.
  • 2026-09-10: navegación completa en vivo — ver KPIs, filtros y conteos de registros por submódulo documentados arriba en cada sección.