Skip to content

Commit 14cc421

Browse files
Llorente29claude
andauthored
feat(supply): pantalla de oficina para cerrar el ciclo de recepción (#72)
ENCARGO CODE (14/08) feat/recepcion-oficina-cierre. El asistente de cocina mete el stock al recibir, pero la oficina "confirmaba" reusando la pantalla de escaneo (GoodsReceiptForm con needsResolution=true) y las líneas sin casar desaparecían del radar en silencio (102 líneas/90d en toda la cuenta). Tramo A (BBDD, verificado en producción vía MCP): - goods_receipt_line.not_goods/not_goods_kind (columna tipificada, no se reutiliza notes ni map_source). - confirm_goods_receipt: guarda real sobre el camino 'recibido' (no cierra con líneas sin decidir) + needs_review calculado de verdad. - adjust_goods_receipt_line: admite marcar "no es mercancía" (reversa sin repostear) y distingue ENTRADA TARDÍA (movement_type='recepcion', occurred_at=received_at) de CORRECCIÓN real (ajuste, now()), con excepción de periodo cerrado si hay un inventario ya aprobado después del albarán. - ReceiptWizard: flagged_for_office ahora sigue a map_needs_review (antes solo al flag manual del operador — un casado fuzzy sin confirmar podía entrar al almacén sin levantar la mano). Tramo B (pantalla nueva, componente separado): - ReceiptOfficeReview.tsx: verifica una recepción 'recibido' (3 clases de línea — resuelta/dudosa/sin decidir — según la maqueta aprobada), lee qty_in_base/doc_qty/doc_amount tal cual sin re-derivar del catálogo, y bloquea el cierre mientras queden líneas sin decidir. - GoodsReceiptForm.tsx pierde el camino 'recibido' (isReceived, persistReviewedReceived, la fila compacta, el orden por ⚑): vuelve a ser solo escaneo/contra-pedido/ciego/revisar-borrador, sin tocarlos. - Quitados los console.log de depuración en producción. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c1051ac commit 14cc421

10 files changed

Lines changed: 1341 additions & 222 deletions

src/modules/supply/pages/GoodsReceiptForm.tsx

Lines changed: 10 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,7 @@ import {
5353
updateGoodsReceipt,
5454
deleteGoodsReceiptLinesByReceipt,
5555
createGoodsReceiptLine,
56-
updateGoodsReceiptLine,
5756
confirmReceipt,
58-
adjustGoodsReceiptLine,
5957
voidReceipt,
6058
listOrderLineReceived,
6159
qtyInBaseFromFormat,
@@ -98,23 +96,12 @@ export interface ReceiptPrefill {
9896
// REVISAR BORRADOR EN SITIO: si la fuente es un borrador, la oficina lo revisa
9997
// y confirma la MISMA recepción (no crea otra ni anula). Trae la foto del albarán.
10098
isDraft?: boolean
101-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C — la fuente es
102-
// una recepción 'recibido' (el asistente ya metió el stock): oficina VERIFICA,
103-
// no re-cuenta. A diferencia de isDraft, persist() NO borra y recrea las
104-
// líneas — las ajusta en sitio (adjustGoodsReceiptLine) para no romper la
105-
// referencia (source_id) de lo que ya está posteado en el ledger.
106-
isReceived?: boolean
10799
code?: string | null
108100
rawDocumentUrl?: string | null
109101
// ENCARGO CODE (13/08) fix/recepcion-p2-oficina, §3 — total del albarán leído
110102
// por la IA (goods_receipt_ai_session.parsed_result.document.grand_total, YA
111103
// EXISTE — se lee vía getReceiptDocTotal). null si no hubo OCR o no lo leyó.
112104
docTotal?: number | null
113-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C — quién recibió y
114-
// cuándo (goods_receipt.created_by_name / received_at), para la cabecera
115-
// "Recibido por X · HH:MM · ya en stock". Solo se usa con isReceived.
116-
receivedByName?: string | null
117-
receivedAt?: string | null
118105
}
119106
export interface ReceiptPrefillLine {
120107
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C — id real de
@@ -146,10 +133,6 @@ export interface ReceiptPrefillLine {
146133
docQty: number | null
147134
docAmount: number | null
148135
discrepancyReason: string | null
149-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C — la ⚑ que puso
150-
// el asistente ("que lo mire la oficina"). Ordena la revisión (⚑ primero) y
151-
// no cuenta como fallo de cocina en la racha.
152-
flaggedForOffice?: boolean
153136
}
154137

155138
// Propuesta OCR (C2.2.a-2): cabecera resuelta + líneas leídas del albarán.
@@ -224,7 +207,6 @@ interface DraftLine {
224207
// (prefill). Ancla adjustGoodsReceiptLine/updateGoodsReceiptLine a LA MISMA
225208
// fila en vez de borrar y recrear.
226209
id?: string
227-
flaggedForOffice?: boolean
228210
recipeItemId: string | null
229211
productName: string
230212
purchaseFormatId: string | null
@@ -679,12 +661,6 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
679661
const fromOcr = !!ocrPrefill
680662
// ¿Revisión de un BORRADOR en sitio? La oficina confirma la MISMA recepción.
681663
const reviewingDraft = correcting && !!prefill?.isDraft
682-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C — ¿revisión de un
683-
// 'recibido' del asistente? El stock YA entró; oficina verifica, no re-cuenta.
684-
const reviewingReceived = correcting && !!prefill?.isReceived
685-
console.log('[DEBUG-wizard-routing] GoodsReceiptForm montado', {
686-
againstOrder, correcting, fromOcr, reviewingDraft, reviewingReceived,
687-
})
688664
const fixedHeader = againstOrder || correcting // en OCR la cabecera es editable (propuesta)
689665
// ENCARGO CODE (12/08) fix/recepcion-fromocr-borrador. `fromOcr` significa
690666
// "esta sesión trae ocrPrefill" (escaneo en vivo) — NO "esta línea necesita
@@ -1580,14 +1556,12 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
15801556
lines = prefill.lines.map((l, i) => {
15811557
const cat = resolveFmt(l.purchaseFormatId, l.recipeItemId)
15821558
const ref = refFor(l.purchaseOrderLineId)
1583-
// Revisión de BORRADOR o de un 'recibido' (Tramo C) → precarga lo que
1584-
// se contó (para verlo y ajustar). Corrección de una CONFIRMADA →
1585-
// vacío (se re-cuenta).
1586-
const startQty = (prefill.isDraft || prefill.isReceived) && l.qtyReceived != null ? numToInputStr(l.qtyReceived) : ''
1559+
// Revisión de BORRADOR → precarga lo que se contó (para verlo y
1560+
// ajustar). Corrección de una CONFIRMADA → vacío (se re-cuenta).
1561+
const startQty = prefill.isDraft && l.qtyReceived != null ? numToInputStr(l.qtyReceived) : ''
15871562
return {
15881563
key: `pf-${i}`,
15891564
id: l.id,
1590-
flaggedForOffice: l.flaggedForOffice ?? false,
15911565
recipeItemId: l.recipeItemId,
15921566
productName: l.productName,
15931567
purchaseFormatId: l.purchaseFormatId ?? cat?.purchaseFormatId ?? null,
@@ -1614,7 +1588,7 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
16141588
// el picker de motivo al editar en oficina, solo en BORRADOR).
16151589
lineAmount: l.docAmount,
16161590
albaranQty: l.docQty,
1617-
originalQty: (prefill.isDraft || prefill.isReceived) ? startQty : null,
1591+
originalQty: prefill.isDraft ? startQty : null,
16181592
discrepancyReason: l.discrepancyReason,
16191593
}
16201594
})
@@ -1693,30 +1667,8 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
16931667

16941668
const visible = useMemo(() => {
16951669
const q = search.trim().toLowerCase()
1696-
const base = q === '' ? draft : draft.filter(l => l.productName.toLowerCase().includes(q))
1697-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C, §4 — "las
1698-
// líneas marcadas ⚑ primero, con el editor completo": la oficina entra
1699-
// directa a lo que cocina pidió revisar.
1700-
if (!reviewingReceived) return base
1701-
return [...base].sort((a, b) => Number(!!b.flaggedForOffice) - Number(!!a.flaggedForOffice))
1702-
}, [draft, search, reviewingReceived])
1703-
1704-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C, §4 — "todo lo
1705-
// demás colapsado a una frase por línea": al revisar un 'recibido', las
1706-
// líneas SIN ⚑ arrancan colapsadas (un vistazo, no un formulario); las ⚑
1707-
// arrancan abiertas con el editor completo (PR #63). Un toque expande
1708-
// cualquiera si la oficina quiere tocarla igualmente — nada queda bloqueado.
1709-
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set())
1710-
function toggleExpanded(key: string) {
1711-
setExpandedKeys(prev => {
1712-
const next = new Set(prev)
1713-
if (next.has(key)) next.delete(key); else next.add(key)
1714-
return next
1715-
})
1716-
}
1717-
function isCompactLine(l: DraftLine): boolean {
1718-
return reviewingReceived && !l.flaggedForOffice && !expandedKeys.has(l.key)
1719-
}
1670+
return q === '' ? draft : draft.filter(l => l.productName.toLowerCase().includes(q))
1671+
}, [draft, search])
17201672

17211673
const filled = useMemo(
17221674
() => draft.filter(l => { const n = parseNum(l.qty); return n !== null && n > 0 }),
@@ -1976,8 +1928,6 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
19761928
// Tramo A/C — crea el NODO de formato si la línea trae equivalencia pero aún
19771929
// no tiene id (propuesto por la IA o montado por el wizard T1). Si ya tiene
19781930
// purchaseFormatId (heredado del proveedor o del catálogo), se reutiliza.
1979-
// Extraído para no duplicar esta lógica entre persist() (recepción nueva) y
1980-
// persistReviewedReceived() (Tramo C, ajuste de una línea ya recibida).
19811931
async function resolvePurchaseFormatId(l: DraftLine): Promise<string | null> {
19821932
if (l.purchaseFormatId) return l.purchaseFormatId
19831933
if (!l.recipeItemId || l.formatQtyInBase == null || l.formatQtyInBase <= 0) return null
@@ -2014,73 +1964,10 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
20141964
}
20151965
}
20161966

2017-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C — persistencia de
2018-
// la revisión de oficina de un 'recibido'. NUNCA borra y recrea las líneas
2019-
// (a diferencia de reviewingDraft): su id es la referencia (source_id) del
2020-
// movimiento YA posteado en el ledger por el asistente. Solo las líneas con
2021-
// algo que afecta a stock/valoración cambiado de verdad llaman a
2022-
// adjustGoodsReceiptLine (reversa + repostea, ledger append-only); un cambio
2023-
// de solo el motivo usa updateGoodsReceiptLine (no toca stock); sin cambios,
2024-
// no se escribe nada. Cierra con confirmReceipt, que sobre un 'recibido' NO
2025-
// vuelve a postear — solo pasa a 'confirmado'.
2026-
async function persistReviewedReceived() {
2027-
if (!prefill) return
2028-
await updateGoodsReceipt(prefill.sourceReceiptId, {
2029-
supplierId: supplierId || null,
2030-
purchaseOrderId: linkedOrderId,
2031-
supplierDocNumber: supplierDoc.trim() || null,
2032-
receiptDate,
2033-
})
2034-
2035-
for (const l of draft) {
2036-
if (!l.id) continue
2037-
const orig = prefill.lines.find(pl => pl.id === l.id)
2038-
if (!orig) continue
2039-
const newQty = parseNum(l.qty) ?? 0
2040-
const newCost = parseNum(l.unitCost)
2041-
const reason = discrepancyReasons[l.key] ?? null
2042-
const purchaseFormatId = await resolvePurchaseFormatId(l)
2043-
const stockChanged =
2044-
orig.recipeItemId !== l.recipeItemId ||
2045-
(orig.purchaseFormatId ?? null) !== purchaseFormatId ||
2046-
orig.qtyReceived !== newQty ||
2047-
(orig.unitCost ?? null) !== newCost
2048-
if (stockChanged) {
2049-
await adjustGoodsReceiptLine(l.id, {
2050-
recipeItemId: l.recipeItemId,
2051-
purchaseFormatId,
2052-
qtyReceived: newQty,
2053-
unitCost: newCost,
2054-
discrepancyReason: reason,
2055-
})
2056-
} else if (reason !== (orig.discrepancyReason ?? null)) {
2057-
await updateGoodsReceiptLine(l.id, { discrepancyReason: reason })
2058-
}
2059-
}
2060-
2061-
const res = await confirmReceipt(prefill.sourceReceiptId)
2062-
2063-
let learnNote = ''
2064-
try {
2065-
const learned = await learnFromReceipt(prefill.sourceReceiptId)
2066-
if (learned > 0) learnNote = ` · memoria del proveedor actualizada (${learned})`
2067-
} catch (e) {
2068-
console.error('persistReviewedReceived: confirmada OK pero el aprendizaje falló', e)
2069-
}
2070-
2071-
const parts = [`verificada`]
2072-
if (res.skippedLines > 0) parts.push(`${res.skippedLines} sin postear (revisar)`)
2073-
onSaved(`Recepción ${prefill.code ?? ''} confirmada: ${parts.join(' · ')}${learnNote}.`)
2074-
}
2075-
20761967
async function persist(confirm: boolean) {
20771968
if (!locationId) { setError('No hay un local operativo definido. Revisa el aviso de local arriba.'); return }
20781969
setSaving(true); setError(null)
20791970
try {
2080-
if (reviewingReceived && prefill) {
2081-
await persistReviewedReceived()
2082-
return
2083-
}
20841971
let receipt: { id: string; code: string | null }
20851972
if (reviewingDraft && prefill) {
20861973
// En sitio: actualiza cabecera + limpia líneas para reescribirlas. NO crea
@@ -2202,20 +2089,10 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
22022089
}
22032090
}
22042091

2205-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C, §4 — "cabecera
2206-
// honesta": el stock YA está dentro, esto no es una revisión a ciegas.
2207-
const receivedTimeLabel = prefill?.receivedAt
2208-
? new Intl.DateTimeFormat('es-ES', { hour: '2-digit', minute: '2-digit' }).format(new Date(prefill.receivedAt))
2209-
: null
2210-
2211-
const title = reviewingReceived
2212-
? `Recibido por ${prefill?.receivedByName ?? '—'}${receivedTimeLabel ? ` · ${receivedTimeLabel}` : ''} · ya en stock`
2213-
: againstOrder
2092+
const title = againstOrder
22142093
? `Recibir pedido ${order?.code ?? ''}${fromOcr ? ' · albarán escaneado' : ''}`
22152094
: reviewingDraft ? `Revisar recepción ${prefill?.code ?? ''}` : correcting ? 'Corregir recepción' : fromOcr ? 'Revisar recepción escaneada' : 'Nueva recepción'
2216-
const subtitle = reviewingReceived
2217-
? 'El almacén ya tiene esta mercancía. Verifica: las líneas marcadas ⚑ primero. Cambiar la cantidad pide motivo y genera un ajuste de stock.'
2218-
: againstOrder
2095+
const subtitle = againstOrder
22192096
? 'Cuenta lo que ha llegado y escríbelo. Lo pedido y lo pendiente están a la derecha como referencia.'
22202097
: reviewingDraft
22212098
? 'Esto contó quien recibió. Revisa la foto del albarán y las líneas; ajusta lo que falte y confirma — entra al almacén con su coste.'
@@ -2242,8 +2119,8 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
22422119

22432120
{!fixedHeader && <OperativeLocationBanner op={op} locations={locations} />}
22442121

2245-
{/* Foto del albarán al revisar un borrador o un recibido (la oficina ve lo que firmó el muelle). */}
2246-
{(reviewingDraft || reviewingReceived) && prefill?.rawDocumentUrl && (
2122+
{/* Foto del albarán al revisar un borrador (la oficina ve lo que firmó el muelle). */}
2123+
{reviewingDraft && prefill?.rawDocumentUrl && (
22472124
<div className="rounded-lg border border-border-default bg-card p-3">
22482125
<p className="text-xs text-text-secondary mb-2">Albarán recibido</p>
22492126
<ReceiptPhotoViewer path={prefill.rawDocumentUrl} />
@@ -2563,30 +2440,6 @@ export default function GoodsReceiptForm({ accountId, order, prefill, ocrPrefill
25632440
const albaranPackTotalBase = albaranPack ? albaranPack.n * albaranPack.m * fu.factor : null
25642441
const formatMismatchAlbaran = albaranPackTotalBase != null && albaranPackTotalBase > 0 && l.formatQtyInBase != null && Math.abs(l.formatQtyInBase - albaranPackTotalBase) / albaranPackTotalBase > 0.02
25652442

2566-
// ENCARGO CODE (13/08) feat/recepcion-v2-asistente, Tramo C, §4 —
2567-
// línea SIN ⚑ al revisar un 'recibido': una frase, no un formulario.
2568-
// Un toque ("Revisar") la abre al editor completo de abajo — nada
2569-
// queda inaccesible, solo declutter por defecto.
2570-
if (isCompactLine(l)) {
2571-
const perBaseCompact = costN != null && l.formatQtyInBase ? costN / l.formatQtyInBase : null
2572-
return (
2573-
<div key={l.key} className="rounded-lg border border-border-default bg-card px-3 py-2 flex items-center justify-between gap-3">
2574-
<div className="min-w-0 flex items-center gap-2">
2575-
<Check size={14} className="text-success shrink-0" />
2576-
<span className="text-sm text-text-primary truncate">
2577-
{l.matchedName ?? l.productName}
2578-
{hasQty && <span className="text-text-secondary"> · {l.qty}{l.formatLabel ? ` ${l.formatLabel}` : ''}</span>}
2579-
{perBaseCompact != null && <span className="text-text-secondary"> · {fmtHumanPrice(perBaseCompact)} €/{l.baseUnit?.abbr ?? 'ud'}</span>}
2580-
</span>
2581-
</div>
2582-
<button type="button" onClick={() => toggleExpanded(l.key)}
2583-
className="shrink-0 text-xs font-medium text-accent hover:underline">
2584-
Revisar
2585-
</button>
2586-
</div>
2587-
)
2588-
}
2589-
25902443
return (
25912444
<div key={l.key}
25922445
className={`rounded-lg border p-3 ${albaranDiff ? 'border-danger bg-danger-bg' : 'border-border-default bg-card'} ${complete && !hasQty ? 'opacity-60' : ''}`}>

0 commit comments

Comments
 (0)