Skip to content

Commit 9508b2a

Browse files
committed
fix(recepcion): la cantidad salia 1 en casi todas las lineas
Julio, tras el arreglo del cuelgue: "en las lineas de recepcion siempre en cantidad sale 1 y provoca errores". LA CAUSA deriveFormatQty dividia SIEMPRE la cantidad del albaran entre el tamano del formato, y ademas ponia suelo con Math.max(1, ...). Pero el albaran casi nunca cuenta en unidad base: cuenta en SU formato -- "1 garrafa", "3 cajas", "2 sacos". Dividir 3 cajas entre las 6 ud que trae cada caja da 0,5, y el max(1,…) lo convertia en 1. Comprobado contra el albaran real que rompio hoy (AV260644360 de BIDFOOD, 19 lineas): el OCR trae las cantidades bien (1, 1, 1, 3...). No fallaba la lectura: se dividia lo que ya estaba contado, y el suelo tapaba el resultado. EL ARREGLO Solo se divide cuando la cantidad del albaran viene en la MISMA unidad que la base de la ficha (3 kg y la base es kg -> tiene sentido convertir a packs). Si el albaran cuenta en cajas, garrafas o sacos, ese numero YA es el que se recibe y se toma tal cual. La columna de bultos sigue mandando sobre todo. Y se quita el suelo mudo: si la cuenta da menos de un pack entero se sugiere 1 pero se marca 'inexact', nunca se fuerza en silencio. SEGUNDO FALLO, DEL MISMO SITIO qtySource: 'inexact' no se pintaba en NINGUN sitio. El comentario del codigo decia que se avisaba y la cabecera del fichero lo exige ("division no exacta -> se avisa, nunca se redondea en silencio"), pero la pantalla no lo decia. Ahora sale un aviso ambar bajo el contador con lo que dice el albaran y lo que trae cada pack, y la peticion de comprobar antes de seguir. PRUEBAS deriveFormatQty sale de ReceiptWizard.tsx a src/modules/supply/lib/receiptQty.ts para poder probarse -- van dos fallos en esta pantalla en un dia y no habia una sola prueba que cubriera este calculo. 13 casos, sacados del albaran real: tests/unit/modules/supply/receiptQty.test.ts. npm run build verde. Lint limpio. 13/13 en las nuevas. Los 6 fallos de la suite (routes y mappers de multitenancy) son PREVIOS y ajenos: siguen igual con estos cambios guardados aparte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vy4yW6xBAtXcaSGgKybquB
1 parent a7cc8e3 commit 9508b2a

3 files changed

Lines changed: 160 additions & 19 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// src/modules/supply/lib/receiptQty.ts
2+
//
3+
// Cuánto se ha recibido de una línea de albarán, expresado en el FORMATO
4+
// físico (cajas, garrafas, sacos) y no en la unidad base suelta.
5+
//
6+
// Vive aparte de ReceiptWizard.tsx para poder probarse: el 20/08 este cálculo
7+
// dejó "1" en casi todas las líneas de todas las recepciones, y no había una
8+
// sola prueba que lo cubriera.
9+
10+
/** De dónde sale el número que ve el trabajador — decide el aviso de "no cuadra". */
11+
export type QtySource = 'albaran' | 'packages' | 'division' | 'inexact' | 'manual'
12+
13+
function norm(u: string | null): string {
14+
return (u ?? '').trim().toLowerCase()
15+
}
16+
17+
/**
18+
* Devuelve la cantidad en unidades de FORMATO, y de dónde sale.
19+
* `null` si no hay formato con el que contar.
20+
*/
21+
export function deriveFormatQty(
22+
albaranQty: number | null,
23+
albaranPackages: number | null,
24+
formatQtyInBase: number | null,
25+
albaranUnit: string | null,
26+
baseUnitAbbr: string | null,
27+
): { qty: number; source: QtySource } | null {
28+
if (formatQtyInBase == null || formatQtyInBase <= 0) return null
29+
30+
// 1 · La columna de bultos manda: es un recuento explícito de packs.
31+
if (albaranPackages != null && albaranPackages > 0) {
32+
return { qty: Math.round(albaranPackages), source: 'packages' }
33+
}
34+
if (albaranQty == null || albaranQty <= 0) return null
35+
36+
// 2 · SOLO se divide si la cantidad del albarán viene en la unidad BASE.
37+
//
38+
// ── EL FALLO DEL 20/08 ────────────────────────────────────────────────
39+
// Antes se dividía siempre, y encima con `Math.max(1, ...)`. Pero el
40+
// albarán casi nunca cuenta en unidad base: cuenta en SU formato — "1
41+
// garrafa", "3 cajas", "2 sacos". Dividir 3 cajas entre las 6 ud que trae
42+
// cada caja da 0,5, y el max(1,…) lo convertía en 1. De ahí el "siempre
43+
// sale 1": no era que fallara la lectura, es que se dividía lo que ya
44+
// estaba contado, y el suelo tapaba el resultado.
45+
//
46+
// Sólo cuando el albarán habla en la misma unidad que la ficha (3 kg y la
47+
// base es kg) tiene sentido convertir a packs.
48+
const enUnidadBase = norm(albaranUnit) !== '' && norm(albaranUnit) === norm(baseUnitAbbr)
49+
50+
if (!enUnidadBase || formatQtyInBase === 1) {
51+
// Ya viene contado en el formato: se toma tal cual.
52+
return { qty: Math.round(albaranQty), source: 'albaran' }
53+
}
54+
55+
const divided = albaranQty / formatQtyInBase
56+
const rounded = Math.round(divided)
57+
if (rounded < 1) {
58+
// Menos de un pack entero. NO se fuerza a 1 en silencio (la cabecera de
59+
// ReceiptWizard lo prohíbe): se sugiere 1 y se marca inexacto para que la
60+
// pantalla lo diga y el trabajador corrija.
61+
return { qty: 1, source: 'inexact' }
62+
}
63+
const exact = Math.abs(divided - rounded) < 0.02
64+
return { qty: rounded, source: exact ? 'division' : 'inexact' }
65+
}

src/modules/supply/pages/ReceiptWizard.tsx

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
} from 'lucide-react'
4949
import { useApp } from '@/context/AppContext'
5050
import { listFormatsByItem } from '@/modules/kitchen/services/purchaseFormatService'
51+
import { deriveFormatQty, type QtySource } from '@/modules/supply/lib/receiptQty'
5152
import type { PurchaseFormat } from '@/types/kitchen'
5253
import ReceiptPhotoViewer from '@/modules/supply/components/ReceiptPhotoViewer'
5354
import LineMatchPicker from '@/modules/supply/pages/LineMatchPicker'
@@ -74,8 +75,6 @@ interface ReceiptWizardProps {
7475
onDone: (message?: string) => void
7576
}
7677

77-
// De dónde sale el número que ve el trabajador — decide el aviso de "no cuadra".
78-
type QtySource = 'albaran' | 'packages' | 'division' | 'inexact' | 'manual'
7978

8079
interface WizardLine {
8180
key: string
@@ -188,21 +187,6 @@ function baseUnitWord(abbr: string | null | undefined): string {
188187
// la cantidad del albarán entre el contenido del formato. Si no divide
189188
// exacto, se marca 'inexact' — el número se sugiere (redondeado) pero se
190189
// avisa, nunca se redondea en silencio.
191-
function deriveFormatQty(
192-
albaranQty: number | null, albaranPackages: number | null, formatQtyInBase: number | null,
193-
): { qty: number; source: QtySource } | null {
194-
if (formatQtyInBase == null || formatQtyInBase <= 0) return null
195-
if (albaranPackages != null && albaranPackages > 0) {
196-
return { qty: Math.round(albaranPackages), source: 'packages' }
197-
}
198-
if (albaranQty != null && albaranQty > 0) {
199-
const divided = albaranQty / formatQtyInBase
200-
const rounded = Math.max(1, Math.round(divided))
201-
const exact = Math.abs(divided - rounded) < 0.02
202-
return { qty: rounded, source: exact ? 'division' : 'inexact' }
203-
}
204-
return null
205-
}
206190

207191
function lineFromOcr(l: OcrPrefill['lines'][number], i: number): WizardLine {
208192
return {
@@ -427,7 +411,7 @@ export default function ReceiptWizard({ accountId, locationId, ocrPrefill, onBac
427411
if (!resolved.purchaseFormatId || resolved.qtyInBasePerPack == null) {
428412
return { ...withResolution, purchaseFormatId: null }
429413
}
430-
const derived = deriveFormatQty(x.albaranQty, x.albaranPackages, resolved.qtyInBasePerPack)
414+
const derived = deriveFormatQty(x.albaranQty, x.albaranPackages, resolved.qtyInBasePerPack, x.albaranUnit, base?.abbr ?? null)
431415
return {
432416
...withResolution, purchaseFormatId: resolved.purchaseFormatId,
433417
...(derived ? { qty: derived.qty, qtySource: derived.source } : {}),
@@ -499,7 +483,7 @@ export default function ReceiptWizard({ accountId, locationId, ocrPrefill, onBac
499483
if (x.key !== key) return x
500484
if (!purchaseFormatId) return { ...x, purchaseFormatId: '' }
501485
const fmt = x.formats.find(f => f.id === purchaseFormatId)
502-
const derived = fmt ? deriveFormatQty(x.albaranQty, x.albaranPackages, fmt.qtyInBase) : null
486+
const derived = fmt ? deriveFormatQty(x.albaranQty, x.albaranPackages, fmt.qtyInBase, x.albaranUnit, x.baseUnit?.abbr ?? null) : null
503487
return { ...x, purchaseFormatId, ...(derived ? { qty: derived.qty, qtySource: derived.source } : {}) }
504488
}))
505489
}
@@ -1008,6 +992,26 @@ function LineScreen({
1008992
</button>
1009993
</div>
1010994

995+
{/* El número NO sale del albarán tal cual: se ha convertido a packs y
996+
la cuenta no da exacta. Se avisa SIEMPRE — la cabecera de este
997+
fichero lo exige ("nunca se redondea en silencio") y hasta el
998+
20/08 no se avisaba en ningún sitio, aunque el código ya marcaba
999+
la línea como 'inexact'. */}
1000+
{l.qtySource === 'inexact' && (
1001+
<div className="mt-3 rounded-lg bg-warning-bg px-3.5 py-2.5 flex items-start gap-2.5">
1002+
<AlertTriangle size={18} className="text-warning shrink-0 mt-0.5" />
1003+
<p className="text-sm text-text-primary leading-snug">
1004+
<span className="font-medium">La cuenta no da exacta.</span>{' '}
1005+
{l.albaranQty != null && (
1006+
<>El albarán dice {nf1.format(l.albaranQty)} {l.albaranUnit || 'ud'}
1007+
{format?.qtyInBase ? <> y cada {pluralFormatName(format.name, 1).toLowerCase()} trae {nf1.format(format.qtyInBase)} {baseUnitWord(l.baseUnit?.abbr)}</> : null}.{' '}
1008+
</>
1009+
)}
1010+
Comprueba el número antes de seguir.
1011+
</p>
1012+
</div>
1013+
)}
1014+
10111015
{/* Franja de resultado */}
10121016
{baseTotal != null && l.qty > 0 && (
10131017
<div className="mt-4 rounded-lg bg-success-bg px-3.5 py-2.5 flex items-center gap-2.5">
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Cantidad recibida por línea de albarán.
2+
//
3+
// Estos casos salen del albarán REAL que rompió el 20/08: AV260644360 de
4+
// BIDFOOD, sesión c7494b99-205a-43f9-914b-6f556fa1fce6, 19 líneas.
5+
6+
import { describe, it, expect } from 'vitest'
7+
import { deriveFormatQty } from '@/modules/supply/lib/receiptQty'
8+
9+
describe('deriveFormatQty · el albarán cuenta en SU formato', () => {
10+
it('1 garrafa de 25 L se recibe como 1 garrafa, no como 1/25', () => {
11+
// ACEITE GIRASOL 25 LT · quantity 1, unit "ud", base litros
12+
expect(deriveFormatQty(1, null, 25, 'ud', 'l')).toEqual({ qty: 1, source: 'albaran' })
13+
})
14+
15+
it('3 cajas de 6 ud se reciben como 3 cajas — ESTE es el fallo del 20/08', () => {
16+
// Antes: 3/6 = 0,5 -> Math.max(1, round(0,5)) = 1. "Siempre sale 1".
17+
expect(deriveFormatQty(3, null, 6, 'caja', 'ud')).toEqual({ qty: 3, source: 'albaran' })
18+
})
19+
20+
it('2 garrafas de 25 L se reciben como 2, no como 1', () => {
21+
expect(deriveFormatQty(2, null, 25, 'ud', 'l')).toEqual({ qty: 2, source: 'albaran' })
22+
})
23+
24+
it('1 caja de 4,8 kg se recibe como 1 caja', () => {
25+
// BACON CRISPY · quantity 1, unit "caja", base kg
26+
expect(deriveFormatQty(1, null, 4.8, 'caja', 'kg')).toEqual({ qty: 1, source: 'albaran' })
27+
})
28+
})
29+
30+
describe('deriveFormatQty · el albarán cuenta en unidad BASE', () => {
31+
it('3 kg con formato de 1 kg son 3', () => {
32+
// CEBOLLA ROJA · quantity 3, unit "kg", formato Kilogramo
33+
expect(deriveFormatQty(3, null, 1, 'kg', 'kg')).toEqual({ qty: 3, source: 'albaran' })
34+
})
35+
36+
it('12 ud con cajas de 6 ud son 2 cajas, y la cuenta es exacta', () => {
37+
expect(deriveFormatQty(12, null, 6, 'ud', 'ud')).toEqual({ qty: 2, source: 'division' })
38+
})
39+
40+
it('13 ud con cajas de 6 ud sugiere 2 pero avisa de que no es exacto', () => {
41+
expect(deriveFormatQty(13, null, 6, 'ud', 'ud')).toEqual({ qty: 2, source: 'inexact' })
42+
})
43+
44+
it('menos de un pack entero sugiere 1 pero NUNCA en silencio', () => {
45+
const r = deriveFormatQty(2, null, 6, 'ud', 'ud')
46+
expect(r).toEqual({ qty: 1, source: 'inexact' })
47+
})
48+
49+
it('la unidad se compara sin distinguir mayúsculas ni espacios', () => {
50+
expect(deriveFormatQty(12, null, 6, ' KG ', 'kg')).toEqual({ qty: 2, source: 'division' })
51+
})
52+
})
53+
54+
describe('deriveFormatQty · precedencias y bordes', () => {
55+
it('la columna de bultos manda sobre todo lo demás', () => {
56+
expect(deriveFormatQty(48, 4, 12, 'ud', 'ud')).toEqual({ qty: 4, source: 'packages' })
57+
})
58+
59+
it('sin formato con el que contar, no inventa nada', () => {
60+
expect(deriveFormatQty(3, null, null, 'caja', 'ud')).toBeNull()
61+
expect(deriveFormatQty(3, null, 0, 'caja', 'ud')).toBeNull()
62+
})
63+
64+
it('sin cantidad, no inventa nada', () => {
65+
expect(deriveFormatQty(null, null, 6, 'caja', 'ud')).toBeNull()
66+
expect(deriveFormatQty(0, null, 6, 'caja', 'ud')).toBeNull()
67+
})
68+
69+
it('unidad del albarán vacía: se toma tal cual, no se divide a ciegas', () => {
70+
expect(deriveFormatQty(3, null, 6, null, 'ud')).toEqual({ qty: 3, source: 'albaran' })
71+
})
72+
})

0 commit comments

Comments
 (0)