Skip to content

Commit f0f5b63

Browse files
committed
fix(pedido): el mismo articulo salia repetido, una fila por ficha de proveedor
La pantalla de pedido pintaba una fila por cada ficha article_supplier, no por articulo. CLOUDTOWN daba 98 filas para 89 articulos: cuando un articulo tenia dos fichas del mismo proveedor (una con codigo y formato, otra sin nada) aparecia dos veces, y la segunda caia al formato base imprimiendo "ml" en vez del envase. - supplierCatalogService: mergeEntriesByItem() agrupa por recipe_item_id y elige representante con fichaScore (preferido > codigo > formato > precio). Rellena huecos desde las hermanas, toma el bloque de formato entero de una sola ficha (no mezcla formato de una con medida de otra) y recoge los codigos alternativos en otherSupplierCodes. - SupplyOrderBuilder: selector de formato por fila cuando el articulo tiene mas de uno, etiqueta con la medida ("Garrafa (5 L)"), DraftLine lleva formatId y el guardado escala el precio al formato elegido. Aviso ambar cuando el articulo no tiene formato de ese proveedor. - 12 pruebas nuevas del merge. Verificado contra datos reales: CLOUDTOWN 98 -> 89 filas. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vy4yW6xBAtXcaSGgKybquB
1 parent 9508b2a commit f0f5b63

3 files changed

Lines changed: 277 additions & 6 deletions

File tree

src/modules/supply/pages/SupplyOrderBuilder.tsx

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ interface DraftLine {
5252
qty: string
5353
note: string
5454
showNote?: boolean
55+
// Formato elegido en la fila. Vacío = el de la ficha del proveedor
56+
// (e.purchaseFormatId). Se guarda aquí y no en el catálogo porque es una
57+
// decisión del pedido que se está montando, no un dato del proveedor.
58+
formatId?: string
5559
}
5660

5761
// Etiqueta legible de la fuente de la sugerencia.
@@ -73,11 +77,25 @@ function formatShortOrderDate(iso: string | null): string {
7377
}
7478

7579
// Unidad legible de la fila (formato "caja" o unidad base "kg").
76-
function unitLabel(e: SupplierCatalogEntry): string {
80+
// Etiqueta de la unidad de pedido. SIEMPRE con la medida cuando se conoce:
81+
// "bidón" no distingue dos bidones distintos, y ése es justo el error que la
82+
// pantalla tiene que impedir (§4.3 del encargo del 20/08).
83+
function unitLabel(e: SupplierCatalogEntry, formatId?: string): string {
84+
const elegido = formatId
85+
? e.formats.find((f) => f.id === formatId)
86+
: null
87+
if (elegido) return (elegido.label ?? elegido.name ?? '').toLowerCase()
88+
if (e.formatLabel) return e.formatLabel.toLowerCase()
7789
if (e.formatName) return e.formatName.toLowerCase()
7890
return e.baseUnitAbbr ?? ''
7991
}
8092

93+
// Qué formato se pide en esta fila: el elegido a mano, o el de la ficha del
94+
// proveedor. Nunca se inventa uno cuando no hay ficha (§4.5).
95+
function formatoDeLaFila(e: SupplierCatalogEntry, d?: DraftLine): string {
96+
return d?.formatId ?? e.purchaseFormatId ?? ''
97+
}
98+
8199
export default function SupplyOrderBuilder({ onBack, onSaved }: SupplyOrderBuilderProps) {
82100
const { userProfile, authUserId } = useApp()
83101
const { activeAccountId, accountsLoading } = useActiveAccount()
@@ -192,6 +210,9 @@ export default function SupplyOrderBuilder({ onBack, onSaved }: SupplyOrderBuild
192210
function setNote(id: string, note: string) {
193211
setDraft(d => ({ ...d, [id]: { ...(d[id] ?? { qty: '', note: '' }), note } }))
194212
}
213+
function setFormato(id: string, formatId: string) {
214+
setDraft(d => ({ ...d, [id]: { ...(d[id] ?? { qty: '', note: '' }), formatId } }))
215+
}
195216
function toggleNote(id: string) {
196217
setDraft(d => ({ ...d, [id]: { ...(d[id] ?? { qty: '', note: '' }), showNote: !d[id]?.showNote } }))
197218
}
@@ -277,15 +298,21 @@ export default function SupplyOrderBuilder({ onBack, onSaved }: SupplyOrderBuild
277298
const n = parseQty(draft[e.articleSupplierId]?.qty)
278299
if (n <= 0) continue
279300
const note = draft[e.articleSupplierId]?.note?.trim() || null
280-
const eurPorCaja = e.lastPrice !== null ? e.lastPrice * (e.formatQtyInBase ?? 1) : null
301+
// El formato que se pide puede haberse cambiado en la fila; el precio
302+
// (€/unidad base) se escala por la equivalencia del formato ELEGIDO.
303+
const formatIdElegido = formatoDeLaFila(e, draft[e.articleSupplierId])
304+
const qtyInBaseElegido = formatIdElegido
305+
? (e.formats.find((f) => f.id === formatIdElegido)?.qtyInBase ?? e.formatQtyInBase)
306+
: e.formatQtyInBase
307+
const eurPorCaja = e.lastPrice !== null ? e.lastPrice * (qtyInBaseElegido ?? 1) : null
281308
const lineTotal = eurPorCaja !== null ? Math.round(n * eurPorCaja * 100) / 100 : null
282309
await createPurchaseOrderLine({
283310
accountId: activeAccountId,
284311
purchaseOrderId: order.id,
285312
recipeItemId: e.recipeItemId,
286313
productName: e.itemName,
287314
qtyOrdered: n,
288-
purchaseFormatId: e.purchaseFormatId,
315+
purchaseFormatId: formatIdElegido || null,
289316
estUnitPrice: eurPorCaja,
290317
estLineTotal: lineTotal,
291318
position: position++,
@@ -438,9 +465,25 @@ export default function SupplyOrderBuilder({ onBack, onSaved }: SupplyOrderBuild
438465
{e.supplierCode && (
439466
<span className="text-[11px] text-text-tertiary">{e.supplierCode}</span>
440467
)}
468+
{/* La fila se agrupa por ARTÍCULO, pero si el proveedor
469+
tiene más de un código para él, no se esconde: es lo
470+
que el operario compara con el albarán. */}
471+
{e.otherSupplierCodes.length > 0 && (
472+
<span
473+
className="text-[11px] text-text-secondary"
474+
title={`Este proveedor tiene más de un código para este artículo: ${[e.supplierCode, ...e.otherSupplierCodes].filter(Boolean).join(', ')}`}
475+
>
476+
· también {e.otherSupplierCodes.join(', ')}
477+
</span>
478+
)}
441479
{e.isPreferred && (
442480
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent-bg text-accent">preferente</span>
443481
)}
482+
{!e.purchaseFormatId && (
483+
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-warning-bg text-text-primary">
484+
sin formato de este proveedor
485+
</span>
486+
)}
444487
</div>
445488
{pending && (
446489
<div className="text-[11px] text-accent mt-0.5 truncate">
@@ -496,9 +539,39 @@ export default function SupplyOrderBuilder({ onBack, onSaved }: SupplyOrderBuild
496539
placeholder="0"
497540
className="w-14 px-2 text-right text-[17px] font-medium bg-transparent text-text-primary focus:outline-none disabled:opacity-50"
498541
/>
499-
<div className="flex items-center px-2 bg-page border-l border-border-default text-[12px] text-text-secondary whitespace-nowrap">
500-
{unitLabel(e)}
501-
</div>
542+
{/* La unidad de pedido. Con más de un formato es un
543+
desplegable DENTRO de la fila: el formato se elige
544+
aquí, no compitiendo con el artículo en la lista
545+
(§4.2). La etiqueta lleva la medida, porque dos
546+
"cajas" del mismo artículo pueden no medir igual. */}
547+
{e.formats.length === 0 ? (
548+
// Ni ficha ni formatos: NO se inventa una unidad de
549+
// compra. Antes caía a la unidad base y escribía "ml",
550+
// que no es el formato de compra de nada (§4.5).
551+
<div
552+
title="Este proveedor no tiene formato de compra para este artículo"
553+
className="flex items-center px-2 bg-warning-bg border-l border-border-default text-[12px] text-text-primary whitespace-nowrap"
554+
>
555+
sin formato
556+
</div>
557+
) : (e.formats.length > 1 || !e.purchaseFormatId) ? (
558+
<select
559+
value={formatoDeLaFila(e, d)}
560+
onChange={ev => setFormato(e.articleSupplierId, ev.target.value)}
561+
disabled={saving}
562+
aria-label={`En qué formato se pide ${e.itemName}`}
563+
className="px-2 bg-page border-l border-border-default text-[12px] text-text-secondary max-w-[150px] focus:outline-none disabled:opacity-50"
564+
>
565+
{!formatoDeLaFila(e, d) && <option value="">— elige formato —</option>}
566+
{e.formats.map(f => (
567+
<option key={f.id} value={f.id}>{f.label ?? f.name ?? '(sin nombre)'}</option>
568+
))}
569+
</select>
570+
) : (
571+
<div className="flex items-center px-2 bg-page border-l border-border-default text-[12px] text-text-secondary whitespace-nowrap">
572+
{unitLabel(e, formatoDeLaFila(e, d))}
573+
</div>
574+
)}
502575
</div>
503576
</div>
504577

src/modules/supply/services/supplierCatalogService.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ export interface SupplierCatalogEntry {
3434
// elegir el formato que coincide con la unidad del albarán (bote vs caja). El
3535
// preferente sigue expuesto arriba (purchaseFormatId/formatName/formatQtyInBase).
3636
formats: SupplierFormatOption[]
37+
// Otros códigos de ESTE proveedor para el MISMO artículo, cuando hay más de
38+
// una ficha. La fila se agrupa por artículo (una fila = un artículo), pero un
39+
// código que existe no se esconde: se enseña al lado del principal.
40+
otherSupplierCodes: string[]
3741
// Stock de referencia: vacío hoy (no hay inventario); gancho para cuando exista.
3842
stockOnHand: number | null
3943
// ── Sugerencia de repedido (motor suggest_purchase_qty) ──
@@ -136,6 +140,64 @@ export function formatStockForOrder(
136140
return formatBaseQty(stockOnHand, baseAbbr)
137141
}
138142

143+
// Prioridad para elegir qué ficha representa al artículo. Nunca al azar: la
144+
// preferente manda, luego la que trae código (es lo que el operario compara con
145+
// el albarán), luego la que trae formato, luego la que trae precio.
146+
export function fichaScore(e: SupplierCatalogEntry): number {
147+
return (e.isPreferred ? 8 : 0)
148+
+ (e.supplierCode ? 4 : 0)
149+
+ (e.purchaseFormatId ? 2 : 0)
150+
+ (e.lastPrice != null ? 1 : 0)
151+
}
152+
153+
export function mergeEntriesByItem(list: SupplierCatalogEntry[]): SupplierCatalogEntry[] {
154+
const byItem = new Map<string, SupplierCatalogEntry[]>()
155+
for (const e of list) {
156+
const arr = byItem.get(e.recipeItemId) ?? []
157+
arr.push(e)
158+
byItem.set(e.recipeItemId, arr)
159+
}
160+
161+
const out: SupplierCatalogEntry[] = []
162+
for (const grupo of byItem.values()) {
163+
if (grupo.length === 1) { out.push(grupo[0]); continue }
164+
165+
const ordenadas = [...grupo].sort((a, b) => fichaScore(b) - fichaScore(a))
166+
const jefe = ordenadas[0]
167+
168+
// El bloque de formato se toma ENTERO de la misma ficha: mezclar el nombre
169+
// de una con la equivalencia de otra daría un formato que no existe.
170+
const conFormato = ordenadas.find((e) => e.purchaseFormatId)
171+
const conCodigo = ordenadas.find((e) => e.supplierCode)
172+
const conPrecio = ordenadas.find((e) => e.lastPrice != null)
173+
const conNombre = ordenadas.find((e) => e.supplierItemName)
174+
175+
const codigoElegido = jefe.supplierCode ?? conCodigo?.supplierCode ?? null
176+
const otros = Array.from(new Set(
177+
ordenadas
178+
.map((e) => e.supplierCode)
179+
.filter((c): c is string => !!c && c !== codigoElegido),
180+
))
181+
182+
out.push({
183+
...jefe,
184+
supplierCode: codigoElegido,
185+
supplierItemName: jefe.supplierItemName ?? conNombre?.supplierItemName ?? null,
186+
lastPrice: jefe.lastPrice ?? conPrecio?.lastPrice ?? null,
187+
isPreferred: grupo.some((e) => e.isPreferred),
188+
purchaseFormatId: jefe.purchaseFormatId ?? conFormato?.purchaseFormatId ?? null,
189+
formatName: jefe.purchaseFormatId ? jefe.formatName : (conFormato?.formatName ?? null),
190+
formatQtyInBase: jefe.purchaseFormatId ? jefe.formatQtyInBase : (conFormato?.formatQtyInBase ?? null),
191+
formatLabel: jefe.purchaseFormatId ? jefe.formatLabel : (conFormato?.formatLabel ?? null),
192+
otherSupplierCodes: otros,
193+
})
194+
}
195+
196+
// Mismo orden que antes: por nombre de artículo.
197+
out.sort((a, b) => a.itemName.localeCompare(b.itemName, 'es'))
198+
return out
199+
}
200+
139201
/**
140202
* Catálogo de un proveedor: todos sus article_supplier activos, con artículo,
141203
* código, formato (nombre+equivalencia) y precio. Ordenado por nombre de artículo.
@@ -223,13 +285,37 @@ export async function getSupplierCatalog(
223285
baseUnitAbbr: baseAbbr,
224286
formatLabel: buildFormatLabel(fmt?.name ?? null, fmt?.qty_in_base ?? null, baseAbbr),
225287
formats: formatsByItem.get(r.recipe_item_id as string) ?? [],
288+
otherSupplierCodes: [],
226289
stockOnHand: null, // gancho inventario
227290
suggestedQty: null,
228291
suggestionSource: null,
229292
suggestionConfidence: null,
230293
}
231294
})
232295

296+
// ── UNA FILA POR ARTÍCULO (ENCARGO 20/08) ───────────────────────────────
297+
// article_supplier puede tener VARIAS fichas del mismo artículo para el mismo
298+
// proveedor: la misma referencia dada de alta dos veces, una con código y otra
299+
// sin él, una con formato y otra sin asignar. La pantalla pintaba UNA FILA POR
300+
// FICHA, así que el mismo artículo salía dos veces, con el mismo stock y el
301+
// mismo "en camino", y el operario tenía que elegir entre filas que se llaman
302+
// igual. Medido en Foodint: Cloudtown daba 98 filas para 89 artículos.
303+
//
304+
// Y el caso feo: "Aceite de Oliva Suave 0,4º" tenía una ficha con código y
305+
// Garrafa, y otra sin código y SIN FORMATO — esa segunda caía a la unidad base
306+
// y la pantalla escribía "ml", que no es un formato de compra de nada.
307+
//
308+
// Regla del encargo, y vale para toda la aplicación: una lista operativa se
309+
// agrupa por la cosa que el usuario nombra. El cocinero pide "aceite", no
310+
// "aceite-en-bidón-formato-3".
311+
//
312+
// NO se descarta información. Se elige una ficha representante y sus huecos se
313+
// rellenan con lo que tengan las hermanas; los códigos que quedan fuera van a
314+
// otherSupplierCodes para que la pantalla los diga en vez de esconderlos.
315+
const mergedByItem = mergeEntriesByItem(entries)
316+
entries.length = 0
317+
entries.push(...mergedByItem)
318+
233319
// Stock real del local activo (T1 inventario, vivo desde 14/06): si nos pasan
234320
// un locationId, leemos recipe_item_location_stock para los artículos del
235321
// catálogo y rellenamos stockOnHand (qty_on_hand en unidad base). Sin local,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Una fila por ARTÍCULO en la pantalla de pedido.
2+
//
3+
// Casos sacados del dato real de Foodint (20/08): CLOUDTOWN, S.L. daba 98
4+
// filas para 89 artículos, y "Aceite de Oliva Suave 0,4º" salía dos veces —
5+
// una con código y Garrafa, otra sin código y SIN FORMATO, que la pantalla
6+
// pintaba como "ml".
7+
8+
import { describe, it, expect } from 'vitest'
9+
import { mergeEntriesByItem, buildFormatLabel } from '@/modules/supply/services/supplierCatalogService'
10+
import type { SupplierCatalogEntry } from '@/modules/supply/services/supplierCatalogService'
11+
12+
function ficha(over: Partial<SupplierCatalogEntry>): SupplierCatalogEntry {
13+
return {
14+
articleSupplierId: 'as-1',
15+
recipeItemId: 'item-1',
16+
itemName: 'Artículo',
17+
supplierCode: null,
18+
supplierItemName: null,
19+
lastPrice: null,
20+
isPreferred: false,
21+
purchaseFormatId: null,
22+
formatName: null,
23+
formatQtyInBase: null,
24+
baseUnitAbbr: 'ml',
25+
formatLabel: null,
26+
formats: [],
27+
otherSupplierCodes: [],
28+
stockOnHand: null,
29+
suggestedQty: null,
30+
suggestionSource: null,
31+
suggestionConfidence: null,
32+
...over,
33+
}
34+
}
35+
36+
describe('mergeEntriesByItem · una fila por artículo', () => {
37+
it('dos fichas del mismo artículo se funden en UNA fila', () => {
38+
const r = mergeEntriesByItem([
39+
ficha({ articleSupplierId: 'a', supplierCode: '510101007', purchaseFormatId: 'f-garrafa', formatName: 'Garrafa', formatQtyInBase: 5000 }),
40+
ficha({ articleSupplierId: 'b' }),
41+
])
42+
expect(r).toHaveLength(1)
43+
})
44+
45+
it('el caso real: la ficha SIN formato no borra el formato de la hermana', () => {
46+
// Antes: la fila sin purchase_format_id caía a la unidad base -> "ml".
47+
const r = mergeEntriesByItem([
48+
ficha({ articleSupplierId: 'sin', supplierCode: null }),
49+
ficha({ articleSupplierId: 'con', supplierCode: '510101007', purchaseFormatId: 'f-garrafa', formatName: 'Garrafa', formatQtyInBase: 5000, formatLabel: 'Garrafa (5 L)' }),
50+
])
51+
expect(r[0].purchaseFormatId).toBe('f-garrafa')
52+
expect(r[0].formatLabel).toBe('Garrafa (5 L)')
53+
expect(r[0].supplierCode).toBe('510101007')
54+
})
55+
56+
it('el bloque de formato se toma ENTERO de una sola ficha, sin mezclar', () => {
57+
const r = mergeEntriesByItem([
58+
ficha({ articleSupplierId: 'a', supplierCode: 'AAA' }),
59+
ficha({ articleSupplierId: 'b', purchaseFormatId: 'f-caja', formatName: 'Caja', formatQtyInBase: 6000, formatLabel: 'Caja (6 L)' }),
60+
])
61+
expect(r[0].formatName).toBe('Caja')
62+
expect(r[0].formatQtyInBase).toBe(6000)
63+
expect(r[0].formatLabel).toBe('Caja (6 L)')
64+
})
65+
66+
it('un código que existe NO se esconde: va a otherSupplierCodes', () => {
67+
const r = mergeEntriesByItem([
68+
ficha({ articleSupplierId: 'a', supplierCode: '111', isPreferred: true }),
69+
ficha({ articleSupplierId: 'b', supplierCode: '222' }),
70+
])
71+
expect(r[0].supplierCode).toBe('111')
72+
expect(r[0].otherSupplierCodes).toEqual(['222'])
73+
})
74+
75+
it('la preferente manda sobre la que sólo tiene código', () => {
76+
const r = mergeEntriesByItem([
77+
ficha({ articleSupplierId: 'a', supplierCode: '111' }),
78+
ficha({ articleSupplierId: 'b', supplierCode: '222', isPreferred: true }),
79+
])
80+
expect(r[0].articleSupplierId).toBe('b')
81+
expect(r[0].isPreferred).toBe(true)
82+
})
83+
84+
it('artículos distintos NO se funden', () => {
85+
const r = mergeEntriesByItem([
86+
ficha({ recipeItemId: 'i1', itemName: 'Aceite' }),
87+
ficha({ recipeItemId: 'i2', itemName: 'Vinagre' }),
88+
])
89+
expect(r).toHaveLength(2)
90+
})
91+
92+
it('se ordena por nombre de artículo', () => {
93+
const r = mergeEntriesByItem([
94+
ficha({ recipeItemId: 'i2', itemName: 'Vinagre' }),
95+
ficha({ recipeItemId: 'i1', itemName: 'Aceite' }),
96+
])
97+
expect(r.map(e => e.itemName)).toEqual(['Aceite', 'Vinagre'])
98+
})
99+
})
100+
101+
describe('buildFormatLabel · la etiqueta lleva la medida', () => {
102+
it('escala mililitros a litros', () => {
103+
expect(buildFormatLabel('Garrafa', 5000, 'ml')).toBe('Garrafa (5 L)')
104+
expect(buildFormatLabel('Bidón', 25000, 'ml')).toBe('Bidón (25 L)')
105+
})
106+
it('por debajo de mil se queda en la unidad base', () => {
107+
expect(buildFormatLabel('Botella', 250, 'ml')).toBe('Botella (250 ml)')
108+
})
109+
it('gramos a kilos', () => {
110+
expect(buildFormatLabel('Caja', 4800, 'g')).toBe('Caja (4,8 kg)')
111+
})
112+
})

0 commit comments

Comments
 (0)