Skip to content

Commit e3fc88a

Browse files
committed
Merge: el ensayo de publicar cuenta todos los precios propios, no una muestra
Trae a main el arreglo del dry_run (la Edge devuelve el TOTAL y la lista completa ordenada por diferencia) y la pantalla que lo ensena: tabla con producto, canal, base, precio que se publica y delta; ocho visibles y "y X mas"; mensaje en positivo cuando no hay ninguno. hubrise-catalog-publish v48 ya esta desplegada y ACTIVE desde antes de esta fusion: la Edge no depende del merge. Lo que el merge desbloquea es el bundle OTA, para que el panel deje de leer el formato viejo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vy4yW6xBAtXcaSGgKybquB
2 parents 26d145e + 20aee81 commit e3fc88a

4 files changed

Lines changed: 152 additions & 48 deletions

File tree

src/modules/kitchen/pages/KitchenMenuPage.tsx

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useEffect, useMemo, useState } from 'react'
1717
import { useNavigate } from 'react-router-dom'
1818
import { Search, ChevronDown, ChevronRight, CircleDashed, CheckCircle2, AlertTriangle, ChefHat, Clock, UtensilsCrossed, Package, Link2Off, Link2, Plus, FolderPlus, ArrowRightLeft, X, Undo2, Info, ArrowUp, ArrowDown, Trash2, UploadCloud, Loader2, Sparkles, PackagePlus, ScanSearch } from 'lucide-react'
1919
import { useActiveAccount } from '@/modules/multitenancy/hooks/useActiveAccount'
20+
import { fmtMoney } from '@/lib/format'
2021
import {
2122
listBrandsWithCatalog,
2223
listCategoriesWithProducts,
@@ -59,6 +60,24 @@ function formatPct(value: number | null): string {
5960
return `${Math.round(value)}%`
6061
}
6162

63+
// Cuántos precios se enseñan en el ensayo. El TOTAL siempre se dice aparte;
64+
// esto solo acota la tabla para que el modal siga siendo legible.
65+
const PRECIOS_VISIBLES = 8
66+
67+
// Slug de canal -> nombre de cara a quien mira.
68+
const CANAL_NOMBRE: Record<string, string> = {
69+
glovo: 'Glovo', justeat: 'Just Eat', uber: 'Uber Eats', deliveroo: 'Deliveroo',
70+
}
71+
function canalBonito(slug: string): string {
72+
return CANAL_NOMBRE[slug] ?? slug
73+
}
74+
75+
// HubRise manda "13.50 EUR"; aquí se lee "13,50 €".
76+
function precioBonito(v: string | undefined): string {
77+
const n = parseFloat(String(v ?? '').split(' ')[0])
78+
return Number.isFinite(n) ? fmtMoney(n) : (v ?? '—')
79+
}
80+
6281
export default function KitchenMenuPage() {
6382
const { activeAccountId, accountsLoading } = useActiveAccount()
6483
const navigate = useNavigate()
@@ -1015,32 +1034,52 @@ export default function KitchenMenuPage() {
10151034
<div className="text-xs text-gray-500 mt-0.5">
10161035
{t.productos} producto(s){t.location_id ? '' : ' · sin local asociado en la conexión'}
10171036
</div>
1018-
{(() => {
1019-
const conOverride = t.precios.filter((p) => (p.price_overrides?.length ?? 0) > 0)
1020-
if (conOverride.length === 0) {
1021-
return <div className="text-xs text-gray-400 mt-2">Sin precios propios por canal en la muestra.</div>
1022-
}
1023-
return (
1024-
<div className="mt-2">
1025-
<div className="text-xs font-medium text-gray-500 mb-1">Precios propios por canal</div>
1026-
<ul className="text-xs space-y-0.5">
1027-
{conOverride.map((p) => (
1028-
<li key={p.ref} className="flex flex-wrap gap-x-2">
1029-
<span className="font-mono text-gray-600">{p.ref}</span>
1030-
<span className="text-gray-400">base {p.price}</span>
1031-
{(p.price_overrides ?? []).map((o, i) => (
1032-
<span key={i} className="text-gray-800">
1033-
· <span className="font-medium">{o.variant_refs.join(', ')}</span> {o.price}
1034-
</span>
1035-
))}
1036-
</li>
1037-
))}
1038-
</ul>
1037+
{/* LOS PRECIOS QUE CAMBIAN. El total va primero y sale del
1038+
total, no de lo que se enseñe: una pantalla de
1039+
confirmación nunca describe una muestra. */}
1040+
{(t.precios_propios_total ?? 0) === 0 ? (
1041+
<div className="text-xs text-gray-600 mt-2">
1042+
Este catálogo se publica con los <span className="font-medium">precios base</span>:
1043+
ningún precio propio por canal.
1044+
</div>
1045+
) : (
1046+
<div className="mt-2">
1047+
<div className="text-xs font-medium text-gray-900 mb-1">
1048+
{t.precios_propios_total} precio{t.precios_propios_total === 1 ? '' : 's'} propio{t.precios_propios_total === 1 ? '' : 's'} por canal
1049+
<span className="font-normal text-gray-500"> · se publican distintos del base</span>
10391050
</div>
1040-
)
1041-
})()}
1042-
{t.precios_truncados > 0 && (
1043-
<div className="text-xs text-gray-400 mt-1">…y {t.precios_truncados} producto(s) más, no mostrados.</div>
1051+
<table className="w-full text-xs">
1052+
<thead>
1053+
<tr className="text-gray-500">
1054+
<th className="text-left font-medium py-0.5">Producto</th>
1055+
<th className="text-left font-medium">Canal</th>
1056+
<th className="text-right font-medium">Base</th>
1057+
<th className="text-right font-medium">Se publica</th>
1058+
<th className="text-right font-medium">Δ</th>
1059+
</tr>
1060+
</thead>
1061+
<tbody>
1062+
{(t.precios_propios ?? []).slice(0, PRECIOS_VISIBLES).map((c, i) => (
1063+
<tr key={`${c.ref}-${i}`} className="border-t border-gray-100">
1064+
<td className="py-0.5 pr-2 text-gray-800">{c.nombre}</td>
1065+
<td className="pr-2 text-gray-600">{c.canales.map(canalBonito).join(', ')}</td>
1066+
<td className="text-right tabular-nums text-gray-500">{precioBonito(c.base)}</td>
1067+
<td className="text-right tabular-nums font-medium text-gray-900">{precioBonito(c.se_publica)}</td>
1068+
<td className={`text-right tabular-nums font-medium ${
1069+
c.delta_pct === null ? 'text-gray-400'
1070+
: c.delta_pct < 0 ? 'text-red-600' : 'text-green-700'}`}>
1071+
{c.delta_pct === null ? '—' : `${c.delta_pct > 0 ? '+' : ''}${c.delta_pct} %`}
1072+
</td>
1073+
</tr>
1074+
))}
1075+
</tbody>
1076+
</table>
1077+
{(t.precios_propios_total ?? 0) > PRECIOS_VISIBLES && (
1078+
<div className="text-xs text-gray-500 mt-1">
1079+
y {(t.precios_propios_total ?? 0) - PRECIOS_VISIBLES} más.
1080+
</div>
1081+
)}
1082+
</div>
10441083
)}
10451084
</div>
10461085
))}

src/modules/kitchen/services/catalogPublishService.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,17 @@ export interface PublishResult {
4444
targets: PublishTarget[]
4545
}
4646

47-
/** Un precio del ensayo: el del sku y, si los hay, los propios por canal. */
48-
export interface DryRunPrice {
47+
/**
48+
* Un precio propio por canal que CAMBIA respecto al base, tal y como se
49+
* publicaría. Precios en el formato de HubRise ("13.50 EUR").
50+
*/
51+
export interface DryRunPriceChange {
4952
ref: string
50-
price: string
51-
price_overrides: Array<{ variant_refs: string[]; price: string }> | null
53+
nombre: string
54+
canales: string[] // slugs de canal: glovo | justeat | uber…
55+
base: string
56+
se_publica: string
57+
delta_pct: number | null
5258
}
5359

5460
/** Un catálogo de destino, tal y como lo devuelve el ensayo. */
@@ -57,8 +63,15 @@ export interface DryRunTarget {
5763
connection_name: string
5864
location_id: string | null
5965
productos: number
60-
precios: DryRunPrice[]
61-
precios_truncados: number
66+
/**
67+
* TOTAL, no lo que se enseñe. Una confirmación nunca describe una muestra.
68+
* Opcionales a propósito: la OTA llega por estación, así que un panel puede
69+
* hablar un rato con un Edge que todavía no los manda. La UI lo tolera en
70+
* vez de reventar.
71+
*/
72+
precios_propios_total?: number
73+
/** Completa y ya ordenada por diferencia descendente (la ordena el Edge). */
74+
precios_propios?: DryRunPriceChange[]
6275
}
6376

6477
export interface DryRunResult {

supabase/functions/_shared/hubriseSku.ts

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,36 @@
2020
// stock 0/null). Si cualquiera de los dos calcula el ref de otra forma, el
2121
// namespacing deja de servir — por eso vive en un solo fichero compartido.
2222

23+
// Rango U+0300..U+036F = marcas diacriticas combinantes: lo que deja NFD tras
24+
// separar el acento de su letra.
25+
//
26+
// Se expresa con CODIGOS NUMERICOS, no con los caracteres en crudo ni con un
27+
// escape dentro de una expresion regular literal. Los caracteres en crudo son
28+
// INVISIBLES en el editor y en cualquier copia; y el escape no sobrevive al
29+
// desplegador, que viaja por JSON y decodifica los escapes antes de escribir
30+
// el fichero — paso en la v45, y volvio a pasar en la v46 y la v47 aun
31+
// mandandolo doblado. Con codigos el fichero es ASCII puro en esta parte: no
32+
// hay nada que un transporte pueda reinterpretar, y desplegado y repo salen
33+
// identicos byte a byte siempre. Mismo rango, misma semantica.
34+
const CM_MIN = 0x300;
35+
const CM_MAX = 0x36f;
36+
37+
// Quita los acentos: descompone (NFD) y descarta las marcas combinantes.
38+
// Equivalente exacto a .normalize("NFD").replace(/[U+0300-U+036F]/g, "").
39+
function sinAcentos(s: string): string {
40+
let out = "";
41+
for (const ch of s.normalize("NFD")) {
42+
const cp = ch.codePointAt(0) ?? 0;
43+
if (cp >= CM_MIN && cp <= CM_MAX) continue;
44+
out += ch;
45+
}
46+
return out;
47+
}
48+
2349
// Sanea un fragmento para ref de SKU de HubRise: alfanumérico + _ - : (evita
2450
// espacios/acentos/símbolos que puedan romper el ref o el parseo de HubRise).
2551
function sanitizeRefPart(s: string): string {
26-
// \u0300-\u036f = marcas diacriticas combinantes. Escritas con escapes y no
27-
// con los caracteres literales a proposito: son INVISIBLES en el editor y en
28-
// cualquier copia, y un despliegue que las pierda romperia el saneo de refs
29-
// sin que nadie lo viera. Mismo rango, misma semantica.
30-
//
31-
// OJO al comparar bytes con lo desplegado (19/08, v45): la copia que corre en
32-
// Supabase lleva los caracteres LITERALES, no estos escapes, porque el
33-
// desplegador va por JSON y decodifico los \uXXXX al mandarlos. Es la MISMA
34-
// expresion y es la forma que llevaba en produccion desde la v1; la unica
35-
// diferencia es de escritura. Se alineara en el proximo despliegue de esta
36-
// funcion. Si alguien diffea v45 contra el repo, este es el unico fichero
37-
// que sale distinto, y estas cuatro lineas mas.
38-
const COMBINING_MARKS = /[\u0300-\u036f]/g;
39-
return s
40-
.normalize("NFD").replace(COMBINING_MARKS, "") // quita acentos
52+
return sinAcentos(s)
4153
.toLowerCase()
4254
.replace(/[^a-z0-9_-]+/g, "-")
4355
.replace(/-+/g, "-")

supabase/functions/hubrise-catalog-publish/index.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -795,15 +795,55 @@ Deno.serve(async (req: Request) => {
795795
// catálogo de destino y los precios ya resueltos por local, que es lo que
796796
// hay que mirar antes de tocar un escaparate vivo.
797797
if (dryRun) {
798+
// Un precio propio por canal, con su base y su delta, listo para ensenar.
799+
const eurNum = (v: unknown): number => parseFloat(String(v ?? "0").split(" ")[0]) || 0;
800+
const cambiosDe = (prods: Array<Record<string, unknown>>): Array<Record<string, unknown>> => {
801+
const out: Array<Record<string, unknown>> = [];
802+
for (const p of prods) {
803+
const sku = ((p.skus as Array<Record<string, unknown>>) ?? [])[0] ?? {};
804+
const base = sku.price as string | undefined;
805+
for (const o of ((sku.price_overrides as Array<Record<string, unknown>>) ?? [])) {
806+
const b = eurNum(base);
807+
const n = eurNum(o.price);
808+
out.push({
809+
ref: p.ref,
810+
nombre: p.name,
811+
canales: (o.variant_refs as string[]) ?? [],
812+
base,
813+
se_publica: o.price,
814+
delta_pct: b === 0 ? null : Math.round(((n - b) / b) * 100),
815+
});
816+
}
817+
}
818+
// Mayor diferencia primero, calculada sobre el TOTAL y no sobre lo que
819+
// se acabe ensenando.
820+
out.sort((a, b) => Math.abs(Number(b.delta_pct ?? 0)) - Math.abs(Number(a.delta_pct ?? 0)));
821+
return out;
822+
};
798823
const preview = publishTargets.map((t) => {
799824
const prods = productsPayloadFor(t.locationId);
825+
const cambios = cambiosDe(prods);
800826
return {
801827
external_catalog_id: t.catalogId,
802828
connection_name: t.connName,
803829
location_id: t.locationId,
804830
productos: prods.length,
805-
// Muestra acotada: publicar entero son cientos de líneas y aquí lo
806-
// que importa es si el PRECIO es el del local o el de la marca.
831+
// ── LOS PRECIOS QUE CAMBIAN, TODOS Y CONTADOS (19/08) ───
832+
// Antes esto leia p.price y p.price_overrides. El precio NO vive en
833+
// el producto: vive en su SKU (p.skus[0], ver donde se monta arriba).
834+
// Los dos salian undefined SIEMPRE, para todas las marcas, asi que el
835+
// ensayo decia "sin precios propios" aunque hubiera trece. No era la
836+
// muestra: era el nivel equivocado, y ampliar la muestra no habria
837+
// arreglado nada.
838+
//
839+
// Ahora se devuelve el TOTAL y la lista COMPLETA, ordenada por la
840+
// diferencia mas grande primero. La pantalla decide cuantos ensena,
841+
// pero el numero y el orden salen del total: una pantalla de
842+
// confirmacion nunca describe una muestra.
843+
precios_propios_total: cambios.length,
844+
precios_propios: cambios,
845+
// Se conservan por compatibilidad con paneles que aun no hayan
846+
// recibido la OTA: leen `precios` y reventarian si desapareciera.
807847
precios: prods.slice(0, 25).map((p) => ({
808848
ref: p.ref,
809849
price: p.price,

0 commit comments

Comments
 (0)