fix(release): 0.2.8 harden admin proxy, cart stock caps, selfpay and refund timeline
- admin proxy: 25s timeout, body guards, structured failure logs - cart: addItem enforces stock cap (409 INSUFFICIENT_STOCK), UI clamps qty - tpv selfpay: hide sidebar/discounts/save-pending, rename button, receipt-settings 400 fix - pos admin: quick products slot count aligned to 8 - returns: human-readable history message + metadata jsonb (migrations 064-065) + admin fallback formatter - storefront: product card white background - product page: remove duplicate stock label under add-to-cart button
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0.2.4
|
0.2.8
|
||||||
|
|||||||
4
project/apps/admin/package-lock.json
generated
4
project/apps/admin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@mercadodevida/admin",
|
"name": "@mercadodevida/admin",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@mercadodevida/admin",
|
"name": "@mercadodevida/admin",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lexical/history": "^0.49.0",
|
"@lexical/history": "^0.49.0",
|
||||||
"@lexical/html": "^0.49.0",
|
"@lexical/html": "^0.49.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mercadodevida/admin",
|
"name": "@mercadodevida/admin",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3001",
|
"dev": "next dev --port 3001",
|
||||||
|
|||||||
@@ -78,6 +78,35 @@ function formatPrice(cents: number) {
|
|||||||
return `€${(cents / 100).toFixed(2)}`;
|
return `€${(cents / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F-138: legacy return events were stored as raw JSON. If the migration
|
||||||
|
* hasn't caught up (or didn't run on a particular row) we still want the UI
|
||||||
|
* to show something readable instead of dumping the payload. Falls back to
|
||||||
|
* the original string when the value isn't JSON.
|
||||||
|
*/
|
||||||
|
function formatRefundMessage(raw: string): string {
|
||||||
|
const trimmed = raw?.trim() ?? '';
|
||||||
|
if (!trimmed.startsWith('{')) return raw;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(trimmed) as {
|
||||||
|
refundedCents?: number;
|
||||||
|
status?: 'refund' | 'partial_refund';
|
||||||
|
lines?: Array<{ returnedQuantity?: number }>;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
const euros = ((payload.refundedCents ?? 0) / 100).toFixed(2);
|
||||||
|
const action = payload.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
|
||||||
|
const lineCount = Array.isArray(payload.lines)
|
||||||
|
? payload.lines.reduce((sum, line) => sum + (line.returnedQuantity ?? 0), 0)
|
||||||
|
: 0;
|
||||||
|
const reason = payload.reason?.trim();
|
||||||
|
const detail = lineCount > 0 ? ` (${lineCount} uds.)` : '';
|
||||||
|
return `${action} de ${euros} €${detail}${reason ? ` — ${reason}` : ''}`;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function OrderDetailPage() {
|
export default function OrderDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [order, setOrder] = useState<Order | null>(null);
|
const [order, setOrder] = useState<Order | null>(null);
|
||||||
@@ -710,6 +739,9 @@ export default function OrderDetailPage() {
|
|||||||
// ORDERS-FIX: detectar refunds para mostrar de forma más legible
|
// ORDERS-FIX: detectar refunds para mostrar de forma más legible
|
||||||
const isRefund = /refund|reembolso|devolu/i.test(event.message);
|
const isRefund = /refund|reembolso|devolu/i.test(event.message);
|
||||||
const isRefundEvent = event.eventType === 'REFUND' || isRefund;
|
const isRefundEvent = event.eventType === 'REFUND' || isRefund;
|
||||||
|
const formattedMessage = isRefundEvent
|
||||||
|
? formatRefundMessage(event.message)
|
||||||
|
: event.message;
|
||||||
return (
|
return (
|
||||||
<div key={event.id} className="flex gap-3">
|
<div key={event.id} className="flex gap-3">
|
||||||
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
|
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${
|
||||||
@@ -718,7 +750,7 @@ export default function OrderDetailPage() {
|
|||||||
}`} />
|
}`} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className={`text-sm break-words ${isRefundEvent ? 'text-pink-700 font-medium' : 'text-gray-800'}`}>
|
<p className={`text-sm break-words ${isRefundEvent ? 'text-pink-700 font-medium' : 'text-gray-800'}`}>
|
||||||
{isRefundEvent && <span className="mr-1">💸</span>}{event.message}
|
{isRefundEvent && <span className="mr-1">💸</span>}{formattedMessage}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{new Date(event.createdAt).toLocaleString('es-ES')}
|
{new Date(event.createdAt).toLocaleString('es-ES')}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export default function PosAdminPage() {
|
|||||||
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
|
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
|
||||||
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
|
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
|
||||||
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
const configured = terminal.settings?.quickProductVariantIds ?? [];
|
||||||
setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null));
|
setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
|
||||||
setTouchMessage('');
|
setTouchMessage('');
|
||||||
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
|
||||||
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
|
||||||
|
|||||||
@@ -2,126 +2,207 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
|
|
||||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||||
|
|
||||||
/**
|
const PROXY_TIMEOUT_MS = 25_000;
|
||||||
* Catch-all proxy: forwards ALL requests to the backend API.
|
const REQUEST_ID_HEADER = 'x-request-id';
|
||||||
* This avoids CORS preflight issues since requests stay within the
|
|
||||||
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
|
interface ProxyInit {
|
||||||
*
|
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||||
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
|
/** Raw body, or `null` when there is no body to forward. */
|
||||||
* so they are NOT served by this handler.
|
body: string | null;
|
||||||
*/
|
headers: Record<string, string>;
|
||||||
export async function GET(req: NextRequest) {
|
requestId: string;
|
||||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
|
||||||
const search = req.nextUrl.search;
|
|
||||||
const cookies = req.headers.get('cookie') ?? '';
|
|
||||||
try {
|
|
||||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
|
||||||
headers: { Cookie: cookies },
|
|
||||||
});
|
|
||||||
if (path === 'admin/logs/stream') {
|
|
||||||
// Pipe SSE directly: Next.js App Router supports ReadableStream passthrough.
|
|
||||||
// X-Accel-Buffering: no tells any intermediate proxy (nginx) not to buffer.
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.set('Content-Type', backendRes.headers.get('content-type') ?? 'text/event-stream');
|
|
||||||
headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
headers.set('X-Accel-Buffering', 'no');
|
|
||||||
// Do NOT set Connection: keep-alive — it is HTTP/1.1 default for persistent
|
|
||||||
// connections and can confuse proxies that do not expect streaming.
|
|
||||||
return new Response(backendRes.body, {
|
|
||||||
status: backendRes.status,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const data = await backendRes.json().catch(() => null);
|
|
||||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
|
||||||
return resp;
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
interface ProxyResult {
|
||||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
status: number;
|
||||||
const search = req.nextUrl.search;
|
headers: Headers;
|
||||||
const cookies = req.headers.get('cookie') ?? '';
|
body: ReadableStream | null;
|
||||||
const body = await req.text();
|
}
|
||||||
|
|
||||||
|
async function proxyToBackend(
|
||||||
|
request: NextRequest,
|
||||||
|
init: ProxyInit,
|
||||||
|
): Promise<ProxyResult> {
|
||||||
|
const path = request.nextUrl.pathname.replace('/api/', '');
|
||||||
|
const search = request.nextUrl.search;
|
||||||
|
const url = `${API}/${path}${search}`;
|
||||||
|
|
||||||
|
// Forward only the cookies the proxy knows are safe to relay. We deliberately
|
||||||
|
// do NOT pass `content-length` (Node fetch sets it from `body`), and we keep
|
||||||
|
// `content-type` only when we actually have a body to send — otherwise some
|
||||||
|
// upstreams reject the request or return 502 on bodyless PATCH/POST calls.
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Cookie: init.headers.Cookie ?? '',
|
||||||
|
[REQUEST_ID_HEADER]: init.requestId,
|
||||||
|
};
|
||||||
|
if (init.body !== null) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const headers: Record<string, string> = { Cookie: cookies };
|
const backendRes = await fetch(url, {
|
||||||
if (body) headers['Content-Type'] = 'application/json';
|
method: init.method,
|
||||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
headers,
|
||||||
...(body ? { body } : {}),
|
body: init.body,
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
const contentType = backendRes.headers.get('content-type');
|
||||||
|
if (contentType) responseHeaders.set('Content-Type', contentType);
|
||||||
const setCookie = backendRes.headers.get('set-cookie');
|
const setCookie = backendRes.headers.get('set-cookie');
|
||||||
const data = await backendRes.json().catch(() => null);
|
|
||||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
|
||||||
if (setCookie) {
|
if (setCookie) {
|
||||||
resp.headers.set(
|
responseHeaders.set(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
|
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return resp;
|
return {
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
|
||||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
|
||||||
const search = req.nextUrl.search;
|
|
||||||
const cookies = req.headers.get('cookie') ?? '';
|
|
||||||
const body = await req.text();
|
|
||||||
try {
|
|
||||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const data = await backendRes.json().catch(() => null);
|
|
||||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
|
||||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
|
||||||
const search = req.nextUrl.search;
|
|
||||||
const cookies = req.headers.get('cookie') ?? '';
|
|
||||||
const body = await req.text();
|
|
||||||
try {
|
|
||||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const data = await backendRes.json().catch(() => null);
|
|
||||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest) {
|
|
||||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
|
||||||
const search = req.nextUrl.search;
|
|
||||||
const cookies = req.headers.get('cookie') ?? '';
|
|
||||||
try {
|
|
||||||
const backendRes = await fetch(`${API}/${path}${search}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { Cookie: cookies },
|
|
||||||
});
|
|
||||||
// Reenviar la respuesta del backend tal cual: el backend puede devolver
|
|
||||||
// 204 No Content (sin body) en borrados exitosos, y forzar un JSON con
|
|
||||||
// status 204 es HTTP inválido (F-127).
|
|
||||||
return new Response(backendRes.body, {
|
|
||||||
status: backendRes.status,
|
status: backendRes.status,
|
||||||
statusText: backendRes.statusText,
|
headers: responseHeaders,
|
||||||
headers: { 'Content-Length': backendRes.headers.get('content-length') ?? '0' },
|
body: backendRes.body,
|
||||||
});
|
};
|
||||||
} catch {
|
} finally {
|
||||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the incoming request body safely. Returns `null` for bodyless methods
|
||||||
|
* (GET/DELETE) and an empty string when the body is empty.
|
||||||
|
*/
|
||||||
|
async function safeReadBody(request: NextRequest): Promise<string | null> {
|
||||||
|
if (request.method === 'GET' || request.method === 'DELETE' || request.method === 'HEAD') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await request.text();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[proxy] failed to read body', { message: (error as Error)?.message });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestId(request: NextRequest): string {
|
||||||
|
return (
|
||||||
|
request.headers.get(REQUEST_ID_HEADER) ??
|
||||||
|
request.headers.get('x-vercel-id') ??
|
||||||
|
`adm-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logProxyFailure(stage: string, request: NextRequest, requestId: string, error: unknown) {
|
||||||
|
console.warn('[proxy] failure', {
|
||||||
|
stage,
|
||||||
|
requestId,
|
||||||
|
method: request.method,
|
||||||
|
target: request.nextUrl.pathname,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
name: error instanceof Error ? error.name : 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const requestId = getRequestId(request);
|
||||||
|
try {
|
||||||
|
const result = await proxyToBackend(request, {
|
||||||
|
method: 'GET',
|
||||||
|
body: null,
|
||||||
|
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
if (request.nextUrl.pathname === 'admin/logs/stream') {
|
||||||
|
result.headers.set('Content-Type', result.headers.get('content-type') ?? 'text/event-stream');
|
||||||
|
result.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
result.headers.set('X-Accel-Buffering', 'no');
|
||||||
|
}
|
||||||
|
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||||
|
} catch (error) {
|
||||||
|
logProxyFailure('get', request, requestId, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestId = getRequestId(request);
|
||||||
|
try {
|
||||||
|
const body = await safeReadBody(request);
|
||||||
|
const result = await proxyToBackend(request, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||||
|
} catch (error) {
|
||||||
|
logProxyFailure('post', request, requestId, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const requestId = getRequestId(request);
|
||||||
|
try {
|
||||||
|
const body = await safeReadBody(request);
|
||||||
|
const result = await proxyToBackend(request, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||||
|
} catch (error) {
|
||||||
|
logProxyFailure('patch', request, requestId, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
const requestId = getRequestId(request);
|
||||||
|
try {
|
||||||
|
const body = await safeReadBody(request);
|
||||||
|
const result = await proxyToBackend(request, {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||||
|
} catch (error) {
|
||||||
|
logProxyFailure('put', request, requestId, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const requestId = getRequestId(request);
|
||||||
|
try {
|
||||||
|
const result = await proxyToBackend(request, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: null,
|
||||||
|
headers: { Cookie: request.headers.get('cookie') ?? '' },
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return new Response(result.body, { status: result.status, headers: result.headers });
|
||||||
|
} catch (error) {
|
||||||
|
logProxyFailure('delete', request, requestId, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: 'PROXY_ERROR', message: 'Proxy error', requestId } },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
project/apps/pos/package-lock.json
generated
4
project/apps/pos/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-pos",
|
"name": "mercadodevida-pos",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mercadodevida-pos",
|
"name": "mercadodevida-pos",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.3.1",
|
"next": "^16.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-pos",
|
"name": "mercadodevida-pos",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3002",
|
"dev": "next dev --port 3002",
|
||||||
|
|||||||
@@ -1659,14 +1659,16 @@ export default function RegisterPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
<button
|
{!isSelfpayMode && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void parkSale()}
|
type="button"
|
||||||
disabled={processing || cart.length === 0}
|
onClick={() => void parkSale()}
|
||||||
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
|
disabled={processing || cart.length === 0}
|
||||||
>
|
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
|
||||||
{processing ? 'Guardando…' : 'Guardar pendiente'}
|
>
|
||||||
</button>
|
{processing ? 'Guardando…' : 'Guardar pendiente'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void confirmSale()}
|
onClick={() => void confirmSale()}
|
||||||
@@ -1677,7 +1679,9 @@ export default function RegisterPage() {
|
|||||||
paidCents <= 0 ||
|
paidCents <= 0 ||
|
||||||
paidCents > totals.total
|
paidCents > totals.total
|
||||||
}
|
}
|
||||||
className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
|
className={`min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40 ${
|
||||||
|
isSelfpayMode ? 'col-span-2' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{processing ? 'Confirmando…' : 'Cobrar e imprimir'}
|
{processing ? 'Confirmando…' : 'Cobrar e imprimir'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1688,7 +1692,7 @@ export default function RegisterPage() {
|
|||||||
disabled={processing || cart.length === 0}
|
disabled={processing || cart.length === 0}
|
||||||
className="mt-2 w-full rounded-xl border border-gray-300 bg-white py-2 text-sm font-bold text-red-600 disabled:opacity-40"
|
className="mt-2 w-full rounded-xl border border-gray-300 bg-white py-2 text-sm font-bold text-red-600 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Vaciar caja
|
{isSelfpayMode ? '🧹 Limpiar Ticket' : 'Vaciar caja'}
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
4
project/frontend/package-lock.json
generated
4
project/frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.3.1",
|
"next": "16.3.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ export default async function ProductPage({ params }: Props) {
|
|||||||
priceCents={grossCents}
|
priceCents={grossCents}
|
||||||
imageUrl={product.images?.[0]?.url}
|
imageUrl={product.images?.[0]?.url}
|
||||||
available={true}
|
available={true}
|
||||||
|
availableQuantity={stock.availableQuantity}
|
||||||
minPurchaseQty={product.minPurchaseQty ?? 1}
|
minPurchaseQty={product.minPurchaseQty ?? 1}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useCart } from '@/contexts/CartContext';
|
import { useCart } from '@/contexts/CartContext';
|
||||||
|
import { fetchStockAvailability } from '@/lib/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variantId: string;
|
variantId: string;
|
||||||
@@ -10,24 +11,48 @@ interface Props {
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
available?: boolean;
|
available?: boolean;
|
||||||
minPurchaseQty?: number;
|
minPurchaseQty?: number;
|
||||||
|
/** F-138: max units available right now. Used to cap the add-to-cart action. */
|
||||||
|
availableQuantity?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddToCartButton({
|
export default function AddToCartButton({
|
||||||
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, className = '',
|
variantId, productId, productName, priceCents, imageUrl, available = true, minPurchaseQty = 1, availableQuantity, className = '',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { addItem, itemCount } = useCart();
|
const { addItem, items } = useCart();
|
||||||
const [added, setAdded] = useState(false);
|
const [added, setAdded] = useState(false);
|
||||||
|
const [liveStock, setLiveStock] = useState<number | null>(availableQuantity ?? null);
|
||||||
const qty = Math.max(1, minPurchaseQty);
|
const qty = Math.max(1, minPurchaseQty);
|
||||||
|
|
||||||
|
// Refresh stock so the button reflects reality (especially on stale tabs).
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchStockAvailability(variantId)
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) setLiveStock(s.availableQuantity);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLiveStock(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [variantId]);
|
||||||
|
|
||||||
|
// Account for whatever the user already has in the cart for this variant.
|
||||||
|
const inCart = items.find((it) => it.variantId === variantId)?.quantity ?? 0;
|
||||||
|
const stockCap = liveStock ?? null;
|
||||||
|
const remaining = stockCap === null ? null : Math.max(0, stockCap - inCart);
|
||||||
|
const disabled = !available || (remaining !== null && remaining < qty);
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
if (!available) return;
|
if (disabled) return;
|
||||||
addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty });
|
addItem({ variantId, productId, productName, quantity: qty, priceCents, imageUrl, minPurchaseQty: qty });
|
||||||
setAdded(true);
|
setAdded(true);
|
||||||
setTimeout(() => setAdded(false), 2000);
|
setTimeout(() => setAdded(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!available) {
|
if (!available || remaining === 0) {
|
||||||
return (
|
return (
|
||||||
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
|
<button disabled className={`px-8 py-3.5 bg-gray-200 text-gray-500 font-semibold rounded-xl cursor-not-allowed ${className}`}>
|
||||||
Agotado
|
Agotado
|
||||||
@@ -47,9 +72,10 @@ export default function AddToCartButton({
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg ${className}`}
|
disabled={disabled}
|
||||||
|
className={`px-8 py-3.5 bg-[#70ad47] hover:bg-[#5a9040] text-white font-semibold rounded-xl transition-colors shadow-lg disabled:bg-gray-300 disabled:cursor-not-allowed ${className}`}
|
||||||
>
|
>
|
||||||
Añadir al carrito{qty > 1 ? ` (${qty} uds.)` : ''}
|
{disabled && remaining !== null ? `Solo ${remaining} uds.` : `Añadir al carrito${qty > 1 ? ` (${qty} uds.)` : ''}`}
|
||||||
</button>
|
</button>
|
||||||
{qty > 1 && (
|
{qty > 1 && (
|
||||||
<p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p>
|
<p className="mt-2 text-xs text-gray-500">Compra mínima: {qty} unidades.</p>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useCart, type CartItem } from '@/contexts/CartContext';
|
import { useCart, type CartItem } from '@/contexts/CartContext';
|
||||||
|
import { fetchStockAvailability } from '@/lib/api';
|
||||||
|
|
||||||
function formatPrice(cents: number) {
|
function formatPrice(cents: number) {
|
||||||
return `€${(cents / 100).toFixed(2)}`;
|
return `€${(cents / 100).toFixed(2)}`;
|
||||||
@@ -9,6 +11,33 @@ function formatPrice(cents: number) {
|
|||||||
|
|
||||||
function CartItemRow({ item }: { item: CartItem }) {
|
function CartItemRow({ item }: { item: CartItem }) {
|
||||||
const { removeItem, changeQuantity } = useCart();
|
const { removeItem, changeQuantity } = useCart();
|
||||||
|
const [stockMax, setStockMax] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// F-138: cap the + button to actual stock so the buyer can never load more
|
||||||
|
// units than the SKU allows.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchStockAvailability(item.variantId)
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) setStockMax(s.availableQuantity);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setStockMax(null);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [item.variantId]);
|
||||||
|
|
||||||
|
const atStockMax = stockMax !== null && item.quantity >= stockMax;
|
||||||
|
const stockLabel =
|
||||||
|
stockMax === null
|
||||||
|
? null
|
||||||
|
: stockMax === 0
|
||||||
|
? 'Sin stock'
|
||||||
|
: stockMax <= 3
|
||||||
|
? `Quedan ${stockMax} uds.`
|
||||||
|
: `Stock: ${stockMax} uds.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
|
<div className="flex gap-4 py-4 border-b border-gray-100 last:border-0">
|
||||||
@@ -49,8 +78,18 @@ function CartItemRow({ item }: { item: CartItem }) {
|
|||||||
</button>
|
</button>
|
||||||
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
|
<span className="w-8 text-center text-sm font-medium">{item.quantity}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => changeQuantity(item.variantId, item.quantity + 1)}
|
onClick={() => {
|
||||||
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors"
|
if (stockMax === null) {
|
||||||
|
changeQuantity(item.variantId, item.quantity + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hard cap: never go above stockMax and never below 1.
|
||||||
|
const next = Math.min(stockMax, item.quantity + 1);
|
||||||
|
if (next > item.quantity) changeQuantity(item.variantId, next);
|
||||||
|
}}
|
||||||
|
disabled={atStockMax}
|
||||||
|
title={atStockMax ? `Solo hay ${stockMax} uds. disponibles` : undefined}
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-gray-600 hover:text-[#70ad47] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
@@ -62,6 +101,11 @@ function CartItemRow({ item }: { item: CartItem }) {
|
|||||||
Eliminar
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{stockLabel && (
|
||||||
|
<p className={`mt-1 text-xs ${atStockMax ? 'text-amber-600' : 'text-gray-500'}`}>
|
||||||
|
{stockLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtotal */}
|
{/* Subtotal */}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
|
||||||
import AddToCartButton from './AddToCartButton';
|
import AddToCartButton from './AddToCartButton';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,10 +8,12 @@ interface Props {
|
|||||||
priceCents: number;
|
priceCents: number;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
/** F-138: available stock for the variant. */
|
||||||
|
availableQuantity?: number;
|
||||||
minPurchaseQty?: number;
|
minPurchaseQty?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, minPurchaseQty }: Props) {
|
export default function ProductAddToCart({ variantId, productId, productName, priceCents, imageUrl, available, availableQuantity, minPurchaseQty }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<AddToCartButton
|
<AddToCartButton
|
||||||
@@ -22,6 +23,7 @@ export default function ProductAddToCart({ variantId, productId, productName, pr
|
|||||||
priceCents={priceCents}
|
priceCents={priceCents}
|
||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
available={available}
|
available={available}
|
||||||
|
availableQuantity={availableQuantity}
|
||||||
minPurchaseQty={minPurchaseQty}
|
minPurchaseQty={minPurchaseQty}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
/>
|
/>
|
||||||
|
|||||||
20
project/migrations/064_orders_history_metadata.js
Normal file
20
project/migrations/064_orders_history_metadata.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* F-138: orders_order_history.metadata holds the structured payload for
|
||||||
|
* machine-readable events (returns, refunds, etc.) so we can keep the
|
||||||
|
* human-visible `message` column short and friendly while preserving all the
|
||||||
|
* detail that internal tooling needs.
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE orders_order_history
|
||||||
|
ADD COLUMN IF NOT EXISTS metadata jsonb NULL
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
ALTER TABLE orders_order_history
|
||||||
|
DROP COLUMN IF EXISTS metadata
|
||||||
|
`);
|
||||||
|
};
|
||||||
65
project/migrations/065_backfill_return_history_messages.js
Normal file
65
project/migrations/065_backfill_return_history_messages.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* F-138 backfill: convert any legacy `orders_order_history` refund entries that
|
||||||
|
* were stored as raw JSON `message` strings into the new human-readable format
|
||||||
|
* introduced by `apply-pos-return.ts`. We also move the original payload into
|
||||||
|
* the new `metadata` column so internal tooling still has the structured data.
|
||||||
|
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||||
|
*/
|
||||||
|
export const up = (pgm) => {
|
||||||
|
pgm.sql(`
|
||||||
|
WITH parsed AS (
|
||||||
|
SELECT id,
|
||||||
|
(message::jsonb) AS payload,
|
||||||
|
(
|
||||||
|
CASE (message::jsonb->>'status')
|
||||||
|
WHEN 'refund' THEN 'Devolución total'
|
||||||
|
ELSE 'Devolución parcial'
|
||||||
|
END
|
||||||
|
) AS action
|
||||||
|
FROM orders_order_history
|
||||||
|
WHERE event_type = 'RETURNED'
|
||||||
|
AND message ~ '^\\s*\\{'
|
||||||
|
),
|
||||||
|
formatted AS (
|
||||||
|
SELECT id,
|
||||||
|
action || ' de ' ||
|
||||||
|
(round(((payload->>'refundedCents')::numeric) / 100)::numeric(12,2)::text) ||
|
||||||
|
' €' ||
|
||||||
|
COALESCE(
|
||||||
|
(SELECT ' — ' || string_agg(
|
||||||
|
((l->>'returnedQuantity')::int)::text || ' × ' ||
|
||||||
|
COALESCE(oi.name, 'Artículo'),
|
||||||
|
', '
|
||||||
|
ORDER BY ord)
|
||||||
|
FROM jsonb_array_elements(payload->'lines') WITH ORDINALITY AS x(l, ord)
|
||||||
|
LEFT JOIN orders_items oi ON oi.id = (l->>'orderItemId')::uuid
|
||||||
|
WHERE (l->>'returnedQuantity')::int > 0
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) ||
|
||||||
|
CASE
|
||||||
|
WHEN NULLIF(payload->>'reason', '') IS NOT NULL
|
||||||
|
THEN ' (motivo: ' || (payload->>'reason') || ')'
|
||||||
|
ELSE ''
|
||||||
|
END AS readable
|
||||||
|
FROM parsed
|
||||||
|
)
|
||||||
|
UPDATE orders_order_history h
|
||||||
|
SET message = f.readable,
|
||||||
|
metadata = h.message::jsonb
|
||||||
|
FROM formatted f
|
||||||
|
WHERE h.id = f.id
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const down = (pgm) => {
|
||||||
|
// Best-effort: restore the JSON message from metadata. We can't know whether
|
||||||
|
// a row was originally a JSON message or already human-readable, so the
|
||||||
|
// down migration just clears the column.
|
||||||
|
pgm.sql(`
|
||||||
|
UPDATE orders_order_history
|
||||||
|
SET message = metadata::text,
|
||||||
|
metadata = NULL
|
||||||
|
WHERE event_type = 'RETURNED' AND metadata IS NOT NULL
|
||||||
|
`);
|
||||||
|
};
|
||||||
4
project/package-lock.json
generated
4
project/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-backend",
|
"name": "mercadodevida-backend",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mercadodevida-backend",
|
"name": "mercadodevida-backend",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.1.2",
|
"@fastify/cookie": "^11.1.2",
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-backend",
|
"name": "mercadodevida-backend",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "mercadodevida vNext backend - modular monolith skeleton",
|
"description": "mercadodevida vNext backend - modular monolith skeleton",
|
||||||
|
|||||||
@@ -105,28 +105,52 @@ describe.skipIf(!hasDb)('cart flows (real PostgreSQL)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flags cart item unavailable when variant is out of stock (AC2)', async () => {
|
it('rejects add when stock is zero (AC2 — F-138)', async () => {
|
||||||
const productId = randomUUID();
|
const productId = randomUUID();
|
||||||
const variantId = randomUUID();
|
const variantId = randomUUID();
|
||||||
await setPrice(variantId, 500);
|
await setPrice(variantId, 500);
|
||||||
await setStock(variantId, 0);
|
await setStock(variantId, 0);
|
||||||
await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/cart/items',
|
url: '/cart/items',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||||
payload: { productId, variantId, quantity: 1 },
|
payload: { productId, variantId, quantity: 1 },
|
||||||
});
|
});
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
|
||||||
|
|
||||||
const cart = await app.inject({
|
const cart = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/cart',
|
url: '/cart',
|
||||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||||
});
|
});
|
||||||
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find(
|
const items = cart.json().items as Array<{ variantId: string }>;
|
||||||
(entry) => entry.variantId === variantId,
|
expect(items.find((entry) => entry.variantId === variantId)).toBeUndefined();
|
||||||
);
|
});
|
||||||
expect(item).toMatchObject({ available: false });
|
|
||||||
|
it('rejects add when total quantity exceeds stock (AC2b — F-138)', async () => {
|
||||||
|
const productId = randomUUID();
|
||||||
|
const variantId = randomUUID();
|
||||||
|
await setPrice(variantId, 500);
|
||||||
|
await setStock(variantId, 3);
|
||||||
|
const first = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/cart/items',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||||
|
payload: { productId, variantId, quantity: 2 },
|
||||||
|
});
|
||||||
|
expect(first.statusCode).toBe(201);
|
||||||
|
const oversell = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/cart/items',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||||
|
payload: { productId, variantId, quantity: 2 },
|
||||||
|
});
|
||||||
|
expect(oversell.statusCode).toBe(409);
|
||||||
|
expect(oversell.json()).toMatchObject({ error: { code: 'INSUFFICIENT_STOCK' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores client-supplied price fields in cart payloads (AC3)', async () => {
|
it('ignores client-supplied price fields in cart payloads (AC3)', async () => {
|
||||||
|
|||||||
@@ -19,8 +19,16 @@ export class CartService {
|
|||||||
|
|
||||||
async addItem(userId: string, input: CartItemInput): Promise<CartView> {
|
async addItem(userId: string, input: CartItemInput): Promise<CartView> {
|
||||||
ensurePositiveQuantity(input.quantity);
|
ensurePositiveQuantity(input.quantity);
|
||||||
// Keep unavailable lines visible in the cart so the customer can remove or
|
// F-138: stock cap is enforced on every cart mutation. The previous
|
||||||
// replace them; checkout remains the authoritative stock gate.
|
// behaviour ("keep unavailable lines visible so the buyer can remove
|
||||||
|
// them") allowed overselling in the online storefront: customers could
|
||||||
|
// load 50 units of a 3-unit SKU into the cart and only failed at checkout.
|
||||||
|
// Cap the resulting quantity to whatever is actually in stock and surface
|
||||||
|
// the available amount to the caller via the standard error.
|
||||||
|
const cart = await this.carts.getOrCreate(userId);
|
||||||
|
const existing = cart.items.find((item) => item.variantId === input.variantId);
|
||||||
|
const requested = (existing?.quantity ?? 0) + input.quantity;
|
||||||
|
await this.assertStockAvailable(input.variantId, requested);
|
||||||
return this.toView(await this.carts.addItem(userId, input));
|
return this.toView(await this.carts.addItem(userId, input));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,31 @@ async function assertReceiptAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves which store the admin POS settings page should target.
|
||||||
|
*
|
||||||
|
* Accepts the explicit `storeId` query value when present and well-formed;
|
||||||
|
* otherwise falls back to the first active store. This keeps the admin
|
||||||
|
* dashboard recoverable when the user lands on the page before the store
|
||||||
|
* dropdown is hydrated or the dropdown value is somehow invalid (F-139).
|
||||||
|
*/
|
||||||
|
async function resolveStoreIdForReceipt(
|
||||||
|
pool: pg.Pool,
|
||||||
|
rawStoreId: string | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
if (rawStoreId && storeIdSchema.safeParse(rawStoreId).success) {
|
||||||
|
return rawStoreId;
|
||||||
|
}
|
||||||
|
const fallback = await pool.query<{ id: string }>(
|
||||||
|
`SELECT id FROM pos_stores WHERE active = true ORDER BY created_at ASC LIMIT 1`,
|
||||||
|
);
|
||||||
|
const id = fallback.rows[0]?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new AppError(404, 'NO_ACTIVE_STORE', 'No hay tiendas activas configuradas');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
|
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
|
||||||
const { pool, authenticate } = deps;
|
const { pool, authenticate } = deps;
|
||||||
|
|
||||||
@@ -1208,8 +1233,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
summary: 'Get company and receipt numbering settings',
|
summary: 'Get company and receipt numbering settings',
|
||||||
querystring: {
|
querystring: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['storeId'],
|
properties: { storeId: { type: 'string' } },
|
||||||
properties: { storeId: { type: 'string', format: 'uuid' } },
|
|
||||||
},
|
},
|
||||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||||
} as FastifySchema,
|
} as FastifySchema,
|
||||||
@@ -1217,7 +1241,11 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const user = await authenticate(request);
|
const user = await authenticate(request);
|
||||||
requireRole(user, 'admin');
|
requireRole(user, 'admin');
|
||||||
const { storeId } = parseJson(z.object({ storeId: storeIdSchema }), request.query);
|
const rawStoreId = (request.query as { storeId?: string }).storeId;
|
||||||
|
// F-139: the admin POS page can land before the store dropdown has a
|
||||||
|
// selection. Fall back to the first active store so the UI never sees a
|
||||||
|
// 400 caused by an empty querystring.
|
||||||
|
const storeId = await resolveStoreIdForReceipt(pool, rawStoreId);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
|
`INSERT INTO pos_receipt_settings (store_id) VALUES ($1)
|
||||||
ON CONFLICT (store_id) DO NOTHING`,
|
ON CONFLICT (store_id) DO NOTHING`,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface OrderRow {
|
|||||||
|
|
||||||
interface OrderItemRow {
|
interface OrderItemRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
returned_quantity: number;
|
returned_quantity: number;
|
||||||
is_free_item: boolean;
|
is_free_item: boolean;
|
||||||
@@ -131,7 +132,7 @@ export class ApplyPosReturnUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemRows = await client.query<OrderItemRow>(
|
const itemRows = await client.query<OrderItemRow>(
|
||||||
`SELECT id, quantity, returned_quantity, is_free_item, variant_id,
|
`SELECT id, name, quantity, returned_quantity, is_free_item, variant_id,
|
||||||
unit_price_cents, discount_cents, tax_cents
|
unit_price_cents, discount_cents, tax_cents
|
||||||
FROM orders_items
|
FROM orders_items
|
||||||
WHERE order_id = $1
|
WHERE order_id = $1
|
||||||
@@ -315,10 +316,18 @@ export class ApplyPosReturnUseCase {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email)
|
`INSERT INTO orders_order_history (order_id, event_type, message, actor_email, metadata)
|
||||||
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3))`,
|
VALUES ($1, 'RETURNED', $2, (SELECT email FROM backoffice_users WHERE id = $3), $4)`,
|
||||||
[
|
[
|
||||||
input.orderId,
|
input.orderId,
|
||||||
|
formatReturnMessage({
|
||||||
|
refundedCents: totalRefundCents,
|
||||||
|
status: reportingStatus,
|
||||||
|
lines: input.lines,
|
||||||
|
reason: input.reason,
|
||||||
|
items: itemsById,
|
||||||
|
}),
|
||||||
|
input.userId,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
returnId,
|
returnId,
|
||||||
refundedCents: totalRefundCents,
|
refundedCents: totalRefundCents,
|
||||||
@@ -326,7 +335,6 @@ export class ApplyPosReturnUseCase {
|
|||||||
lines: input.lines,
|
lines: input.lines,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
}),
|
}),
|
||||||
input.userId,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -366,3 +374,38 @@ export class ApplyPosReturnUseCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormatReturnMessageInput {
|
||||||
|
refundedCents: number;
|
||||||
|
status: 'refund' | 'partial_refund';
|
||||||
|
lines: ReadonlyArray<{ orderItemId: string; returnedQuantity: number }>;
|
||||||
|
reason: string;
|
||||||
|
items: ReadonlyMap<string, OrderItemRow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a refund event as a short, human-readable line for the order
|
||||||
|
* timeline. We deliberately do NOT dump the raw payload here: customers and
|
||||||
|
* back-office staff see `event.message` and the JSON was unreadable.
|
||||||
|
* Structured fields are still stored on `orders_order_history.metadata` so
|
||||||
|
* support tooling can read them when needed.
|
||||||
|
*/
|
||||||
|
function formatReturnMessage(input: FormatReturnMessageInput): string {
|
||||||
|
const euros = (input.refundedCents / 100).toFixed(2);
|
||||||
|
const action = input.status === 'refund' ? 'Devolución total' : 'Devolución parcial';
|
||||||
|
const lineDescriptions = input.lines
|
||||||
|
.filter((line) => line.returnedQuantity > 0)
|
||||||
|
.map((line) => {
|
||||||
|
const item = input.items.get(line.orderItemId);
|
||||||
|
const name = item?.name ?? 'Artículo';
|
||||||
|
return `${line.returnedQuantity} × ${name}`;
|
||||||
|
});
|
||||||
|
const linesSummary =
|
||||||
|
lineDescriptions.length === 0
|
||||||
|
? ''
|
||||||
|
: ` — ${lineDescriptions.slice(0, 3).join(', ')}${
|
||||||
|
lineDescriptions.length > 3 ? ` y ${lineDescriptions.length - 3} más` : ''
|
||||||
|
}`;
|
||||||
|
const reason = input.reason?.trim();
|
||||||
|
return `${action} de ${euros} €${linesSummary}${reason ? ` (motivo: ${reason})` : ''}`;
|
||||||
|
}
|
||||||
|
|||||||
4
project/storefront/package-lock.json
generated
4
project/storefront/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-storefront",
|
"name": "mercadodevida-storefront",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mercadodevida-storefront",
|
"name": "mercadodevida-storefront",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
"next": "^16.0.5",
|
"next": "^16.0.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mercadodevida-storefront",
|
"name": "mercadodevida-storefront",
|
||||||
"version": "0.2.4",
|
"version": "0.2.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "mercadodevida customer storefront shell",
|
"description": "mercadodevida customer storefront shell",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
|
|||||||
const existing = items.find((it) => it.variantId === productId);
|
const existing = items.find((it) => it.variantId === productId);
|
||||||
const newQty = existing ? existing.quantity + qty : qty;
|
const newQty = existing ? existing.quantity + qty : qty;
|
||||||
if (newQty > stock) {
|
if (newQty > stock) {
|
||||||
setError(`Solo hay ${stock} unidades disponibles.`);
|
setError(`Solo hay ${stock} unidades disponibles en total (ya tienes ${existing?.quantity ?? 0} en el carrito).`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = existing
|
const next = existing
|
||||||
@@ -93,7 +93,16 @@ export function AddToCart({ productId, productName, unitPriceCents, imageUrl }:
|
|||||||
min={1}
|
min={1}
|
||||||
max={stock}
|
max={stock}
|
||||||
value={qty}
|
value={qty}
|
||||||
onChange={(e) => setQty(Math.max(1, parseInt(e.target.value, 10) || 1))}
|
onChange={(e) => {
|
||||||
|
const parsed = parseInt(e.target.value, 10);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
setQty(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// F-138: never let the buyer enter a quantity outside [1, stock].
|
||||||
|
const clamped = Math.max(1, Math.min(stock, parsed));
|
||||||
|
setQty(clamped);
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="w-24 rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-700 outline-none disabled:opacity-50"
|
className="w-24 rounded-lg border border-stone-300 px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-700 outline-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function ProductCard({ product }: Readonly<{ product: ProductSummaryDto }
|
|||||||
href={product.url}
|
href={product.url}
|
||||||
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
|
className="group block overflow-hidden rounded-3xl border border-emerald-900/10 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-700"
|
||||||
>
|
>
|
||||||
<div className="flex w-full max-h-72 items-center justify-center bg-emerald-50 text-sm text-emerald-900 overflow-hidden">
|
<div className="flex w-full max-h-72 items-center justify-center bg-white text-sm text-emerald-900 overflow-hidden">
|
||||||
{mainImage ? (
|
{mainImage ? (
|
||||||
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
|
// Keep plain img for remote/local URL compatibility until image pipeline configuration exists.
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
# Estado Actual
|
# Estado Actual
|
||||||
|
|
||||||
## Ninguna feature activa
|
## Sesión anterior completada: 2026-08-25
|
||||||
|
|
||||||
**Última feature cerrada:** `FRONTEND-UI-FIXES2` (ya estaba `done` en backlog)
|
### Features cerradas
|
||||||
|
- TPV-FIXES (3/3 bugs) ✅
|
||||||
|
- TICKET-LOGO ✅
|
||||||
|
- ORDERS-FIX ✅
|
||||||
|
|
||||||
Para iniciar una nueva feature:
|
### Pendientes
|
||||||
1. Seleccionar una feature `pending` de `backlog/features.json`
|
- **INVENTORY-OPT**: Inventory optimization (pagination, queries, filters for 10k+ products)
|
||||||
2. Actualizar estado con `python3 scripts/agent_status.py set --feature-id <id> --stage intake --agent leader`
|
- **SHIPPING-ZONES**: Restrict Balearic and Canary islands, add continental...
|
||||||
3. Continuar con `harness/workflow.stages.yml`
|
|
||||||
|
|
||||||
---
|
### Issue pendiente de resolver
|
||||||
|
- **TPV stock**: Tema de stock con items convertidos a free items necesita revisión más profunda
|
||||||
|
|
||||||
## Resumen backlog
|
## Para iniciar próxima sesión
|
||||||
- Total features: 344
|
```bash
|
||||||
- Features pendientes de iniciar: verificar con `rg '"status": "pending"' backlog/features.json | wc -l`
|
./scripts/pi_orquestra.sh
|
||||||
|
```
|
||||||
|
Seleccionar INVENTORY-OPT o SHIPPING-ZONES del backlog.
|
||||||
|
|||||||
@@ -1,152 +1,152 @@
|
|||||||
{
|
{
|
||||||
"feature_id": "TPV-FIXES",
|
"feature_id": "POS-RECEIPT-QUICK-FIXES",
|
||||||
"stage": "review_gate",
|
"stage": "build",
|
||||||
"agent": "reviewer",
|
"agent": "implementer",
|
||||||
"action": "Fixing favicon 404, cashier label, and pos/sales 400 error",
|
"action": "Fix receipt-settings 400 and quick products save",
|
||||||
"state": "done",
|
"state": "running",
|
||||||
"next_agent": null,
|
"next_agent": null,
|
||||||
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
"waiting_for": "Seleccionar una feature pending y actualizar este estado",
|
||||||
"updated_at": "2026-08-25T04:47:13Z",
|
"updated_at": "2026-08-25T21:03:29Z",
|
||||||
"timeline": [
|
"timeline": [
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:27:58Z",
|
"ts": "2026-08-25T19:46:39Z",
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "idle",
|
|
||||||
"message": "Estado actualizado"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:28:09Z",
|
|
||||||
"agent": "implementer",
|
|
||||||
"stage": "build",
|
|
||||||
"state": "idle",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:28:15Z",
|
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Rebuild dist for awaiting payment notification fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:30:08Z",
|
"ts": "2026-08-25T19:52:55Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "done",
|
"state": "done",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Redeploy local prod stack after awaiting-payment fix"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:30:24Z",
|
"ts": "2026-08-25T19:53:50Z",
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:30:35Z",
|
|
||||||
"agent": "security",
|
|
||||||
"stage": "security_gate",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:30:44Z",
|
|
||||||
"agent": "qa",
|
|
||||||
"stage": "qa_gate",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:31:04Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "intake",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:31:58Z",
|
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Fix monolith restart to stop old deployments before starting latest"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:35:08Z",
|
"ts": "2026-08-25T19:59:53Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "done",
|
"state": "done",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Fix monolith restart to remove old deployments and run latest version"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:35:09Z",
|
"ts": "2026-08-25T20:00:40Z",
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:35:18Z",
|
|
||||||
"agent": "security",
|
|
||||||
"stage": "security_gate",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:35:26Z",
|
|
||||||
"agent": "qa",
|
|
||||||
"stage": "qa_gate",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:35:43Z",
|
|
||||||
"agent": "leader",
|
|
||||||
"stage": "intake",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:37:19Z",
|
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Add semantic versioning and conventional commit enforcement to Orquestra"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:37:57Z",
|
"ts": "2026-08-25T20:04:01Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "done",
|
"state": "done",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Add semantic versioning and conventional commit enforcement to Orquestra"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:37:57Z",
|
"ts": "2026-08-25T20:04:58Z",
|
||||||
"agent": "reviewer",
|
|
||||||
"stage": "review_gate",
|
|
||||||
"state": "done",
|
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ts": "2026-08-25T04:45:38Z",
|
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "running",
|
"state": "running",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Add settings about section with service versions and server info"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:47:13Z",
|
"ts": "2026-08-25T20:08:32Z",
|
||||||
"agent": "implementer",
|
"agent": "implementer",
|
||||||
"stage": "build",
|
"stage": "build",
|
||||||
"state": "done",
|
"state": "done",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Add settings about section with service versions and server info"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ts": "2026-08-25T04:47:13Z",
|
"ts": "2026-08-25T20:12:55Z",
|
||||||
"agent": "reviewer",
|
"agent": "implementer",
|
||||||
"stage": "review_gate",
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:16:24Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
"state": "done",
|
"state": "done",
|
||||||
"message": "Fixing favicon 404, cashier label, and pos/sales 400 error"
|
"message": "Fix admin 500 errors for awaiting-payment and POS cash-close reports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:20:00Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Fix reporting trend chart height and closures report display"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:24:31Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "done",
|
||||||
|
"message": "Fix reporting trend chart height and closures report display"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:26:16Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:30:04Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "done",
|
||||||
|
"message": "Hide TPV self-checkout menu/discounts and fix trend text distortion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:36:53Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Remove duplicate ticket print header and force white print background"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:39:16Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "done",
|
||||||
|
"message": "Remove duplicate ticket print header and force white print background"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:39:16Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Diagnose PATCH /api/products/[id] 502 Bad Gateway"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:50:29Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T20:54:10Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Fix storefront card bg, stock cap, return human readable, PATCH 502, selfpay button rename"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-08-25T21:03:29Z",
|
||||||
|
"agent": "implementer",
|
||||||
|
"stage": "build",
|
||||||
|
"state": "running",
|
||||||
|
"message": "Fix receipt-settings 400 and quick products save"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user