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 compartidoApp\Core\Purchases\Order(tablaorders) — este documento cubre solo lo específico de “Compras Cotizadas”.
Backend
Sección titulada «Backend»- Modelo:
App\Core\Purchases\QuotedOrder(app/Core/Purchases/QuotedOrder.php), que extiendeOrder(class QuotedOrder extends Order). No es una tabla separada: sigue siendo la tablaorders. - Scope
isQuotedsobreescrito:Order::boot()defineaddGlobalScope('isQuoted', fn($q) => $q->where('is_quoted', 0))(solo compras normales).QuotedOrder::boot()(línea ~251) vuelve a registrar el mismo scopeisQuotedpero con la condición invertida:$builder->where('is_quoted', 1). Efecto práctico:Order::query()únicamente veis_quoted=0;QuotedOrder::query()únicamente veis_quoted=1. Mismo registro físico enorders, dos “vistas” de modelo mutuamente excluyentes según por cuál clase se consulte. limit_payment_date(columnadate, nullable) — confirmado contra el schema real de PRD: existe enordersdesde la migración2020_06_12_113439_add_limit_payment_date_to_orders_table.php, compartida con Compras normal (ver hallazgo crítico encompras.mdy más abajo).$fillableadicional sobre lo heredado deOrder: incluyeis_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 portrailer_id, no un código/número de caja de texto libre. OrderProduct(app/Core/Purchases/OrderProduct.php, tablaorder_product— el detalle de línea/producto de una orden, sea normal o cotizada) también tienetrailer_iden$fillabley su propia relacióntrailer()→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 (verQuotedOrderService::create/update, que validaunitProductLockService->validateBeforeQuotedOrderCreation(...)conunit_id/trailer_idcuandofor_stockes falso).- Servicio:
App\Core\Purchases\QuotedOrder\QuotedOrderServiceorquesta creación/actualización, incluyendo el manejo detrailer_id/unit_idy su impacto en bloqueos de inventario (UnitProductLockService). - Controlador:
Purchases\QuotedOrders\QuotedOrderController, acciones:index,getPaginated,store/update(víaapiResource),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.
Relación con el bug GMG-3084
Sección titulada «Relación con el bug GMG-3084»- El bug de sincronización AS400 está en
UltimaSituacionRemolque::createOrFindFromAS400(), que usafirstOrCreatesobrecompany_id + code + trailer_owner_idy puede terminar creando remolques duplicados (varios registrosTrailercon el mismocode/número de caja pero distintoid) cuando cambia algún dato deltrailer_owner_idu otra condición de matching entre corridas del sync (detalle completo del mecanismo enremolques.md). - Como tanto
QuotedOrder.trailer_idcomoOrderProduct.trailer_idson FKs poridnumérico (no porcode/número de caja de texto), cuando el sync crea unTrailerduplicado y deja “vivo” solo uno de los duplicados (o cambia cuál se considera el “vigente”), los registros deorders/order_productque ya apuntaban aliddel remolque “viejo” no se actualizan automáticamente — su FK sigue apuntando a uniddeTrailerque técnicamente sigue existiendo en la tablatrailers(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.idque 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_ida nivel de orden vs.OrderProduct.trailer_ida nivel de línea de producto, ni si el problema se manifiesta también enOrder(compras no cotizadas) — el código no lo impide, ya quetrailer_idvive en las mismas tablas físicas (orders,order_product) independientemente deis_quoted. (Nota 2026-09-10: ya existe una copia schema-only de PRD en la BD localgmg_09_010_2026— ver “Acceso al schema real” encompras.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) yexpiration_datevigente para llegar awinner.ProductTenderQuotees la cotización de un proveedor específico (quotes(), ordenada porposition—position=0es la ganadora,hasSelectedWinner()). En vivo (QA): filtrosConcepto,Producto,Status, checkboxActivo; columnas# Concursantes,$ Actual,$ Promedio, estatus (Ganador100%,Listo para deliberar30% visto); 6,131 registros. - Supplier (Proveedor): sync opcional desde AS400 (
getAs400Model()víaProveedor::wherePropoa/wherePronum/whereProemp).linkBills()reasigna facturas porissuer_rfccuando aún no tienensupplier_id. En vivo (QA): barra de sync AS400 (Sync: Ok, contador13341/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ónmain_bill/related_bills.order_product()esmorphedByMany(polimórfica, vía tablabillable). En vivo (QA): filtrosProveedor,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íaCompoships.from_quoted_order(bool) distingue stock generado por una compra cotizada vs otro origen. En vivo (QA): columnasCUP,Artículo,IRyIET(tooltips “?”, significado no confirmado),[CC],[Salidas],Bloqueos; 6,168 registros. - Output (Salida): status
initial, requested, authorized, rejected, delivered(+changes-requested).adjustInventory()descuentaProductInventory.quantityy crea unProductInventoryMovemental entregar. Puede tenerparent/children(self-referencing) — confirmado en vivo: fila con indicador218894 ← 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ónorders()esbelongsToManyvía pivotorder_product. En vivo (QA): columnasProductos 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.
Frontend (Vue 2)
Sección titulada «Frontend (Vue 2)»- Ruta
/compras/cotizadas(resources/js/routes.jslínea ~192-220) → componenteresources/js/components/Purchases/QuotedOrders/QuotedOrderPage.vue, guardcan('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), checkboxProductos pendientes por entregar, checkboxNo tiene comprobante de pago, checkboxVer 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.
Permisos (Laratrust)
Sección titulada «Permisos (Laratrust)»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-testiden la app (patrón consistente en todocontrolgmg-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+ variasFormSections/*) 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_idpara 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 elid“vigente”). - Antes de escribir cualquier spec para este módulo, correr
npm run codegen:gmgcontra QA real para verificar selectores, igual que se hizo para Remolques/Tractores.
Por verificar
Sección titulada «Por verificar»- Rutas backend exactas de
/proveedoresy/facturas(no están bajoordersniproduct). - Efecto real de
field="approvals.limit_payment_date"en el sort de la columna “Fecha Límite” de Compras normal (vercompras.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
QuotedOrderControllertiene el mismo bugupdated_at-vs-limit_payment_datequeOrderController(ver hallazgo arriba) — no verificado. - Significado exacto de las columnas
IReIETen 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_idvs.OrderProduct.trailer_id, y si el problema también se manifiesta enOrdernormal — ahora se puede intentar consultar contra la copia schema-only local de PRD (gmg_09_010_2026) sin tocar PRD directamente.
Datos observados en QA
Sección titulada «Datos observados en QA»- 2026-08-12: página carga en
https://qa.controlgmg.com/compras/cotizadasbajo 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.