Lotes de custodia
El lote de custodia (custody batch, /custody) es la operación unitaria de
despacho, recepción o traslado de producto: abre con un aforo de apertura, registra la
operación física y cierra con un aforo de cierre, para luego emitir un ticket numerado. Es el
flujo más complejo del sistema y el corazón del Core Value de TankOS: cálculos custody-transfer
correctos y trazables. Esta página está orientada al operador y al supervisor, y cubre el
ciclo de vida del lote desde planificarlo hasta dejarlo listo para emitir; la
emisión del ticket —numeración gap-free, motor MPMS y
PDF firmado— se documenta en su propia página.
La captura de gauge de un lote de custodia (OPENING_SRC, CLOSING_DEST, …) es interna al lote y
alimenta directamente su cálculo. No debe confundirse con el
gauging ticket custody-grade del laboratorio, que tiene su
propia máquina de estados y su number humano. El lote de custodia usa el contador correlativo
gap-free; el gauging ticket usa una secuencia simple legible.
Quién hace qué
| Acción | Autoridad | Roles |
|---|---|---|
| Planificar / cancelar el lote | custody:plan | operador, supervisor, ingeniero |
| Abrir el lote | custody:operate | operador, supervisor, ingeniero |
| Capturar gauges (aforar) | custody:gauge | operador, supervisor, ingeniero |
| Emitir el ticket | custody:issue | supervisor (único) |
| Anular el ticket | custody:void | supervisor (único) |
La emisión y la anulación del ticket son exclusivas del supervisor y se documentan en la página de tickets de custodia. El resto de la operación del lote —planificar, abrir, aforar, avanzar— la realiza el operador (o el supervisor/ingeniero).
El ciclo de vida en 9 estados
El lote de custodia obedece un reductor de 9 estados (nextCustodyState, 9 estados + 11 acciones):
PLANNED, OPENED, GAUGING_OPEN, MEASURING, GAUGING_CLOSE, CALCULATING, TICKETED, VOIDED y
CANCELLED. Cualquier par (estado, acción) que no aparezca abajo se rechaza como transición inválida.
| Estado origen | Acción | Estado destino | Autoridad | Guardia / Nota |
|---|---|---|---|---|
PLANNED | OPEN | OPENED | custody:operate | openedAt = now() |
OPENED | CAPTURE_OPENING | GAUGING_OPEN | custody:gauge | Congela el snapshot de telemetría |
GAUGING_OPEN | re-gauge | GAUGING_OPEN | custody:gauge | Self-loop de re-aforo (D-02) |
GAUGING_OPEN | CONFIRM_LARGE_DIVERGENCE | GAUGING_OPEN | custody:gauge | Confirma divergencia > 5% (D-04) |
GAUGING_OPEN | ADVANCE_TO_MEASURING | MEASURING | custody:operate | measuringStartedAt = now() |
MEASURING | ADVANCE_TO_CLOSING | GAUGING_CLOSE | custody:operate | gaugingCloseStartedAt = now() |
GAUGING_CLOSE | re-gauge | GAUGING_CLOSE | custody:gauge | Self-loop de re-aforo de cierre |
GAUGING_CLOSE | START_CALC | CALCULATING | custody:issue | Solo supervisor (parte de emitir) |
CALCULATING | CALC_FAILED | GAUGING_CLOSE | (sistema) | Rollback con lastCalcWarnings (D-07) |
CALCULATING | (éxito) | TICKETED | custody:issue | Número gap-free como última op. SQL |
TICKETED | VOID | VOIDED | custody:void | Solo supervisor, reason ≥ 20 chars |
cualquiera pre-TICKETED | CANCEL | CANCELLED | custody:plan | reason ≥ 10 chars |
VOIDED y CANCELLED no tienen transiciones salientes: una vez ahí, el lote está cerrado. No se
"reabre" ni se "edita". Si un ticket emitido necesita corrección, se anula (VOID) y el sistema crea
un lote correctivo de reemplazo — el detalle se documenta en tickets de custodia.
El wizard de nuevo lote (5 pasos)
Crear un lote arranca en /custody/new con un wizard de 5 pasos (CustodyWizardStepper). El paso
activo se refleja en la URL (?step=…), de modo que se puede compartir o retomar exactamente donde se
dejó. El paso final hace POST /api/v1/custody/batches (autoridad custody:plan) y el lote nace en
estado PLANNED.

| # | Paso (?step=) | Qué se elige | Regla |
|---|---|---|---|
| 1 | Tipo (type) | OUTBOUND / INBOUND / TRANSFER | Determina qué tanques pide el paso 2 |
| 2 | Tanques (tanks) | Tanque(s) de origen y/o destino | OUTBOUND → solo origen; INBOUND → solo destino; TRANSFER → ambos |
| 3 | Producto (product) | productId del catálogo | El producto fija las propiedades del cálculo |
| 4 | Volumen (volume) | plannedVolumeM3 (Decimal, SI) | counterpartyName requerido para OUTBOUND/INBOUND, prohibido para TRANSFER |
| 5 | Revisión (review) | Resumen + CTA "Crear lote" | POST …/batches → estado PLANNED |
Los 3 tipos de operación
OUTBOUND(salida): despacho de producto. Solo se elige el tanque de origen (sourceTankId) y se exige la contraparte (counterpartyName).INBOUND(entrada): recepción de producto. Solo se elige el tanque de destino (destinationTankId) y se exige la contraparte.TRANSFER(traslado interno): movimiento tanque → tanque dentro de la instalación. Se eligen ambos tanques; la contraparte no aplica (es interno).
El wizard acepta parámetros ?type=&sourceTankId= para pre-llenar el tipo y el tanque de origen
cuando se abre desde el detalle de un tanque. Así, planificar un
despacho desde el tanque que se está viendo es un solo clic.
Screenshot pendiente del barrido diferido — ver 61-DEFERRED-SWEEP.md (/custody/new pasos 2–5 del wizard (tanques/producto/volumen/revisión)).
Operar el lote paso a paso
El lote se opera desde su vista operativa /custody/batches/[id]: un header sticky con el número,
el badge de estado y el badge de tipo, los CTA de lifecycle (Abrir lote / Iniciar medición /
Cerrar medición), una columna izquierda (60%) con los GaugePanels de apertura y cierre, y una
columna derecha (40%) con el LabStalenessBadge, el ConservationDeltaBadge (en TRANSFER), una card
de previsualización del cálculo (NSV / masa totales) y la lista de warnings. El footer sticky lleva
los CTA Cancelar lote y Emitir ticket.
- Crear el lote (→
PLANNED). El wizard de 5 pasos crea el lote enPLANNED. - Abrir el lote (
PLANNED → OPENED). Con autoridadcustody:operate; se sellaopenedAt = now(). - Capturar el gauging de apertura (
OPENED → GAUGING_OPEN). El operador captura el aforo de apertura (OPENING_SRCy/oOPENING_DEST) — ver Captura de gauge y snapshot inmutable abajo. - Avanzar a medición (
GAUGING_OPEN → MEASURING). Concustody:operate; se sellameasuringStartedAt. Aquí ocurre la operación física (bombeo, carga/descarga). - Avanzar al cierre (
MEASURING → GAUGING_CLOSE). Concustody:operate; se sellagaugingCloseStartedAt. - Capturar el gauging de cierre (en
GAUGING_CLOSE). El operador captura el aforo de cierre (CLOSING_SRCy/oCLOSING_DEST), mismo proceso que la apertura. - Emitir el ticket. Con todos los gauges capturados y el lote en
GAUGING_CLOSE, el supervisor emite el ticket desde el footer — ver la página de tickets de custodia.
OUTBOUND e INBOUND requieren 2 gauges (apertura + cierre del tanque relevante). TRANSFER
requiere 4 gauges: OPENING_SRC, CLOSING_SRC, OPENING_DEST y CLOSING_DEST. El botón
Emitir ticket permanece deshabilitado hasta que todos los gauges requeridos estén capturados.
Screenshot pendiente del barrido diferido — ver 61-DEFERRED-SWEEP.md (/custody/batches/[id] en PLANNED / GAUGING_OPEN / GAUGING_CLOSE (GaugePanels)).
Captura de gauge y snapshot inmutable
Al capturar un gauge, el operador ingresa las magnitudes del aforo en unidades SI: nivel (levelMm),
TOV (tovM3), GOV (govM3), GSV (gsvM3), NSV (nsvM3), masa (massKg) y temperatura del producto
(productTempC). En el mismo acto, el servicio congela el snapshot de telemetría ACTUAL del tanque
(snapshotAt) dentro del campo telemetrySnapshot.
El telemetrySnapshot es un JSONB inmutable: un trigger de base de datos impide modificarlo
después de insertarlo (D-21). Es precisamente esta inmutabilidad la que hace trazable el aforo: deja
constancia de qué decía el radar en el instante exacto del aforo manual, sin posibilidad de
reescribir la historia. Si el valor del operador difiere del snapshot, el sistema no sobrescribe el
snapshot: crea una fila CustodyGaugeOverride por cada campo divergente (append-only, con
overrideReason ≥ 10 caracteres, validado por un CHECK en base de datos).
El gauger puede re-aforar mientras el lote está en GAUGING_OPEN (o en GAUGING_CLOSE para el
cierre): el reductor permite el self-loop de re-gauge (D-02) sin sacar el lote del estado.
Divergencia de nivel mayor al 5%
Si el nivel del operador difiere del nivel de telemetría en más del 5%
(|operatorLevel − telemetryLevel| / telemetryLevel > 5%, D-04), el sistema exige confirmación
explícita del gauger: confirmedByGauger = true más una razón extra (≥ 10 caracteres) más
un audit row con action = 'LARGE_LEVEL_DIVERGENCE'. Es una guardia custody clave: el operador debe
reconocer y justificar una discrepancia grande para que el aforo siga adelante. Nada se asume; todo
queda registrado.
Staleness gate de laboratorio
El cálculo custody-grade necesita densidad y BSW recientes del laboratorio. Por eso, al emitir el ticket, el motor revisa la antigüedad de las muestras de laboratorio (D-18):
| Antigüedad de la muestra | Resultado |
|---|---|
| < 72 h | Flujo normal — la emisión procede |
| 72 h – 14 días | Requiere staleLabOverride + reason + autoridad ops:override |
| > 14 días | HARD BLOCK — sin override posible |
Si la muestra de laboratorio tiene más de 14 días, la emisión se bloquea sin excepción: la
densidad y el BSW del cálculo custody deben ser frescos. Entre 72 h y 14 días, solo un usuario con
ops:override puede continuar, dejando una razón. Por debajo de 72 h, el flujo es normal. El gate se
evalúa en el momento de emitir — su detalle vive en la página de
tickets de custodia.
INVENTORY_GRADE_BLOCKED, 422)Además del staleness, la emisión puede devolver INVENTORY_GRADE_BLOCKED (422) cuando un TRANSFER
queda fuera de la tolerancia de conservación sin override, o cuando un tanque no tiene tabla de
calibración (strapping) publicada o sus medidas caen fuera de la tabla. Sin una tabla de aforo
vigente, el lote no alcanza grado de transferencia. La gestión de la tabla de aforo se ve en
Calibración y strapping.
Workspace de lotes
El workspace de custodia (/custody) organiza los lotes en 4 pestañas (la pestaña activa va en la
URL como ?tab=…): active (lotes en curso), tickets (tickets recientes), voided
(anulados) y cancelled (cancelados). Un indicador de tiempo real en la barra de herramientas
refleja los cambios de estado en vivo. El CTA "Nuevo lote" abre el wizard y solo aparece para quien
tiene custody:plan (oculto, por ejemplo, para el técnico de laboratorio y el administrador).

Páginas relacionadas
- La emisión y anulación del ticket —numeración gap-free, motor MPMS de 18 etapas, PDF/A-3 firmado y el correctivo en VOID— es la segunda mitad de este flujo.
- La densidad y el BSW que evalúa el staleness gate provienen de la muestra de laboratorio.
- Los aforos custody-grade y el override de radar se documentan en gauging tickets.
- El pre-llenado del wizard y el banner de override activo se ven en el detalle de tanque.
- El ticket de custodia se puede leer en SI o en imperial según la preferencia del usuario, sin alterar el valor almacenado — ver el sistema de unidades.
- Las guías por rol del lote de custodia llegarán en una próxima sección del manual (próximamente).