Skip to content

Commit 81b4f6c

Browse files
committed
feat(recepcion): verificar el albaran con el papel delante, y retener lo que la IA marca
ALB-00119 (20/08, Alcala): el papel dice 56,57 kg de milanesas a 7,50 EUR/kg = 424,27 EUR; la pantalla dice "56,57 uds"; lo guardado son 38 paquetes, 228 uds y unit_cost NULL. La sesion de IA traia needs_review=true con tres motivos y la recepcion se creo con needs_review=false y la mercancia posteada. La bandera de la IA no llegaba por dos sitios: createGoodsReceipt se llamaba sin needsReview, y receive_goods_receipt la PISABA con needs_review=(skipped>0). - migracion 20260820T1700 (PROPUESTA, sin aplicar): _post_goods_receipt_lines gana p_only_unposted; receive_goods_receipt gana p_hold (no postea nada y deja el albaran en 'recibido' para que oficina lo abra) y deja de pisar la bandera; confirm_goods_receipt postea al cerrar lo que nunca posteo y exige proveedor y n de albaran para cerrar un 'recibido'. - ReceiptScanPanel/ReceiptWizard: la validacion de la IA viaja en OcrPrefill; el asistente ensena los motivos, cambia el boton a "Guardar y mandar a oficina" y no aprende de un albaran retenido. El importe de linea se prerrellena tambien desde cantidad x precio cuando el papel no trae el importe (causa del coste nulo). - ReceiptOfficeReview: el albaran se ve AL LADO de las lineas (sticky en ancho, boton grande en movil) reusando ReceiptPhotoViewer, que ya existia y no se usaba aqui. Editor de linea con FORMATO ademas de cantidad y coste, con vista previa de lo que entra al almacen y motivo obligatorio; disponible tambien desde una linea dudosa, que antes solo ofrecia si/no. Proveedor y n de albaran editables y obligatorios para cerrar. - El aviso amarillo decia siempre "lo emparejo por parecido de nombre": de las 408 marcadas en Foodint, 400 son map_source='unmapped' y en toda la base solo hay 11 fuzzy. Ahora cada linea dice su motivo (unverifiedReason, 8 pruebas). - UnverifiedLinesPage: las lineas que entraron sin confirmar, por importe, con su albaran a un clic. 341 lineas, 16.108,21 EUR (las 410 del encargo incluian albaranes anulados). El frontend funciona antes y despues de la migracion: hold=false llama con un solo argumento, y hold=true sin migracion deja la recepcion en borrador con un error legible en vez de romper el recepcionar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vy4yW6xBAtXcaSGgKybquB
1 parent 4ad6c94 commit 81b4f6c

10 files changed

Lines changed: 1489 additions & 86 deletions
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# ENCARGO «Verificar un albarán a ciegas» — resultado
2+
3+
Fecha: 2026-08-20 · Rama: `main` y `claude/redesign-pricing-modal-amxuzy`
4+
5+
> **Hay una migración SIN APLICAR**: `supabase/migrations/20260820T1700_recepcion_verificacion_a_ciegas.sql`.
6+
> El frontend está blindado para funcionar antes y después de aplicarla (ver §Orden de despliegue).
7+
8+
---
9+
10+
## 0 · Qué pasó en ALB-00119, con los datos delante
11+
12+
Sesión de IA `9bae61b1-fdba-4fd0-a16e-c8450aec72d1`, leída de producción:
13+
14+
```json
15+
"document": { "handwritten": true, "supplier_name": null, "doc_number": null,
16+
"bill_to_name": "LLORENTE", "tax_base_total": 424.27,
17+
"tax_total": 42.27, "grand_total": 466.54 }
18+
"lines": [ { "raw_text": "Milanesas pollo", "quantity": 56.57, "unit": "kg",
19+
"unit_price_net": 7.5, "line_amount": null } ]
20+
"validation": { "needs_review": true,
21+
"reasons": ["No se pudo validar por base imponible (faltan importes)",
22+
"Documento manuscrito", "Confianza de lectura baja"] }
23+
```
24+
25+
**La IA lo leyó bien.** Los tres números distintos salen de tres fallos encadenados:
26+
27+
1. **`unit_price_net` sí estaba, `line_amount` no.** El asistente solo prerrellenaba
28+
el importe desde `line_amount`, así que la casilla salió vacía → `unit_cost = NULL`.
29+
424 € de pollo a coste cero.
30+
31+
2. **El artículo se mide en unidades, no en kilos.** `Milanesa de Pollo Rebozado`
32+
tiene unidad base `ud` (250 g cada una) y cuatro formatos, ninguno en kg. El papel
33+
dice 56,57 kg; el asistente lo convirtió a Paquetes (6 uds = 1,5 kg):
34+
56,57 ÷ 1,5 = 37,71 → **38 paquetes = 228 uds = 57 kg**. Sobran 1,72 uds (0,43 kg).
35+
La pantalla enseña «56,57 uds» porque pinta `doc_qty` con la etiqueta de la unidad
36+
base del artículo, sin decir que el papel hablaba de kilos.
37+
38+
3. **La bandera de la IA no llegaba a la recepción**, y por dos sitios a la vez:
39+
`createGoodsReceipt` se llamaba sin `needsReview`, y aunque se hubiera pasado,
40+
`receive_goods_receipt` la PISABA con `needs_review = (v_skipped > 0)` — un albarán
41+
con todas las líneas casadas sale `false` por definición, diga lo que diga la IA.
42+
43+
---
44+
45+
## 1 · El tamaño real, con el filtro que faltaba
46+
47+
Las 410 del encargo incluyen líneas de albaranes **anulados o archivados**. Sin ellos:
48+
49+
| | Del encargo | Medido hoy (albarán vivo, no anulado) |
50+
|---|---:|---:|
51+
| Líneas de recepción | 831 (dos cuentas) | 744 en Foodint |
52+
| Marcadas sin confirmar | 410 | **341** |
53+
| Importe en juego || **16.108,21 €** |
54+
| Cantidad distinta a la del papel | 53 | 53 |
55+
| Sin coste unitario | 8 | 5 en Foodint |
56+
57+
**Y el aviso amarillo describe mal el problema.** Decía siempre *«Lo emparejó el sistema
58+
por parecido de nombre, no por código»*. De las 408 marcadas en Foodint, **400 son
59+
`map_source='unmapped'`** — el sistema no las casó en absoluto — y en toda la base solo
60+
hay **11 líneas `fuzzy`**. El aviso mandaba a mirar donde no era. Ahora cada línea dice
61+
lo que le pasa a ella (`unverifiedReason`, con 8 pruebas).
62+
63+
---
64+
65+
## 2 · Lo que se ha hecho
66+
67+
### 2.1 · Ver el albarán — `ReceiptOfficeReview.tsx`
68+
69+
El componente ya existía (`ReceiptPhotoViewer`, con zoom, lightbox y visor de PDF) y
70+
esta pantalla no lo usaba. Ahora:
71+
72+
- **Pantalla ancha**: dos columnas, el papel a la derecha y `sticky` — sigue visible al
73+
bajar por las líneas.
74+
- **Móvil**: botón grande *Ver el albarán* (no un icono de 9 px).
75+
76+
Sirve para los dos formatos porque el visor ya los distinguía: ALB-00119 es `00-image.jpg`,
77+
ALB-00117 es un PDF de Bidfood.
78+
79+
### 2.2 · Corregir cantidad, formato y coste
80+
81+
`adjust_goods_receipt_line` estaba desplegada desde el 13/08 y en 24 h se llamó 2 veces
82+
frente a 357 peticiones de la pantalla. **No faltaba construirla: no se llegaba a ella.**
83+
Dos huecos, los dos tapados:
84+
85+
- El editor **solo dejaba cambiar cantidad y coste, no el formato**. Un albarán en kilos
86+
casado a un formato de 6 unidades no tenía arreglo desde la pantalla — el caso ALB-00119.
87+
Ahora hay desplegable de formato con la medida en la etiqueta («Paquete (1,5 kg)»).
88+
- El botón **solo estaba en las líneas resueltas**. Las 341 marcadas son *dudosas*, y una
89+
dudosa solo ofrecía *Sí, es esta* / *No, es otro artículo*: si el artículo era el
90+
correcto pero la cantidad estaba mal, no había por dónde. Ahora hay un tercer botón,
91+
**Corregir cantidad, formato o precio**.
92+
93+
El editor enseña, mientras escribes, **lo que va a entrar al almacén** y el total.
94+
Exige motivo (`discrepancy_reason`) siempre que se mueva cantidad o formato — corregir
95+
solo el coste no mueve stock. Escribe **siempre** por `adjust_goods_receipt_line`
96+
(reverso + reposteo, quién y por qué), nunca por un `UPDATE` (§9.8).
97+
98+
### 2.3 · Proveedor y nº de albarán
99+
100+
Editables en la cabecera, y **obligatorios para cerrar** — en la pantalla y también en
101+
el servidor (`confirm_goods_receipt`), para que no dependa de que la pantalla se acuerde.
102+
La exigencia solo aplica al cierre de OFICINA (`recibido``confirmado`); un borrador
103+
manual sigue igual, para no levantar un muro sin puerta donde no se puede arreglar.
104+
105+
Impacto medido: de 114 recepciones confirmadas, **0 sin proveedor y 4 sin nº**.
106+
107+
### 3 · Que no se repita — la migración
108+
109+
`20260820T1700_recepcion_verificacion_a_ciegas.sql`, tres piezas:
110+
111+
| | Qué |
112+
|---|---|
113+
| **A** | `_post_goods_receipt_lines(p_receipt_id, p_only_unposted default false)` — postear solo las líneas que nunca han posteado. Sin el parámetro, idéntica a hoy. |
114+
| **B** | `receive_goods_receipt(p_receipt_id, p_hold default false)``p_hold` deja el albarán en `'recibido'` (para que la oficina lo pueda abrir) pero **no postea nada** y marca `needs_review`. Y deja de PISAR una bandera que venga puesta. |
115+
| **C** | `confirm_goods_receipt` — al cerrar un `'recibido'`, entra lo que aún no había entrado. Para un albarán normal no cambia nada: sus líneas posteables ya tienen movimiento. |
116+
117+
**Por qué se retiene el posteo y no el estado**: un borrador va a `GoodsReceiptForm`, no
118+
a la pantalla de oficina, y `adjust_goods_receipt_line` exige `status='recibido'`.
119+
Retener en borrador dejaría el albarán fuera del único sitio donde se puede corregir.
120+
121+
Verificado que nada más en la base llama a esas funciones: solo se mencionan en
122+
comentarios de `adjust_goods_receipt_line` y `post_pending_receipt_line`.
123+
124+
En el asistente, un albarán retenido:
125+
- enseña al trabajador los motivos de la IA antes de guardar,
126+
- cambia el botón a **«Guardar y mandar a oficina»**,
127+
- y no aprende nada (`learnFromReceipt`, `last_price`, cascada de coste): aprender de un
128+
albarán que la IA marcó dudoso es exactamente cómo se contamina el catálogo.
129+
130+
### 6 · La lista de las líneas sin confirmar
131+
132+
`UnverifiedLinesPage.tsx`, nueva. Todas las líneas marcadas de la cuenta, **ordenadas por
133+
importe**, cada una con su albarán detrás a un clic. Filtro por local. Distingue las que
134+
están en el almacén de las que no entraron. Entrada desde el aviso de la lista de
135+
recepciones, que ahora es un botón, no un cartel.
136+
137+
---
138+
139+
## Orden de despliegue
140+
141+
El frontend **no depende** de que la migración esté puesta:
142+
143+
- `hold=false` (todo el tráfico de hoy) llama a `receive_goods_receipt` con **un**
144+
argumento → funciona antes y después.
145+
- `hold=true` solo puede ocurrir con la migración puesta; si no lo está, el error se
146+
explica en castellano y **la recepción se queda en borrador**: con todo lo que escribió
147+
el trabajador guardado y sin nada posteado. Recepcionar es lo que estuvo 6 días roto;
148+
no se vuelve a romper por un orden de despliegue.
149+
150+
Después de aplicar la migración, `needs_review` de la IA empieza a retener de verdad.
151+
152+
---
153+
154+
## Criterio 1 · La corrección de ALB-00119
155+
156+
**No la he ejecutado yo, y es deliberado.** `adjust_goods_receipt_line` escribe
157+
`created_by = auth.uid()`; llamándola desde MCP (rol de servicio) el movimiento de 424 €
158+
quedaría firmado por nadie, que es justo lo contrario de lo que pide el criterio (§9.8,
159+
rastro). Se hace desde la pantalla, con el botón **Corregir** nuevo — y así el criterio 3
160+
se verifica con el mismo gesto.
161+
162+
**Los números, sacados del papel:**
163+
164+
| Campo | Valor | De dónde sale |
165+
|---|---|---|
166+
| Proveedor | **NOBLEZA VACUNA SL** (a confirmar con el papel) | es el proveedor **preferido** de este artículo y su ficha está en formato *Paquete*, el mismo de la línea. El albarán es manuscrito: la IA no leyó emisor. |
167+
| Nº de albarán | el que venga en el papel | la IA lo leyó como `null` |
168+
| Formato | **Paquete** (6 uds = 1,5 kg) | el que ya tiene; no hay ningún formato en kg |
169+
| Cantidad | **37,713333** | 56,57 kg ÷ 1,5 kg/paquete |
170+
| Coste de cada uno | **11,25 €** | 7,50 €/kg × 1,5 kg/paquete |
171+
| Motivo | *lo dice el albarán* | |
172+
173+
Comprobación: 37,713333 × 11,25 = **424,27 €** ✓ (base imponible del papel)
174+
y 37,713333 × 6 = **226,28 uds** al almacén, frente a las 228 que hay ahora.
175+
176+
**Aviso de precio**: `article_supplier.last_price` de este artículo con NOBLEZA está en
177+
1,25 €/ud = 5,00 €/kg. El papel dice 7,50 €/kg, **un 50 % más**. Puede ser real o puede
178+
ser un 5 leído como 7 en letra manuscrita. Con el papel delante se resuelve en un
179+
segundo; ése es exactamente el punto del encargo.
180+
181+
---
182+
183+
## Criterios
184+
185+
| # | Criterio | Estado |
186+
|---|---|---|
187+
| 1 | ALB-00119 corregido con la función y motivo | Números listos arriba · **lo ejecuta Julio desde el botón Corregir** (razón explicada) |
188+
| 2 | El albarán se ve sin salir de la pantalla (foto y PDF) | ✅ implementado · falta tu captura |
189+
| 3 | Cambiar cantidad, formato y coste con rastro | ✅ implementado · falta tu captura |
190+
| 4 | Proveedor y nº se pueden poner y sin ellos no se cierra | ✅ pantalla + servidor (tras la migración) |
191+
| 5 | Una recepción con `needs_review` no postea stock | ✅ código · **efectivo al aplicar la migración** |
192+
| 6 | Lista de las líneas sin confirmar por importe |`UnverifiedLinesPage` · **341 líneas, 16.108,21 €** |
193+
194+
## Comprobaciones
195+
196+
`tsc -b` limpio · `npm run build` limpio · **31 pruebas, 3 ficheros, todas pasan**
197+
(8 nuevas de `unverifiedReason`).
198+
`eslint`: los mismos errores que ya había antes del encargo, verificado con `git stash`
199+
(2 en `GoodsReceiptsPage`, 2 en `ReceiptScanPanel`/`ReceiptWizard`, todos
200+
`set-state-in-effect` preexistentes). `UnverifiedLinesPage.tsx` sale limpia.

src/modules/supply/pages/GoodsReceiptForm.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ export interface OcrPrefill {
161161
rawDocumentUrl: string | null
162162
unmatchedSupplier: boolean
163163
unmatchedLocation: boolean
164+
// ENCARGO CODE (20/08) «Verificar un albarán a ciegas» §3 — la IA YA sabe
165+
// decir "esto hay que mirarlo" (documento manuscrito, no cuadra la base
166+
// imponible, confianza de lectura baja) y ese aviso se quedaba en el panel
167+
// de escaneo: nunca llegaba a la recepción. Caso real ALB-00119 (20/08):
168+
// tres motivos puestos y la mercancía posteada igual, con coste nulo.
169+
// Ahora viaja, y ReceiptWizard retiene el posteo.
170+
aiNeedsReview: boolean
171+
aiReviewReasons: string[]
164172
lines: OcrPrefillLine[]
165173
}
166174
export interface OcrPrefillLine {

src/modules/supply/pages/GoodsReceiptsPage.tsx

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
getReceiptDocTotal,
3636
getReceiptCorrectionStreak,
3737
getLineCounts,
38+
countUnverifiedLines,
3839
type GoodsReceipt,
3940
type GoodsReceiptStatus,
4041
type CorrectionStreak,
@@ -51,6 +52,8 @@ import ReceiptScanPanel from '@/modules/supply/pages/ReceiptScanPanel'
5152
import ReceiptWizard from '@/modules/supply/pages/ReceiptWizard'
5253
import OrderReceiveFlow from '@/modules/supply/components/OrderReceiveFlow'
5354
import ReceiptOfficeReview from '@/modules/supply/pages/ReceiptOfficeReview'
55+
// ENCARGO CODE (20/08) «Verificar un albarán a ciegas» §6.
56+
import UnverifiedLinesPage from '@/modules/supply/pages/UnverifiedLinesPage'
5457

5558
const STATUS_LABEL: Record<GoodsReceiptStatus, string> = {
5659
borrador: 'Borrador',
@@ -72,7 +75,7 @@ function formatDate(value: string | null): string {
7275
.format(new Date(value))
7376
}
7477

75-
type View = 'list' | 'form' | 'scan' | 'receive-order' | 'wizard' | 'office-review'
78+
type View = 'list' | 'form' | 'scan' | 'receive-order' | 'wizard' | 'office-review' | 'unverified'
7679

7780
export default function GoodsReceiptsPage() {
7881
const { activeAccountId, accountsLoading } = useActiveAccount()
@@ -103,6 +106,9 @@ export default function GoodsReceiptsPage() {
103106
// ENCARGO CODE (14/08) feat/recepcion-oficina-cierre, B.1 — la recepción
104107
// 'recibido' abre ReceiptOfficeReview (pantalla propia), no GoodsReceiptForm.
105108
const [officeReviewReceiptId, setOfficeReviewReceiptId] = useState<string | null>(null)
109+
// ENCARGO CODE (20/08) «Verificar un albarán a ciegas» §6 — cuántas líneas
110+
// entraron al almacén sin que nadie las confirmara.
111+
const [unverifiedCount, setUnverifiedCount] = useState(0)
106112

107113
// Arranque rápido desde el vigía de stock negativo (Almacén → Teórico vs
108114
// Real → Stock negativo): llega por navigate(state), no por props (esta
@@ -177,10 +183,13 @@ export default function GoodsReceiptsPage() {
177183
listGoodsReceipts({ accountId: activeAccountId, locationId: resolvedLocationId ?? undefined }),
178184
listSuppliers(activeAccountId),
179185
listSupplyLocations(activeAccountId),
186+
// ENCARGO CODE (20/08) §6 — solo el número; la lista se carga al entrar.
187+
// Si falla no rompe la pantalla: el aviso simplemente no sale.
188+
countUnverifiedLines(activeAccountId).catch(() => 0),
180189
])
181-
.then(([rows, sups, locs]) => {
190+
.then(([rows, sups, locs, unver]) => {
182191
if (cancelled) return
183-
setReceipts(rows); setSuppliers(sups); setLocations(locs)
192+
setReceipts(rows); setSuppliers(sups); setLocations(locs); setUnverifiedCount(unver)
184193
})
185194
.catch((err: unknown) => {
186195
if (cancelled) return
@@ -513,6 +522,18 @@ export default function GoodsReceiptsPage() {
513522
)
514523
}
515524

525+
// ── Vista SIN-VERIFICAR: las líneas que entraron sin confirmar (§6) ──
526+
if (view === 'unverified' && activeAccountId) {
527+
return (
528+
<UnverifiedLinesPage
529+
accountId={activeAccountId}
530+
locationId={resolvedLocationId}
531+
onBack={() => { setView('list'); setReloadTick(t => t + 1) }}
532+
onOpenReceipt={(id) => { setOfficeReviewReceiptId(id); setView('office-review') }}
533+
/>
534+
)
535+
}
536+
516537
// ── Vista OFFICE-REVIEW: oficina verifica una recepción 'recibido' ──
517538
if (view === 'office-review' && activeAccountId && officeReviewReceiptId) {
518539
return (
@@ -603,6 +624,21 @@ export default function GoodsReceiptsPage() {
603624
</div>
604625
)}
605626

627+
{/* ENCARGO CODE (20/08) «Verificar un albarán a ciegas» §6 — el aviso de
628+
la pantalla de oficina avisaba de algo que ya había pasado y no daba
629+
con qué arreglarlo: una alarma sin puerta. Ésta es la puerta. */}
630+
{!loading && !error && unverifiedCount > 0 && (
631+
<button type="button" onClick={() => setView('unverified')}
632+
className="w-full text-left p-3 rounded-md border border-warning/30 bg-warning-bg flex items-center gap-2 hover:opacity-90 transition-base">
633+
<AlertTriangle size={16} className="text-warning shrink-0" />
634+
<p className="text-sm font-medium text-text-primary flex-1">
635+
{unverifiedCount} línea{unverifiedCount === 1 ? '' : 's'} entr{unverifiedCount === 1 ? 'ó' : 'aron'} al
636+
almacén sin que nadie {unverifiedCount === 1 ? 'la' : 'las'} confirmara
637+
</p>
638+
<span className="shrink-0 text-sm font-semibold text-text-primary">Verlas por importe →</span>
639+
</button>
640+
)}
641+
606642
{/* ENCARGO CODE (13/08) fix/recepcion-p2-oficina, §5 — solo lectura + una
607643
propuesta, nada automático. Sin botón que escriba receipt_approval=
608644
'directo': ese valor no lo consume ninguna pantalla todavía (modo

0 commit comments

Comments
 (0)