diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 49632e96..9fd7115e 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -250,6 +250,11 @@ index.ts → routes → controllers → services → repositories → models outside production, the production and Vercel domains — plus `FRONTEND_URL`. Credentials enabled. It is also the CSRF defence: any other origin gets a 403 before reaching a route. +- **Dates (ADR-047)**: the process and every MySQL session run in UTC; + timestamps are UTC instants. The hotel's "today" and "now" come from + `config/date-utils.ts` and `repositories/shared/hotel-clock.ts` + (`sqlHotelToday`, `sqlHotelNow`, `sqlMadridDay`), never from `CURDATE()`, + `NOW()`, `DATE(col)` or `toISOString()`. - **Cron jobs**: `services/cron/cron-service.ts`, started from `index.ts`. The times are in `cron-schedule.ts` (Europe/Madrid). Render's free plan sleeps, so on every start the jobs whose period is due and not done run, and diff --git a/backend/config/date-utils.ts b/backend/config/date-utils.ts index ddffc2c9..bae230be 100644 --- a/backend/config/date-utils.ts +++ b/backend/config/date-utils.ts @@ -74,6 +74,27 @@ export const getLastDayOfMonth = (year: number, month: number): string => { .format('YYYY-MM-DD') } +// ── Hotel wall-clock times (ADR-047) ───────────────────────────────────────── +// Parking check-in and check-out times are the hotel's clock: "12:00" is noon in +// Madrid, stored as 12:00:00. mysql2 reads such a DATETIME as a Date whose UTC +// fields are the stored value, so these helpers never go through the local zone. + +/** The hotel's current wall-clock time, as a Date to store in such a column */ +export const getHotelNowDate = (): Date => + new Date(`${getNowMadrid().format('YYYY-MM-DDTHH:mm:ss')}Z`) + +/** A wall-clock time typed by the user ('YYYY-MM-DD HH:mm' or 'YYYY-MM-DDTHH:mm'), as stored */ +export const parseHotelTime = (value: string): Date => dayjs.utc(value).toDate() + +/** A stored wall-clock time for the API: no Z, so the browser reads it as local time */ +export function toHotelTime(value: Date | string): string +export function toHotelTime(value: Date | string | null | undefined): string | null +export function toHotelTime(value: Date | string | null | undefined): string | null { + if (value === null || value === undefined) return null + const date = value instanceof Date ? value : parseHotelTime(value) + return Number.isNaN(date.getTime()) ? null : date.toISOString().slice(0, 19) +} + export default { getTodayMadrid, getNowMadrid, diff --git a/backend/config/db-config.ts b/backend/config/db-config.ts index 71ca80bb..8883ae47 100644 --- a/backend/config/db-config.ts +++ b/backend/config/db-config.ts @@ -82,4 +82,6 @@ if (environment === 'aiven' && (!config.user || !Number.isInteger(config.port))) throw new Error('❌ Faltan AIVEN_DB_USER o AIVEN_DB_PORT en .env') } -export const dbConfig = config +// mysql2 reads and writes DATETIME and DATE in UTC, whatever the machine's zone +// (ADR-047); config/db.ts sets the MySQL session to UTC too +export const dbConfig = { ...config, timezone: 'Z' } diff --git a/backend/config/db.ts b/backend/config/db.ts index fa1c0cff..79d6bcb8 100644 --- a/backend/config/db.ts +++ b/backend/config/db.ts @@ -25,6 +25,17 @@ logger.info( // ======================================== const pool: Pool = mysql.createPool(config) // ← MODIFICADO (añadido tipo) +// NOW(), CURDATE() and DEFAULT CURRENT_TIMESTAMP in UTC on every connection, as on +// Aiven (ADR-047). The event hands over the callback connection, not the promise one +pool.on('connection', (connection) => { + const raw = connection as unknown as { + query: (sql: string, callback: (err: Error | null) => void) => void + } + raw.query("SET time_zone = '+00:00'", (err) => { + if (err) logger.error({ err }, 'No se pudo fijar la zona horaria UTC en la sesión MySQL') + }) +}) + // Verify connection at startup with retries (Aiven can timeout on cold start) async function connectWithRetry(retries = 3, delayMs = 3000): Promise { for (let attempt = 1; attempt <= retries; attempt++) { diff --git a/backend/config/timezone.ts b/backend/config/timezone.ts new file mode 100644 index 00000000..5ce35f8a --- /dev/null +++ b/backend/config/timezone.ts @@ -0,0 +1,6 @@ +// config/timezone.ts +// The backend runs in UTC on every machine, as it does on Render (ADR-047). +// Imported first by index.ts: Node reads TZ when it next builds a Date. Business +// dates ("today", the hotel day) come from config/date-utils.ts, in Madrid. + +process.env.TZ = 'UTC' diff --git a/backend/controllers/activity/activity-controller.ts b/backend/controllers/activity/activity-controller.ts index 2b0cedb5..c1d3fc53 100644 --- a/backend/controllers/activity/activity-controller.ts +++ b/backend/controllers/activity/activity-controller.ts @@ -15,6 +15,7 @@ import { mergeByTimestamp, } from '../../services/activity/activity-access.js' import { logger } from '../../config/logger.js' +import { getTodayMadrid } from '../../config/date-utils.js' import { isCalendarDate } from '../../validations/common/calendar-date.js' export class ActivityController { @@ -81,7 +82,7 @@ export class ActivityController { const fetchActivity = (src?: ActivitySource) => { if (dateFrom || dateTo) { - const to = dateTo || new Date().toISOString().slice(0, 10) + const to = dateTo || getTodayMadrid() let from = dateFrom if (!from) { const d = new Date(to) diff --git a/backend/controllers/backoffice/backoffice-controller.ts b/backend/controllers/backoffice/backoffice-controller.ts index 6a5faac0..400bdc4f 100644 --- a/backend/controllers/backoffice/backoffice-controller.ts +++ b/backend/controllers/backoffice/backoffice-controller.ts @@ -1548,7 +1548,7 @@ export class BackofficeController { const archive = archiver('zip', { zlib: { level: 5 } }) // Set response headers - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getTodayMadrid().replace(/-/g, '') res.setHeader('Content-Type', 'application/zip') res.setHeader( 'Content-Disposition', diff --git a/backend/controllers/cashier/cashier-history-controller.ts b/backend/controllers/cashier/cashier-history-controller.ts index 0ad9472c..487009d1 100644 --- a/backend/controllers/cashier/cashier-history-controller.ts +++ b/backend/controllers/cashier/cashier-history-controller.ts @@ -12,6 +12,7 @@ import { recentHistoryQuerySchema, validationError, } from '../../validations/cashier/cashier-validation.js' +import { sqlMadridDay } from '../../repositories/shared/hotel-clock.js' export class CashierHistoryController { /** @@ -94,8 +95,8 @@ export class CashierHistoryController { COUNT(*) as count FROM cashier_history WHERE 1=1 - ${from_date ? 'AND DATE(changed_at) >= ?' : ''} - ${to_date ? 'AND DATE(changed_at) <= ?' : ''} + ${from_date ? `AND ${sqlMadridDay('changed_at')} >= ?` : ''} + ${to_date ? `AND ${sqlMadridDay('changed_at')} <= ?` : ''} GROUP BY action ORDER BY count DESC ` @@ -113,8 +114,8 @@ export class CashierHistoryController { COUNT(*) as actions_count FROM cashier_history WHERE changed_by IS NOT NULL - ${from_date ? 'AND DATE(changed_at) >= ?' : ''} - ${to_date ? 'AND DATE(changed_at) <= ?' : ''} + ${from_date ? `AND ${sqlMadridDay('changed_at')} >= ?` : ''} + ${to_date ? `AND ${sqlMadridDay('changed_at')} <= ?` : ''} GROUP BY changed_by ORDER BY actions_count DESC LIMIT 10 diff --git a/backend/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts index f9b04251..f11b3524 100644 --- a/backend/controllers/parking/bookings.controller.ts +++ b/backend/controllers/parking/bookings.controller.ts @@ -9,7 +9,7 @@ import { createBookingSchema, updateBookingBodySchema, } from '../../validations/parking/booking-validation.js' -import { getNowMadrid } from '../../config/date-utils.js' +import { getHotelNowDate, parseHotelTime } from '../../config/date-utils.js' import { ERROR_CODES, SUCCESS_CODES } from '../../config/error-codes.js' import type { BookingFilters, BookingSource, UpdateBookingDTO } from '../../models/parking/index.js' import { logger } from '../../config/logger.js' @@ -298,7 +298,7 @@ class ParkingBookingsController { // Convertir code → id const id = await this._getBookingIdFromCode(code) - const checkinDate = actual_checkin ? new Date(actual_checkin) : getNowMadrid().toDate() + const checkinDate = actual_checkin ? parseHotelTime(actual_checkin) : getHotelNowDate() if (actual_checkin && isNaN(checkinDate.getTime())) { res.status(400).json({ @@ -396,7 +396,7 @@ class ParkingBookingsController { const id = await this._getBookingIdFromCode(code) - const checkoutDate = actual_checkout ? new Date(actual_checkout) : getNowMadrid().toDate() + const checkoutDate = actual_checkout ? parseHotelTime(actual_checkout) : getHotelNowDate() if (actual_checkout && isNaN(checkoutDate.getTime())) { res.status(400).json({ @@ -679,11 +679,11 @@ class ParkingBookingsController { // Validar fechas si se proporcionan if (expected_checkin || expected_checkout) { const checkin = expected_checkin - ? new Date(expected_checkin) - : new Date(existingBooking.schedule.expected_checkin) + ? parseHotelTime(expected_checkin) + : parseHotelTime(existingBooking.schedule.expected_checkin) const checkout = expected_checkout - ? new Date(expected_checkout) - : new Date(existingBooking.schedule.expected_checkout) + ? parseHotelTime(expected_checkout) + : parseHotelTime(existingBooking.schedule.expected_checkout) if (isNaN(checkin.getTime()) || isNaN(checkout.getTime())) { res.status(400).json({ @@ -820,8 +820,8 @@ class ParkingBookingsController { return } - const now = getNowMadrid().toDate() - const checkin = new Date(booking.schedule.expected_checkin) + const now = getHotelNowDate() + const checkin = parseHotelTime(booking.schedule.expected_checkin) if (now >= checkin) { res.status(400).json({ diff --git a/backend/db-mysql/INDEX.md b/backend/db-mysql/INDEX.md index 525ede19..dc0a05db 100644 --- a/backend/db-mysql/INDEX.md +++ b/backend/db-mysql/INDEX.md @@ -117,6 +117,7 @@ db-mysql/ | 2026-10-11 | `20261011_keep_group_history_on_delete.sql` | Quita la clave foránea `group_history_ibfk_1` (`ON DELETE CASCADE` a `hotel_groups`): borrar un grupo ya no borra su historial, y el borrado deja una fila `deleted` con la foto del grupo. **Aplicar en Aiven antes de desplegar el código**: con la clave dentro, borrar un grupo falla. | ✅ local · ✅ Aiven | | 2026-10-12 | `20261012_unique_invoice_supplier_number.sql` | Añade `UNIQUE KEY uk_invoice_supplier_number (supplier_id, invoice_number)` a `bo_invoices`: la misma factura de un proveedor se podía registrar dos veces y se pagaban las dos en el lote mensual. El backend responde 409 `BACKOFFICE_INVOICE_DUPLICATE`. Antes del `ALTER` lista las duplicadas; si hay alguna, el `ALTER` falla y hay que corregirlas. Idempotente. El código funciona sin ella (no rechaza nada hasta que exista). | ✅ local · ✅ Aiven | | 2026-10-14 | `20261014_cron_catch_up.sql` | Crea el usuario inactivo `system-cron` (el pago automático del día 10 firmaba con él y no existía: fallaba la clave foránea de `updated_by`) y la tabla `cron_job_runs`, con el último periodo de cada tarea programada para ponerlas al día al arrancar sin repetirlas (ADR-046). Idempotente. **Aplicar en Aiven antes de desplegar el código**: sin la tabla las tareas no se ejecutan. | ✅ local · ✅ Aiven | +| 2026-10-14 | `20261014_booking_code_hotel_date.sql` | `trg_generate_booking_code` toma la fecha de `PK-AAAAMMDD-NNNN` en hora de Madrid (`CONVERT_TZ` de `UTC_TIMESTAMP()`): con `NOW()` en UTC, una reserva entre las 00:00 y las 02:00 llevaba la fecha de ayer (ADR-047). Sin zonas con nombre (MySQL local) usa la fecha UTC, como antes. Idempotente. | ✅ local · ✅ Aiven | ### Verificación (99) diff --git a/backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql b/backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql new file mode 100644 index 00000000..7565cabd --- /dev/null +++ b/backend/db-mysql/scripts/20261014_booking_code_hotel_date.sql @@ -0,0 +1,54 @@ +-- ============================================================================= +-- 20261014_booking_code_hotel_date.sql +-- ============================================================================= +-- trg_generate_booking_code took the date of PK-YYYYMMDD-NNNN from NOW(), which +-- is UTC on Aiven and in every session (ADR-047): a booking made between 00:00 +-- and 02:00 in Madrid got yesterday's code. It now converts UTC_TIMESTAMP() +-- (UTC whatever the session zone) to Madrid. +-- Where MySQL has no named time zones loaded (a local install), CONVERT_TZ +-- gives NULL and the code falls back to the UTC date, as before. +-- Only the date changes; the numbering is the same. +-- +-- Idempotent: DROP TRIGGER IF EXISTS + CREATE. +-- +-- Applies to: LOCAL + AIVEN. The code works with either version. +-- Rollback: recreate it with DATE_FORMAT(NOW(), '%Y%m%d') +-- (aiven/05_parking_functions_triggers.sql). +-- ============================================================================= + +DROP TRIGGER IF EXISTS trg_generate_booking_code; + +DELIMITER $$ + +CREATE TRIGGER trg_generate_booking_code +BEFORE INSERT ON parking_bookings +FOR EACH ROW +BEGIN + DECLARE next_number INT; + DECLARE date_part VARCHAR(8); + + -- Formato: PK-YYYYMMDD-0001, con la fecha de Madrid + SET date_part = DATE_FORMAT( + COALESCE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', 'Europe/Madrid'), UTC_TIMESTAMP()), + '%Y%m%d' + ); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(booking_code, -4) AS UNSIGNED) + ), 0) + 1 + INTO next_number + FROM parking_bookings + WHERE booking_code LIKE CONCAT('PK-', date_part, '-%'); + + SET NEW.booking_code = CONCAT( + 'PK-', + date_part, + '-', + LPAD(next_number, 4, '0') + ); +END$$ + +DELIMITER ; + +SELECT DATE_FORMAT(COALESCE(CONVERT_TZ(UTC_TIMESTAMP(), '+00:00', 'Europe/Madrid'), UTC_TIMESTAMP()), '%Y%m%d') + AS booking_code_date_now; diff --git a/backend/index.ts b/backend/index.ts index ee8b27db..03cbc357 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -1,5 +1,6 @@ // index.ts +import './config/timezone.js' // first: the process runs in UTC (ADR-047) import express, { Request, Response, NextFunction } from 'express' import cookieParser from 'cookie-parser' import helmet from 'helmet' diff --git a/backend/models/maintenance/index.ts b/backend/models/maintenance/index.ts index f42683c7..e3b69963 100644 --- a/backend/models/maintenance/index.ts +++ b/backend/models/maintenance/index.ts @@ -106,6 +106,9 @@ export interface MaintenanceHistory { changed_at: string // Campos adicionales (JOINs) user_name?: string + // Usernames when the change is an assignment (old_value/new_value are user ids) + old_value_name?: string + new_value_name?: string } // ======================================== diff --git a/backend/models/parking/index.ts b/backend/models/parking/index.ts index efe8a9b8..4d126a5e 100644 --- a/backend/models/parking/index.ts +++ b/backend/models/parking/index.ts @@ -300,10 +300,11 @@ export interface FormattedOperator { } export interface FormattedSchedule { - expected_checkin: Date - expected_checkout: Date - actual_checkin: Date | null - actual_checkout: Date | null + // Hotel wall-clock times without Z: YYYY-MM-DDTHH:mm:ss (ADR-047) + expected_checkin: string + expected_checkout: string + actual_checkin: string | null + actual_checkout: string | null planned_days: number actual_days: number | null } @@ -356,8 +357,8 @@ export interface FormattedOverdueBooking { owner: string | null model: string | null } - actual_checkin: Date - expected_checkout: Date + actual_checkin: string + expected_checkout: string horas_retraso: number status: BookingStatus notes: string | null @@ -413,8 +414,8 @@ export interface FormattedPendingCheckin { model: string | null } | null schedule: { - expected_checkin: Date - expected_checkout: Date + expected_checkin: string + expected_checkout: string days: number } booking_info: { @@ -438,9 +439,9 @@ export interface FormattedPendingCheckout { model: string | null } | null schedule: { - expected_checkin: Date - actual_checkin: Date | null - expected_checkout: Date + expected_checkin: string + actual_checkin: string | null + expected_checkout: string planned_days: number actual_days: number is_overdue: boolean diff --git a/backend/repositories/activity/activity-repository.ts b/backend/repositories/activity/activity-repository.ts index 375feeed..d36408ba 100644 --- a/backend/repositories/activity/activity-repository.ts +++ b/backend/repositories/activity/activity-repository.ts @@ -6,6 +6,7 @@ import db from '../../config/db.js' import { RowDataPacket } from 'mysql2' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ======================================== // TYPES @@ -235,7 +236,7 @@ export class ActivityRepository { CAST(ch.shift_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id FROM cashier_history ch LEFT JOIN users u1 ON ch.changed_by = u1.id - WHERE DATE(ch.changed_at) = ? + WHERE ${sqlMadridDay('ch.changed_at')} = ? UNION ALL @@ -249,7 +250,7 @@ export class ActivityRepository { CAST(gh.group_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id FROM group_history gh LEFT JOIN users u2 ON gh.changed_by = u2.id - WHERE DATE(gh.changed_at) = ? + WHERE ${sqlMadridDay('gh.changed_at')} = ? UNION ALL @@ -263,7 +264,7 @@ export class ActivityRepository { CAST(lh.logbook_id AS CHAR) COLLATE utf8mb4_0900_ai_ci as record_id FROM logbook_history lh LEFT JOIN users u3 ON lh.editor_id = u3.id - WHERE DATE(lh.created_at) = ? + WHERE ${sqlMadridDay('lh.created_at')} = ? UNION ALL @@ -277,7 +278,7 @@ export class ActivityRepository { mh.report_id COLLATE utf8mb4_0900_ai_ci as record_id FROM maintenance_history mh LEFT JOIN users u4 ON mh.changed_by = u4.id - WHERE DATE(mh.changed_at) = ? + WHERE ${sqlMadridDay('mh.changed_at')} = ? ) AS combined ORDER BY timestamp DESC LIMIT ? diff --git a/backend/repositories/backoffice/backoffice-repository.ts b/backend/repositories/backoffice/backoffice-repository.ts index d62a29d6..f4b0c3ff 100644 --- a/backend/repositories/backoffice/backoffice-repository.ts +++ b/backend/repositories/backoffice/backoffice-repository.ts @@ -24,6 +24,7 @@ import { Asset, SummaryStats, } from '../../models/backoffice/index.js' +import { sqlHotelToday } from '../shared/hotel-clock.js' import { likeContains } from '../shared/like.js' // Re-exportar tipos para mantener compatibilidad @@ -960,7 +961,7 @@ export class BackofficeRepository { `SELECT COUNT(*) as count FROM bo_invoices WHERE status IN ('pending', 'validated') AND is_deleted = 0 - AND due_date < CURDATE()` + AND due_date < ${sqlHotelToday()}` ) const [suppliers] = await pool.query( diff --git a/backend/repositories/cashier/cashier-history-repository.ts b/backend/repositories/cashier/cashier-history-repository.ts index f801d35a..f01102a4 100644 --- a/backend/repositories/cashier/cashier-history-repository.ts +++ b/backend/repositories/cashier/cashier-history-repository.ts @@ -4,6 +4,7 @@ import db from '../../config/db.js' import { SORT_FIELDS, safeSort, safeOrder } from '../../validations/cashier/cashier-validation.js' import { ResultSetHeader, RowDataPacket } from 'mysql2' import { CashierHistory, CreateHistoryDTO, HistoryFilters } from '../../models/cashier/index.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' export class CashierHistoryRepository { /** @@ -94,12 +95,12 @@ export class CashierHistoryRepository { } if (filters.from_date) { - query += ' AND DATE(changed_at) >= ?' + query += ` AND ${sqlMadridDay('changed_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(changed_at) <= ?' + query += ` AND ${sqlMadridDay('changed_at')} <= ?` params.push(filters.to_date) } @@ -148,12 +149,12 @@ export class CashierHistoryRepository { } if (filters.from_date) { - query += ' AND DATE(changed_at) >= ?' + query += ` AND ${sqlMadridDay('changed_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(changed_at) <= ?' + query += ` AND ${sqlMadridDay('changed_at')} <= ?` params.push(filters.to_date) } diff --git a/backend/repositories/cashier/cashier-voucher-repository.ts b/backend/repositories/cashier/cashier-voucher-repository.ts index 4dd944c1..78419862 100644 --- a/backend/repositories/cashier/cashier-voucher-repository.ts +++ b/backend/repositories/cashier/cashier-voucher-repository.ts @@ -9,6 +9,7 @@ import { UpdateVoucherDTO, VoucherFilters, } from '../../models/cashier/index.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' export class CashierVoucherRepository { /** @@ -51,12 +52,12 @@ export class CashierVoucherRepository { } if (filters.from_date) { - query += ' AND DATE(v.created_at) >= ?' + query += ` AND ${sqlMadridDay('v.created_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(v.created_at) <= ?' + query += ` AND ${sqlMadridDay('v.created_at')} <= ?` params.push(filters.to_date) } @@ -115,12 +116,12 @@ export class CashierVoucherRepository { } if (filters.from_date) { - query += ' AND DATE(v.created_at) >= ?' + query += ` AND ${sqlMadridDay('v.created_at')} >= ?` params.push(filters.from_date) } if (filters.to_date) { - query += ' AND DATE(v.created_at) <= ?' + query += ` AND ${sqlMadridDay('v.created_at')} <= ?` params.push(filters.to_date) } @@ -429,12 +430,12 @@ export class CashierVoucherRepository { const params: (string | number)[] = [] if (fromDate) { - query += ' AND DATE(created_at) >= ?' + query += ` AND ${sqlMadridDay('created_at')} >= ?` params.push(fromDate) } if (toDate) { - query += ' AND DATE(created_at) <= ?' + query += ` AND ${sqlMadridDay('created_at')} <= ?` params.push(toDate) } diff --git a/backend/repositories/demo/demo-activity-repository.ts b/backend/repositories/demo/demo-activity-repository.ts index 2c44fc0c..24cf3f98 100644 --- a/backend/repositories/demo/demo-activity-repository.ts +++ b/backend/repositories/demo/demo-activity-repository.ts @@ -7,6 +7,7 @@ import db from '../../config/db.js' import { RowDataPacket, ResultSetHeader } from 'mysql2' import { likeContains } from '../shared/like.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ======================================== // TYPES @@ -133,12 +134,12 @@ export class DemoActivityRepository { } if (filters?.startDate) { - whereClause += ' AND DATE(timestamp) >= ?' + whereClause += ` AND ${sqlMadridDay('timestamp')} >= ?` params.push(filters.startDate) } if (filters?.endDate) { - whereClause += ' AND DATE(timestamp) <= ?' + whereClause += ` AND ${sqlMadridDay('timestamp')} <= ?` params.push(filters.endDate) } @@ -213,10 +214,10 @@ export class DemoActivityRepository { // Activity by day const [daysResult] = await db.query( - `SELECT DATE(timestamp) as date, COUNT(*) as count + `SELECT ${sqlMadridDay('timestamp')} as date, COUNT(*) as count FROM demo_activity_log WHERE timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) - GROUP BY DATE(timestamp) + GROUP BY ${sqlMadridDay('timestamp')} ORDER BY date DESC`, [days] ) diff --git a/backend/repositories/group/group-payment-repository.ts b/backend/repositories/group/group-payment-repository.ts index bf00891e..71e44a64 100644 --- a/backend/repositories/group/group-payment-repository.ts +++ b/backend/repositories/group/group-payment-repository.ts @@ -10,6 +10,7 @@ import { PaymentsSummary, } from '../../models/group/index' import { ResultSetHeader, RowDataPacket } from 'mysql2' +import { sqlHotelToday } from '../shared/hotel-clock.js' import { buildSetClause } from '../shared/update-columns.js' export class GroupPaymentRepository { @@ -199,11 +200,11 @@ export class GroupPaymentRepository { gp.*, g.name as group_name, g.agency, - DATEDIFF(gp.due_date, CURDATE()) as days_until_due + DATEDIFF(gp.due_date, ${sqlHotelToday()}) as days_until_due FROM group_payments gp INNER JOIN hotel_groups g ON gp.group_id = g.id WHERE gp.status IN ('pending', 'requested', 'partial') - AND gp.due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND gp.due_date BETWEEN ${sqlHotelToday()} AND DATE_ADD(${sqlHotelToday()}, INTERVAL ? DAY) ORDER BY gp.due_date ASC ` @@ -220,11 +221,11 @@ export class GroupPaymentRepository { gp.*, g.name as group_name, g.agency, - DATEDIFF(CURDATE(), gp.due_date) as days_overdue + DATEDIFF(${sqlHotelToday()}, gp.due_date) as days_overdue FROM group_payments gp INNER JOIN hotel_groups g ON gp.group_id = g.id WHERE gp.status IN ('pending', 'requested', 'partial') - AND gp.due_date < CURDATE() + AND gp.due_date < ${sqlHotelToday()} ORDER BY gp.due_date ASC ` diff --git a/backend/repositories/group/group-repository.ts b/backend/repositories/group/group-repository.ts index 13bfa0f2..995600ad 100644 --- a/backend/repositories/group/group-repository.ts +++ b/backend/repositories/group/group-repository.ts @@ -24,6 +24,7 @@ import { import { ResultSetHeader } from 'mysql2' import { buildSetClause } from '../shared/update-columns.js' import { GROUP_SORT_FIELDS } from '../../validations/group/group-schemas.js' +import { sqlHotelToday } from '../shared/hotel-clock.js' import { likeContains } from '../shared/like.js' export class GroupRepository { @@ -354,11 +355,11 @@ export class GroupRepository { g.*, gs.rooming_status, gs.rooming_received_date, - DATEDIFF(g.arrival_date, CURDATE()) as days_until_arrival + DATEDIFF(g.arrival_date, ${sqlHotelToday()}) as days_until_arrival FROM hotel_groups g LEFT JOIN group_status gs ON g.id = gs.group_id WHERE g.status IN ('confirmed', 'in_progress') - AND g.arrival_date = DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND g.arrival_date = DATE_ADD(${sqlHotelToday()}, INTERVAL ? DAY) ORDER BY g.arrival_date ASC ` @@ -375,12 +376,12 @@ export class GroupRepository { SELECT g.*, gs.rooming_status, - DATEDIFF(g.arrival_date, CURDATE()) as days_until_arrival + DATEDIFF(g.arrival_date, ${sqlHotelToday()}) as days_until_arrival FROM hotel_groups g LEFT JOIN group_status gs ON g.id = gs.group_id WHERE g.status IN ('confirmed', 'in_progress') AND (gs.rooming_status IS NULL OR gs.rooming_status != 'received') - AND g.arrival_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND g.arrival_date BETWEEN ${sqlHotelToday()} AND DATE_ADD(${sqlHotelToday()}, INTERVAL ? DAY) ORDER BY g.arrival_date ASC ` diff --git a/backend/repositories/logbook/logbook-repository.ts b/backend/repositories/logbook/logbook-repository.ts index 0447c32b..abed4f87 100644 --- a/backend/repositories/logbook/logbook-repository.ts +++ b/backend/repositories/logbook/logbook-repository.ts @@ -10,6 +10,7 @@ import type { CreatedLogbook, ResultSetHeader, } from '../../models/logbook/index.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ============================================ // PAGINATION DEFAULTS @@ -133,12 +134,12 @@ export async function getLogbooksFiltered( } if (options.date_from) { - conditions.push('(l.date >= ? OR (l.date IS NULL AND DATE(l.created_at) >= ?))') + conditions.push(`(l.date >= ? OR (l.date IS NULL AND ${sqlMadridDay('l.created_at')} >= ?))`) params.push(options.date_from, options.date_from) } if (options.date_to) { - conditions.push('(l.date <= ? OR (l.date IS NULL AND DATE(l.created_at) <= ?))') + conditions.push(`(l.date <= ? OR (l.date IS NULL AND ${sqlMadridDay('l.created_at')} <= ?))`) params.push(options.date_to, options.date_to) } @@ -249,7 +250,7 @@ export async function getLogbooksByDay( FROM logbooks l LEFT JOIN users u ON l.author_id = u.id LEFT JOIN departments d ON l.department_id = d.id - WHERE (l.date = ? OR (l.date IS NULL AND DATE(l.created_at) = ?)) + WHERE (l.date = ? OR (l.date IS NULL AND ${sqlMadridDay('l.created_at')} = ?)) AND l.deleted_at IS NULL ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, diff --git a/backend/repositories/maintenance/maintenance-repository.ts b/backend/repositories/maintenance/maintenance-repository.ts index af3b1a9b..d2a7c080 100644 --- a/backend/repositories/maintenance/maintenance-repository.ts +++ b/backend/repositories/maintenance/maintenance-repository.ts @@ -19,7 +19,9 @@ import type { HistoryAction, AddImageInput, } from '../../models/maintenance/index.js' +import { getNowMadrid } from '../../config/date-utils.js' import { likeContains } from '../shared/like.js' +import { sqlMadridDay } from '../shared/hotel-clock.js' // ======================================== // INTERFACES INTERNAS (para tipado de queries) @@ -72,6 +74,8 @@ interface ImageRow extends RowDataPacket { } interface HistoryRow extends RowDataPacket { + old_value_name?: string | null + new_value_name?: string | null id: number report_id: string action: string @@ -103,11 +107,8 @@ export const MAX_REPORT_IMAGES = 5 * Ejemplo: 120625-001, 120625-002, etc. */ async function generateReportId(): Promise { - const now = new Date() - const day = String(now.getDate()).padStart(2, '0') - const month = String(now.getMonth() + 1).padStart(2, '0') - const year = String(now.getFullYear()).slice(-2) - const datePrefix = `${day}${month}${year}` + // The hotel day in Madrid: in UTC a report at 00:30 would carry yesterday (ADR-047) + const datePrefix = getNowMadrid().format('DDMMYY') // Buscar el último número del día const [rows] = await dbx().query( @@ -227,6 +228,8 @@ function parseHistoryRow(row: HistoryRow): MaintenanceHistory { changed_at: typeof row.changed_at === 'string' ? row.changed_at : new Date(row.changed_at).toISOString(), user_name: row.user_name || undefined, + old_value_name: row.old_value_name || undefined, + new_value_name: row.new_value_name || undefined, } } @@ -322,17 +325,17 @@ export class MaintenanceRepository { // Filtro por fecha específica (single day) if (date) { - conditions.push(`DATE(r.report_date) = ?`) + conditions.push(`${sqlMadridDay('r.report_date')} = ?`) whereParams.push(date) } // Filtro por rango de fechas (inclusivo en ambos extremos) if (date_from && !date) { - conditions.push(`DATE(r.report_date) >= ?`) + conditions.push(`${sqlMadridDay('r.report_date')} >= ?`) whereParams.push(date_from) } if (date_to && !date) { - conditions.push(`DATE(r.report_date) <= ?`) + conditions.push(`${sqlMadridDay('r.report_date')} <= ?`) whereParams.push(date_to) } @@ -936,9 +939,13 @@ export class MaintenanceRepository { */ static async getHistoryByReportId(reportId: string): Promise { const query = ` - SELECT h.*, u.username as user_name + SELECT h.*, u.username as user_name, + ou.username AS old_value_name, nu.username AS new_value_name FROM maintenance_history h LEFT JOIN users u ON h.changed_by = u.id + -- An assignment logs user ids: their names, for the screen + LEFT JOIN users ou ON h.field_changed = 'assigned_to' AND ou.id = h.old_value + LEFT JOIN users nu ON h.field_changed = 'assigned_to' AND nu.id = h.new_value WHERE h.report_id = ? ORDER BY h.changed_at DESC ` diff --git a/backend/repositories/parking/bookings.repository.ts b/backend/repositories/parking/bookings.repository.ts index d2d56325..4451c288 100644 --- a/backend/repositories/parking/bookings.repository.ts +++ b/backend/repositories/parking/bookings.repository.ts @@ -20,6 +20,8 @@ import type { BookingStatus, } from '../../models/parking/index.js' import { likeContains } from '../shared/like.js' +import { sqlHotelNow, sqlHotelToday } from '../shared/hotel-clock.js' +import { getHotelNowDate, toHotelTime } from '../../config/date-utils.js' interface CountRow extends RowDataPacket { total: number @@ -202,7 +204,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -279,7 +281,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -384,7 +386,10 @@ class ParkingBookingsRepository { // ============================================ // CHECK-IN // ============================================ - async checkIn(id: number, actual_checkin: Date = new Date()): Promise { + async checkIn( + id: number, + actual_checkin: Date = getHotelNowDate() + ): Promise { const connection = await pool.getConnection() try { @@ -414,7 +419,7 @@ class ParkingBookingsRepository { WHERE spot_id = ? AND id != ? AND status = 'checked_in' - AND (actual_checkout IS NULL OR actual_checkout > NOW()) + AND (actual_checkout IS NULL OR actual_checkout > ${sqlHotelNow()}) FOR SHARE`, [booking[0].spot_id, id] ) @@ -444,7 +449,7 @@ class ParkingBookingsRepository { // ============================================ async checkOut(id: number, checkoutData: CheckoutDTO): Promise { const { - actual_checkout = new Date(), + actual_checkout = getHotelNowDate(), payment_amount = null, payment_method = null, payment_reference = null, @@ -777,7 +782,7 @@ class ParkingBookingsRepository { switch (quickFilter) { case 'arrivals_pending': // Llegadas en espera: reserved + entrada hoy - whereClause = `WHERE b.status = 'reserved' AND DATE(b.expected_checkin) = CURDATE()` + whereClause = `WHERE b.status = 'reserved' AND DATE(b.expected_checkin) = ${sqlHotelToday()}` break case 'arrivals_inside': @@ -789,25 +794,25 @@ class ParkingBookingsRepository { // Total llegadas: reserved entrada hoy + checked_in whereClause = `WHERE ( b.status = 'checked_in' - OR (b.status = 'reserved' AND DATE(b.expected_checkin) = CURDATE()) + OR (b.status = 'reserved' AND DATE(b.expected_checkin) = ${sqlHotelToday()}) )` break case 'departures_pending': // Salidas en espera: checked_in + salida hoy - whereClause = `WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) = CURDATE()` + whereClause = `WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) = ${sqlHotelToday()}` break case 'departures_completed': // Completadas hoy (por fecha de checkout real) - whereClause = `WHERE b.status = 'completed' AND DATE(b.actual_checkout) = CURDATE()` + whereClause = `WHERE b.status = 'completed' AND DATE(b.actual_checkout) = ${sqlHotelToday()}` break case 'departures_total': // Total salidas: checked_in salida hoy + completed hoy whereClause = `WHERE ( - (b.status = 'checked_in' AND DATE(b.expected_checkout) = CURDATE()) - OR (b.status = 'completed' AND DATE(b.actual_checkout) = CURDATE()) + (b.status = 'checked_in' AND DATE(b.expected_checkout) = ${sqlHotelToday()}) + OR (b.status = 'completed' AND DATE(b.actual_checkout) = ${sqlHotelToday()}) )` break @@ -866,7 +871,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -982,7 +987,7 @@ class ParkingBookingsRepository { WHEN b.actual_checkin IS NOT NULL AND b.actual_checkout IS NOT NULL THEN DATEDIFF(DATE(b.actual_checkout), DATE(b.actual_checkin)) WHEN b.actual_checkin IS NOT NULL - THEN DATEDIFF(CURDATE(), DATE(b.actual_checkin)) + THEN DATEDIFF(${sqlHotelToday()}, DATE(b.actual_checkin)) ELSE NULL END AS actual_days FROM parking_bookings b @@ -1028,7 +1033,7 @@ class ParkingBookingsRepository { b.actual_checkin, b.expected_checkout, b.status, - TIMESTAMPDIFF(HOUR, b.expected_checkout, NOW()) AS horas_retraso, + TIMESTAMPDIFF(HOUR, b.expected_checkout, ${sqlHotelNow()}) AS horas_retraso, b.notes, b.created_at, b.updated_at @@ -1037,7 +1042,7 @@ class ParkingBookingsRepository { LEFT JOIN parking_vehicles v ON b.vehicle_id = v.id WHERE b.status = 'checked_in' AND b.actual_checkout IS NULL - AND b.expected_checkout < NOW() + AND b.expected_checkout < ${sqlHotelNow()} ORDER BY b.expected_checkout ASC ` @@ -1077,10 +1082,10 @@ class ParkingBookingsRepository { } : null, schedule: { - expected_checkin: row.expected_checkin, - expected_checkout: row.expected_checkout, - actual_checkin: row.actual_checkin, - actual_checkout: row.actual_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + expected_checkout: toHotelTime(row.expected_checkout), + actual_checkin: toHotelTime(row.actual_checkin), + actual_checkout: toHotelTime(row.actual_checkout), planned_days: row.planned_days, actual_days: row.actual_days, }, @@ -1139,8 +1144,8 @@ class ParkingBookingsRepository { owner: row.owner_name, model: row.vehicle_model, }, - actual_checkin: row.actual_checkin, - expected_checkout: row.expected_checkout, + actual_checkin: toHotelTime(row.actual_checkin), + expected_checkout: toHotelTime(row.expected_checkout), horas_retraso: row.horas_retraso, status: row.status, notes: row.notes, diff --git a/backend/repositories/parking/stats.repository.ts b/backend/repositories/parking/stats.repository.ts index 2cc8db89..d1d1a2a5 100644 --- a/backend/repositories/parking/stats.repository.ts +++ b/backend/repositories/parking/stats.repository.ts @@ -3,7 +3,8 @@ // ============================================ import pool from '../../config/db.js' import { RowDataPacket } from 'mysql2/promise' -import { formatDateMadrid } from '../../config/date-utils.js' +import { formatDateMadrid, toHotelTime } from '../../config/date-utils.js' +import { sqlHotelNow, sqlMadridDay } from '../shared/hotel-clock.js' import type { DailyStatsRow, OccupancyByLevelRow, @@ -100,14 +101,14 @@ class ParkingStatsRepository { (SELECT COUNT(*) FROM parking_bookings WHERE status = 'canceled' - AND DATE(updated_at) = ? + AND ${sqlMadridDay('updated_at')} = ? ) AS canceled_today, -- No-shows hoy (SELECT COUNT(*) FROM parking_bookings WHERE status = 'no_show' - AND DATE(updated_at) = ? + AND ${sqlMadridDay('updated_at')} = ? ) AS no_shows_today ` @@ -320,8 +321,8 @@ class ParkingStatsRepository { } : null, schedule: { - expected_checkin: row.expected_checkin, - expected_checkout: row.expected_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + expected_checkout: toHotelTime(row.expected_checkout), days: row.days, }, booking_info: { @@ -366,7 +367,7 @@ class ParkingStatsRepository { b.payment_method, b.notes, CASE - WHEN NOW() > b.expected_checkout THEN 1 + WHEN ${sqlHotelNow()} > b.expected_checkout THEN 1 ELSE 0 END AS is_overdue FROM parking_bookings b @@ -375,7 +376,7 @@ class ParkingStatsRepository { WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) BETWEEN ? AND ? ORDER BY - CASE WHEN NOW() > b.expected_checkout THEN 0 ELSE 1 END, + CASE WHEN ${sqlHotelNow()} > b.expected_checkout THEN 0 ELSE 1 END, b.expected_checkout, ps.level_code, ps.spot_number @@ -405,7 +406,7 @@ class ParkingStatsRepository { b.payment_method, b.notes, CASE - WHEN NOW() > b.expected_checkout THEN 1 + WHEN ${sqlHotelNow()} > b.expected_checkout THEN 1 ELSE 0 END AS is_overdue FROM parking_bookings b @@ -414,7 +415,7 @@ class ParkingStatsRepository { WHERE b.status = 'checked_in' AND DATE(b.expected_checkout) = ? ORDER BY - CASE WHEN NOW() > b.expected_checkout THEN 0 ELSE 1 END, + CASE WHEN ${sqlHotelNow()} > b.expected_checkout THEN 0 ELSE 1 END, b.expected_checkout, ps.level_code, ps.spot_number @@ -439,9 +440,9 @@ class ParkingStatsRepository { } : null, schedule: { - expected_checkin: row.expected_checkin, - actual_checkin: row.actual_checkin, - expected_checkout: row.expected_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + actual_checkin: toHotelTime(row.actual_checkin), + expected_checkout: toHotelTime(row.expected_checkout), planned_days: row.planned_days, actual_days: row.actual_days, is_overdue: row.is_overdue === 1, @@ -621,14 +622,14 @@ class ParkingStatsRepository { (SELECT COUNT(*) FROM parking_bookings WHERE status = 'canceled' - AND DATE(updated_at) BETWEEN ? AND ? + AND ${sqlMadridDay('updated_at')} BETWEEN ? AND ? ) AS canceled_in_range, -- No-shows en el rango (SELECT COUNT(*) FROM parking_bookings WHERE status = 'no_show' - AND DATE(updated_at) BETWEEN ? AND ? + AND ${sqlMadridDay('updated_at')} BETWEEN ? AND ? ) AS no_shows_in_range ` diff --git a/backend/repositories/search/search-repository.ts b/backend/repositories/search/search-repository.ts index f8d3fee2..97955e0b 100644 --- a/backend/repositories/search/search-repository.ts +++ b/backend/repositories/search/search-repository.ts @@ -7,6 +7,7 @@ import db from '../../config/db.js' import type { RowDataPacket } from 'mysql2' import { type SearchModule } from '../../services/search/search-access.js' +import { toHotelTime } from '../../config/date-utils.js' import { likeContains } from '../shared/like.js' // ======================================== @@ -133,8 +134,8 @@ async function searchParking(query: string): Promise { plate_number: row.plate_number, owner_name: row.owner_name, status: row.status, - expected_checkin: row.expected_checkin, - expected_checkout: row.expected_checkout, + expected_checkin: toHotelTime(row.expected_checkin), + expected_checkout: toHotelTime(row.expected_checkout), })) } diff --git a/backend/repositories/shared/hotel-clock.ts b/backend/repositories/shared/hotel-clock.ts new file mode 100644 index 00000000..0de0e224 --- /dev/null +++ b/backend/repositories/shared/hotel-clock.ts @@ -0,0 +1,22 @@ +// repositories/shared/hotel-clock.ts +// The hotel's "today" and "now" as SQL literals, in place of CURDATE() and NOW(): +// the MySQL session runs in UTC, and the business day is Madrid's (ADR-047). +// The values come from the server clock, never from a request. + +import mysql from 'mysql2' +import { getNowMadrid, getTodayMadrid } from '../../config/date-utils.js' + +/** Madrid's date as a quoted 'YYYY-MM-DD', for DATE columns and DATE(...) of wall-clock times */ +export const sqlHotelToday = (): string => mysql.escape(getTodayMadrid()) + +/** Madrid's wall-clock time as a quoted 'YYYY-MM-DD HH:mm:ss', for parking check-in/out times */ +export const sqlHotelNow = (): string => mysql.escape(getNowMadrid().format('YYYY-MM-DD HH:mm:ss')) + +/** + * The Madrid date of an instant column (stored in UTC), in place of DATE(column): + * at 00:30 in Madrid the UTC date is still yesterday. Without named time zones + * (a local MySQL) CONVERT_TZ gives NULL and it falls back to the UTC date. + * `column` is a column name from the code, never input. + */ +export const sqlMadridDay = (column: string): string => + `DATE(COALESCE(CONVERT_TZ(${column}, '+00:00', 'Europe/Madrid'), ${column}))` diff --git a/backend/services/cron/cron-service.ts b/backend/services/cron/cron-service.ts index 28ed7f73..7c6e7501 100644 --- a/backend/services/cron/cron-service.ts +++ b/backend/services/cron/cron-service.ts @@ -8,6 +8,7 @@ import { purgeOldEventLogs, } from '../checklist/checklist.service.js' import { logger } from '../../config/logger.js' +import { getNowMadrid } from '../../config/date-utils.js' import { extendAvailability } from '../../repositories/parking/parking.repository.js' import { claimRun, finishRun } from '../../repositories/cron/cron-job-runs-repository.js' import { @@ -208,15 +209,14 @@ export class CronService { const startTime = Date.now() // Calculate previous month if not specified - const now = new Date() let year = targetYear let month = targetMonth if (!year || !month) { - // Get previous month - const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) - year = previousMonth.getFullYear() - month = previousMonth.getMonth() + 1 // getMonth() is 0-indexed + // The previous month in Madrid, not in UTC (ADR-047) + const previousMonth = getNowMadrid().subtract(1, 'month') + year = previousMonth.year() + month = previousMonth.month() + 1 } const executingUserId = userId || this.SYSTEM_USER_ID @@ -287,14 +287,14 @@ export class CronService { count: number total_amount: number }> { - const now = new Date() let year = targetYear let month = targetMonth if (!year || !month) { - const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) - year = previousMonth.getFullYear() - month = previousMonth.getMonth() + 1 + // The previous month in Madrid, not in UTC (ADR-047) + const previousMonth = getNowMadrid().subtract(1, 'month') + year = previousMonth.year() + month = previousMonth.month() + 1 } const preview = await BackofficeRepository.getValidatedInvoicesCountByMonth(year, month) diff --git a/backend/services/group/group-history-service.ts b/backend/services/group/group-history-service.ts index 43ce372f..d23a81bf 100644 --- a/backend/services/group/group-history-service.ts +++ b/backend/services/group/group-history-service.ts @@ -238,6 +238,11 @@ export class GroupHistoryService { return null } + // A date as plain ISO text: JSON.stringify quoted it and the screen split it into characters + if (value instanceof Date) { + return value.toISOString() + } + if (typeof value === 'object') { return JSON.stringify(value) } diff --git a/backend/services/parking/AGENTS.md b/backend/services/parking/AGENTS.md index 9514d632..a377ac2c 100644 --- a/backend/services/parking/AGENTS.md +++ b/backend/services/parking/AGENTS.md @@ -73,6 +73,12 @@ a method, **update both sides**. (`normal`, `ancha`, `mas_ancha`, `esquina`, `accesible`, `estrecha_bicis`). If either side changes, update the other. +`expected_checkin`, `expected_checkout`, `actual_checkin` and `actual_checkout` +are the hotel's wall clock: stored as typed (`12:00` is noon in Madrid), read +with `parseHotelTime` and answered without `Z` by `toHotelTime`, so the browser +shows them as typed. `payment_date` and the audit columns are UTC instants +(ADR-047). + ### Booking status - **`reserved`**: Booking created, waiting for arrival diff --git a/backend/tests/config/hotel-clock.test.ts b/backend/tests/config/hotel-clock.test.ts new file mode 100644 index 00000000..d1112b1f --- /dev/null +++ b/backend/tests/config/hotel-clock.test.ts @@ -0,0 +1,50 @@ +// tests/config/hotel-clock.test.ts +// "Today" and "now" are the hotel's (Madrid), not UTC's (ADR-047). At 00:30 in +// Madrid it is still yesterday in UTC: CURDATE(), NOW() and toISOString() gave +// the wrong day, and parking showed every booking time two hours late. + +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + getHotelNowDate, + getTodayMadrid, + parseHotelTime, + toHotelTime, +} from '../../config/date-utils.js' +import { sqlHotelNow, sqlHotelToday } from '../../repositories/shared/hotel-clock.js' + +afterEach(() => vi.useRealTimers()) + +describe('at 00:30 in Madrid', () => { + it('summer: today is already the 15th', () => { + vi.useFakeTimers({ now: new Date('2026-10-14T22:30:00Z') }) + expect(new Date().toISOString().slice(0, 10)).toBe('2026-10-14') + expect(getTodayMadrid()).toBe('2026-10-15') + expect(sqlHotelToday()).toBe("'2026-10-15'") + expect(sqlHotelNow()).toBe("'2026-10-15 00:30:00'") + }) + + it('winter: the offset is one hour', () => { + vi.useFakeTimers({ now: new Date('2026-12-14T23:30:00Z') }) + expect(getTodayMadrid()).toBe('2026-12-15') + expect(sqlHotelNow()).toBe("'2026-12-15 00:30:00'") + }) + + it('the wall-clock now is stored as the Madrid time', () => { + vi.useFakeTimers({ now: new Date('2026-10-14T22:30:00Z') }) + expect(getHotelNowDate().toISOString()).toBe('2026-10-15T00:30:00.000Z') + }) +}) + +describe('parking wall-clock times', () => { + it('stores what the user typed, with or without the T', () => { + expect(parseHotelTime('2026-10-14T12:00').toISOString()).toBe('2026-10-14T12:00:00.000Z') + expect(parseHotelTime('2026-10-14 12:00:00').toISOString()).toBe('2026-10-14T12:00:00.000Z') + }) + + it('answers the stored time without Z, so the browser reads 12:00', () => { + // mysql2 (timezone Z) reads the stored 12:00:00 as this Date + expect(toHotelTime(new Date('2026-10-14T12:00:00.000Z'))).toBe('2026-10-14T12:00:00') + expect(toHotelTime('2026-10-14 12:00:00')).toBe('2026-10-14T12:00:00') + expect(toHotelTime(null)).toBeNull() + }) +}) diff --git a/backend/tests/group/group-history-update.test.ts b/backend/tests/group/group-history-update.test.ts index eb67e689..034b6614 100644 --- a/backend/tests/group/group-history-update.test.ts +++ b/backend/tests/group/group-history-update.test.ts @@ -83,4 +83,19 @@ describe('GroupHistoryService.logGroupUpdated', () => { }) ) }) + + it('stores a changed date as plain ISO text, not quoted JSON', async () => { + const oldData = { arrival_date: new Date('2026-10-24T00:00:00.000Z') } + const newData = { arrival_date: new Date('2026-10-26T00:00:00.000Z') } + + await GroupHistoryService.logGroupUpdated(1, 'user1', oldData, newData) + + expect(GroupHistoryRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + field_changed: 'arrival_date', + old_value: '2026-10-24T00:00:00.000Z', + new_value: '2026-10-26T00:00:00.000Z', + }) + ) + }) }) diff --git a/backend/tests/security/env-example.test.ts b/backend/tests/security/env-example.test.ts index 5c653639..3cdd9efd 100644 --- a/backend/tests/security/env-example.test.ts +++ b/backend/tests/security/env-example.test.ts @@ -11,8 +11,16 @@ import { fileURLToPath } from 'node:url' const backend = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const example = readFileSync(join(backend, '.env.example'), 'utf8') -// vitest.config.ts reads SKIP_DB_TESTS for the test runner, not the app -const SKIP = new Set(['node_modules', 'tests', 'scheduling-solver', 'db-mysql', 'vitest.config.ts']) +// vitest.config.ts reads SKIP_DB_TESTS for the test runner, not the app; +// config/timezone.ts sets TZ, it is not something to configure (ADR-047) +const SKIP = new Set([ + 'node_modules', + 'tests', + 'scheduling-solver', + 'db-mysql', + 'vitest.config.ts', + 'timezone.ts', +]) function sources(dir: string): string[] { return readdirSync(dir).flatMap((name) => { const path = join(dir, name) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index de8ec2a0..0b70540e 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -26,5 +26,7 @@ export default defineConfig({ include: ['services/scheduling/**/*.ts'], }, testTimeout: 10000, + // The backend runs in UTC everywhere (ADR-047): tests too, on any machine + env: { TZ: 'UTC' }, }, }) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 322951d6..bb599262 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1040,3 +1040,43 @@ de 2026 (periodo `2026-10`). - La migración tiene que estar en Aiven antes del despliegue; sin la tabla, cada tarea anota el error en el log y no se ejecuta. + +## ADR-047 — El backend corre en UTC; el «hoy» y la hora del hotel, en Madrid + +- **Estado:** ✅ aceptada (2026-10-14) +- **Fecha:** 2026-10-14 +- **Decisión:** tres reglas, con una única fuente en `config/date-utils.ts` y + `repositories/shared/hotel-clock.ts`. + - **Instantes en UTC:** el backend fija `TZ=UTC` al arrancar + (`config/timezone.ts`, primer import de `index.ts`), `mysql2` usa + `timezone: 'Z'` y cada sesión de MySQL `time_zone = '+00:00'`. `created_at`, + `updated_at`, `changed_at`… son instantes UTC y el navegador los muestra en + su hora. + - **«Hoy» en Madrid:** se calcula en Node (`getTodayMadrid`, `sqlHotelToday`, + `sqlHotelNow`) en lugar de `CURDATE()`, `NOW()` o `toISOString()`; en el + frontend, `getMadridDate()`. Para saber el día de Madrid de un instante + guardado, `sqlMadridDay(col)` (`CONVERT_TZ` a `Europe/Madrid`, que Aiven + tiene cargado; sin zonas con nombre recurre a la fecha UTC). El trigger del + código de parking hace lo mismo. + - **Horas de parking = reloj del hotel:** la entrada y salida previstas y + reales se guardan tal como se escriben (`12:00` es mediodía en Madrid), se + leen con `parseHotelTime` y la API las devuelve sin `Z` (`toHotelTime`), así + que el navegador las lee como hora local. +- **Motivo:** Render y Aiven estaban en UTC, pero el portátil y el MySQL local + en hora de Madrid: el mismo código daba otra fecha según la máquina. En + producción, entre las 00:00 y las 02:00 de Madrid, `CURDATE()` y + `toISOString()` daban todavía el día anterior, y `DATE(col)` ponía en el día + anterior lo hecho a esas horas (historial de caja, actividad…). Parking + guardaba la hora escrita pero la devolvía como UTC: las pantallas la mostraban + 2 horas tarde y cada edición la movía otras 2. Se descartó devolver las `DATE` + como texto (`dateStrings`): cambia la forma de los datos en decenas de sitios + para el mismo resultado. Se descartó pasar parking a UTC con una migración: no + se puede distinguir qué filas escribió el usuario y cuáles el servidor. +- **Consecuencias:** + - Local se comporta como producción; en producción no cambia la forma de + ningún dato salvo las horas de parking, que llegan sin `Z`. + - Los `DATETIME` locales escritos antes con hora de Madrid se ven 2 horas + desplazados, y en local `sqlMadridDay` usa la fecha UTC (datos de prueba). + - Queda con `CURDATE()` la vista `v_bo_suppliers_stats` (total del año en + curso): solo falla la primera hora del 1 de enero. + - Los tests corren en UTC en cualquier máquina (`vitest.config.ts`). diff --git a/docs/SESSION.md b/docs/SESSION.md index 575da891..05536947 100644 --- a/docs/SESSION.md +++ b/docs/SESSION.md @@ -28,7 +28,7 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, - **Local:** `pnpm setup:local` instala desde cero con admin completo, datos ficticios y la base de horarios de la demo (`scheduling-seed.sql`). - **Repositorio original:** `four-points-archive`, archivado y privado. -- **Decisiones:** `DECISIONS.md`, hasta ADR-044. +- **Decisiones:** `DECISIONS.md`, hasta ADR-047. - **Harness:** capa 1 en `backend/AGENTS.md` y `frontend/AGENTS.md`; el `AGENTS.md` raíz guarda lo común y los tres niveles se enlazan entre sí (ADR-042, ADR-043). La sesión se abre en la raíz y activa el Node de cada lado @@ -39,9 +39,18 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, migraciones `20261011_keep_group_history_on_delete.sql` y `20261012_unique_invoice_supplier_number.sql`, aplicadas en Aiven. En `main` desde el PR #28. -- **`main` al día con la rama** desde el PR #29 (2026-10-14, merge `47f77b9`): - harness en los dos subproyectos, documentos de módulo alineados con el código - y Prettier 3.9.9 en los dos lados. +- **`main` al día con la rama** desde el PR #30 (2026-10-14, merge `9b33943`): + las tareas programadas se ponen al día al arrancar y el pago automático del + día 10 funciona (ADR-046, comprobado en preview y producción); los errores de + `auth` y de los middlewares llevan `code`; el historial de grupos solo anota + lo que cambia. +- **Fechas y horas (ADR-047, en la rama, pendiente de `main`):** el backend + corre en UTC en cualquier máquina; «hoy» y el día de un instante se calculan + en Madrid; las horas de parking son el reloj del hotel. Los historiales + muestran fechas legibles y estados traducidos. +- **Rama `opencode-tasks`:** trabajo delegado a OpenCode con una directriz + cerrada (ficheros permitidos, tabla de cambios, verificación); se revisa, se + commitea y entra en la rama de preview con fast-forward. ## ⚠️ Empieza por aquí @@ -68,10 +77,7 @@ y auditado, publicación y demo. Sus documentos (`ROADMAP.md`, `GITCLEAN.md`, de un usuario de la aplicación (`test-login.js`, commit `8595ec0`): cambiarla también. Se hace en los paneles y se comprueba que producción y preview siguen funcionando. -2. **Fechas y «hoy» según la zona horaria de cada pieza**, como tarea propia - (decidido el 2026-10-12). -3. Las de prioridad media, empezando por las comprobaciones cortas (cron en - Render) y el pago automático del día 10, que nunca ha funcionado. +2. Las de prioridad media. Los errores conocidos que se aceptan sin arreglarlos ahora están en `docs/_archive/TODO.md` (ADR-045). @@ -88,6 +94,8 @@ Nada ahora mismo. - **Preview y producción comparten la BD de Aiven** y las cookies de sesión: lo que se prueba en preview cambia datos de producción, y entrar en preview sustituye la sesión de producción en ese navegador. +- **Preview y producción comparten `cron_job_runs`:** el primer backend que + arranca después de la hora de una tarea la hace por los dos (ADR-046). - **La cuenta demo es un admin** (`users.is_demo = 1`): lo que no deba tocar se bloquea en la ruta con `denyDemo`/`denyDemoWrites` o en `demoRestriction`. Una ruta nueva que escriba fuera de lo que reinicia el mock (usuarios, catálogos, diff --git a/docs/TODO.md b/docs/TODO.md index 3341d9c3..21a8ecb6 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -18,7 +18,7 @@ hace que alguien reimplemente lo que ya existe. ## Rama `claude/compassionate-planck-gh6aof` -Última fusión en `main`: 2026-10-14 (PR #29, commit de merge `47f77b9`). La rama +Última fusión en `main`: 2026-10-14 (PR #30, commit de merge `9b33943`). La rama sigue siendo donde se arregla este fichero y se prueba en el entorno preview. ## Después de publicar @@ -58,47 +58,12 @@ ahora están en `docs/_archive/TODO.md`, apartado «Errores conocidos pendientes reales — Aiven hace copias automáticas y existe `backend/db-mysql/backup/backup.ps1`, pero no consta que se haya probado a restaurar ninguna. Del plan archivado (`Global-Plan.md`, H3-8). -- [ ] **Cron en producción: desplegar y comprobarlo en Render** — en Render no - se ejecutaba ninguna tarea programada (7 días de logs sin una, 2026-10-14: - el plan gratuito duerme el backend) y el pago del día 10 fallaba por - `system-cron`. Arreglado en la rama `opencode-tasks`: se ponen al día al - arrancar (ADR-046). `20261014_cron_catch_up.sql` ya está en Aiven - (2026-10-14). Falta desplegar y ver en los logs de Render el - `[CRON] Ejecutando` de cada tarea y la tabla `cron_job_runs` con sus - periodos. El primer arranque paga las facturas validadas de septiembre. - [ ] **La retención de mensajes borra las conversaciones vacías** — el evento MySQL `cleanup_old_messages` borra cada día, además de los mensajes de más de 90 días, toda conversación sin mensajes, incluidas las recién creadas y aún sin estrenar. _Comprobado el 2026-09-28 en local y Aiven: `event_scheduler=ON` y el evento está `ENABLED` (última ejecución en Aiven: 2026-09-27)._ -- [ ] **Fechas y «hoy» dependen de la zona horaria de cada pieza** — navegador - en Madrid, Node en la del proceso (Madrid en local; Render sin comprobar, - UTC si no tiene `TZ`) y MySQL en la suya (Madrid en local; Aiven en UTC, - `time_zone=SYSTEM`, comprobado el 2026-09-28). No son relojes - desincronizados: - El pool (`config/db.ts`) no fija `timezone` ni - `dateStrings`: una columna `DATE` llega como instante a medianoche de - Node. Con Node en Madrid, el 30/09 viaja como `2026-09-29T22:00:00Z`, y - quien lo corta por la `T` se queda con el 29. Así, en local, cada edición - de una factura le resta un día a sus fechas - (`InvoiceFormModal.tsx:83-87`). Con Node en UTC sale bien. - Si Node y - MySQL no están en la misma zona, los `DATETIME` se desplazan: con Node en - UTC contra la MySQL local, `NOW()` sale 2 h movido (p. ej. - `pnpm dev:aiven` desde un equipo en Madrid). - «Hoy» en UTC está mal entre - las 00:00 y las 02:00 de Madrid: `toISOString()` en 26 sitios del frontend - y 6 del backend, `CURDATE()`/`NOW()` en Aiven (19 consultas con - `CURDATE()`: filtros de parking, código `PK-AAAAMMDD` del trigger, - `CURRENT_TIMESTAMP`) y el identificador `DDMMAA-NNN` de Maintenance (fecha - de Node). - 15 sitios del frontend cortan por la `T` fechas del servidor - (grupos, lista negra, parking, horarios, checklist, caja, backoffice). - - Plan: `DATE` como texto `AAAA-MM-DD` (`dateStrings`), «hoy» siempre en - Madrid (un helper en el frontend, `getTodayMadrid` en el backend) y zona - fija entre Node y MySQL. Antes: mirar si Render tiene `TZ` y si alguna - fecha de Aiven se ha movido ya por ediciones. Tarea propia, decidida el - 2026-10-12. _Medido el 2026-10-12 contra la BD local con Node en Madrid y - en UTC._ - - [ ] **Logbook: papelera e historial solo ocultos en la pantalla** — el backend da `/trashed`, `include_trashed`, el historial y los comentarios de entradas borradas a cualquier rol con acceso. _Según la revisión diff --git a/frontend/app/components/blacklist/mains/AuditTrail.tsx b/frontend/app/components/blacklist/mains/AuditTrail.tsx index 8b34e7fa..d261d35b 100644 --- a/frontend/app/components/blacklist/mains/AuditTrail.tsx +++ b/frontend/app/components/blacklist/mains/AuditTrail.tsx @@ -219,6 +219,7 @@ function formatValue(value: unknown, t: any): string { try { const date = new Date(value) return new Intl.DateTimeFormat('es-ES', { + timeZone: 'Europe/Madrid', day: '2-digit', month: '2-digit', year: 'numeric', diff --git a/frontend/app/components/blacklist/mains/BlacklistForm.tsx b/frontend/app/components/blacklist/mains/BlacklistForm.tsx index f0453b1d..294aabcf 100644 --- a/frontend/app/components/blacklist/mains/BlacklistForm.tsx +++ b/frontend/app/components/blacklist/mains/BlacklistForm.tsx @@ -41,6 +41,7 @@ type BlacklistFormValues = { // ✅ Y también las constantes import { DOCUMENT_TYPES, SEVERITY_LEVELS } from '@/app/lib/blacklist/types' import { privateFileUrl } from '@/app/lib/helpers/private-file' +import { getMadridDate } from '@/app/lib/helpers/date' interface BlacklistFormProps { mode: 'create' | 'edit' @@ -82,8 +83,8 @@ export function BlacklistForm({ mode, initialData, onSuccess }: BlacklistFormPro guest_name: '', document_type: 'DNI', document_number: '', - check_in_date: new Date(), - check_out_date: new Date(), + check_in_date: new Date(getMadridDate()), + check_out_date: new Date(getMadridDate()), reason: '', severity: 'MEDIUM', comments: '', diff --git a/frontend/app/components/bo/modals/InvoiceFormModal.tsx b/frontend/app/components/bo/modals/InvoiceFormModal.tsx index 5d13794e..98a35777 100644 --- a/frontend/app/components/bo/modals/InvoiceFormModal.tsx +++ b/frontend/app/components/bo/modals/InvoiceFormModal.tsx @@ -21,6 +21,7 @@ import { applyVat } from '@/app/lib/backoffice/types' import { backofficeApi } from '@/app/lib/backoffice/backofficeApi' import DatePickerInput from '@/app/ui/calendar/DatePickerInput' import toast from 'react-hot-toast' +import { getMadridDate } from '@/app/lib/helpers/date' interface InvoiceFormModalProps { isOpen: boolean @@ -59,8 +60,8 @@ export function InvoiceFormModal({ amount_without_vat: 0, amount_with_vat: 0, vat_percentage: 21, - invoice_date: new Date().toISOString().split('T')[0], - received_date: new Date().toISOString().split('T')[0], + invoice_date: getMadridDate(), + received_date: getMadridDate(), billing_period_start: undefined, billing_period_end: undefined, due_date: undefined, @@ -97,8 +98,8 @@ export function InvoiceFormModal({ amount_without_vat: 0, amount_with_vat: 0, vat_percentage: 21, - invoice_date: new Date().toISOString().split('T')[0], - received_date: new Date().toISOString().split('T')[0], + invoice_date: getMadridDate(), + received_date: getMadridDate(), billing_period_start: undefined, billing_period_end: undefined, due_date: undefined, diff --git a/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx b/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx index 8227ed2e..04010a6f 100644 --- a/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx +++ b/frontend/app/components/bo/tabs/PaidInvoicesTabLazy.tsx @@ -10,7 +10,7 @@ import { useMemo, useState, useCallback } from 'react' import { useTranslations } from 'next-intl' -import { formatDateDisplayShort } from '@/app/lib/helpers/date' +import { formatDateDisplayShort, getMadridDate } from '@/app/lib/helpers/date' import { FiSearch, FiDownload, @@ -397,7 +397,7 @@ export function PaidInvoicesTabLazy({ const url = window.URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `facturas_pagadas_${new Date().toISOString().split('T')[0]}.csv` + link.download = `facturas_pagadas_${getMadridDate()}.csv` document.body.appendChild(link) link.click() document.body.removeChild(link) @@ -444,7 +444,7 @@ export function PaidInvoicesTabLazy({ const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') a.download = `facturas_pagadas_${timestamp}.zip` document.body.appendChild(a) a.click() diff --git a/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx b/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx index 4120b9c4..e138f82a 100644 --- a/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx +++ b/frontend/app/components/bo/tabs/PendingInvoicesTabLazy.tsx @@ -11,7 +11,7 @@ import React, { useMemo, useState, useTransition } from 'react' import { useTranslations } from 'next-intl' -import { formatDateDisplayShort } from '@/app/lib/helpers/date' +import { formatDateDisplayShort, getMadridDate } from '@/app/lib/helpers/date' import { FiSearch, FiPlus, @@ -398,7 +398,7 @@ export function PendingInvoicesTabLazy({ const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') a.download = `facturas_validadas_${timestamp}.zip` document.body.appendChild(a) a.click() diff --git a/frontend/app/components/cashier/logs/HistoryTable.tsx b/frontend/app/components/cashier/logs/HistoryTable.tsx index 27a2ca19..d807ca63 100644 --- a/frontend/app/components/cashier/logs/HistoryTable.tsx +++ b/frontend/app/components/cashier/logs/HistoryTable.tsx @@ -25,6 +25,16 @@ export default function HistoryTable({ }: HistoryTableProps) { const t = useTranslations('cashier') + // Shift and voucher statuses are stored as codes (open, pending…): show them in words + const STATUS_KEYS: Record = { + open: 'summary.open', + closed: 'summary.closed', + pending: 'voucher.pending', + justified: 'voucher.justified', + cancelled: 'voucher.cancelled', + } + const formatStatusValue = (value: string) => (STATUS_KEYS[value] ? t(STATUS_KEYS[value]) : value) + const getActionBadge = (action: string) => { const badges: Record = { created: { @@ -167,12 +177,14 @@ export default function HistoryTable({ day: '2-digit', month: '2-digit', year: 'numeric', + timeZone: 'Europe/Madrid', })}
{new Date(log.changed_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', + timeZone: 'Europe/Madrid', })}
@@ -189,6 +201,7 @@ export default function HistoryTable({ {new Date(log.shift_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', + timeZone: 'Europe/Madrid', })}
@@ -209,7 +222,9 @@ export default function HistoryTable({ {/* Campo */} - {log.field_changed || -} + {log.field_changed === 'status' + ? t('summary.status') + : log.field_changed || -} {/* Cambio */} @@ -218,12 +233,12 @@ export default function HistoryTable({
{log.old_value && (
- - {log.old_value} + - {formatStatusValue(log.old_value)}
)} {log.new_value && (
- + {log.new_value} + + {formatStatusValue(log.new_value)}
)}
diff --git a/frontend/app/components/groups/history/HistoryItem.tsx b/frontend/app/components/groups/history/HistoryItem.tsx index 2f03d465..ac417d0d 100644 --- a/frontend/app/components/groups/history/HistoryItem.tsx +++ b/frontend/app/components/groups/history/HistoryItem.tsx @@ -126,6 +126,7 @@ export function HistoryItem({ record }: HistoryItemProps) { year: 'numeric', hour: '2-digit', minute: '2-digit', + timeZone: 'Europe/Madrid', }) } @@ -141,11 +142,18 @@ export function HistoryItem({ record }: HistoryItemProps) { .join(' ') } - const formatValue = (value: string, field?: string) => { + const formatValue = (rawValue: string, field?: string) => { // Check for null/undefined - if (value === null || value === undefined || value === 'null' || value === 'undefined') { + if ( + rawValue === null || + rawValue === undefined || + rawValue === 'null' || + rawValue === 'undefined' + ) { return '—' } + // Rows written before 2026-10-14 keep dates in quotes ("2026-10-24T22:00:00.000Z") + const value = rawValue.replace(/^"(.*)"$/, '$1') // Check if it's a date field if (field && field.includes('date') && value && value.match(/^\d{4}-\d{2}-\d{2}/)) { @@ -153,6 +161,7 @@ export function HistoryItem({ record }: HistoryItemProps) { day: '2-digit', month: 'short', year: 'numeric', + timeZone: 'Europe/Madrid', }) } @@ -185,6 +194,10 @@ export function HistoryItem({ record }: HistoryItemProps) { try { const oldObj = oldValue ? JSON.parse(oldValue) : {} const newObj = newValue ? JSON.parse(newValue) : {} + // Only whole records are compared field by field; a single value (a number, + // a quoted date) is shown as it is below + const isRecord = (v: unknown) => typeof v === 'object' && v !== null && !Array.isArray(v) + if (!isRecord(oldObj) || !isRecord(newObj)) return null // Find changed fields const changes: Array<{ field: string; oldVal: unknown; newVal: unknown }> = [] diff --git a/frontend/app/components/maintenance/tabs/HistoryTab.tsx b/frontend/app/components/maintenance/tabs/HistoryTab.tsx index e8980c36..690da0bb 100644 --- a/frontend/app/components/maintenance/tabs/HistoryTab.tsx +++ b/frontend/app/components/maintenance/tabs/HistoryTab.tsx @@ -96,9 +96,19 @@ export function HistoryTab() { year: 'numeric', hour: '2-digit', minute: '2-digit', + timeZone: 'Europe/Madrid', }) } + // Statuses and priorities in words, and the assignee by name, not as stored + const formatValue = (field: string | null, value: string, name?: string) => { + if (name) return name + const camel = value.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) + if (field === 'status' && t.has(`status.${camel}`)) return t(`status.${camel}`) + if (field === 'priority' && t.has(`priority.${camel}`)) return t(`priority.${camel}`) + return value + } + return (
@@ -143,13 +153,21 @@ export function HistoryTab() { {entry.old_value && (

{t('history.previous')}{' '} - {entry.old_value} + {formatValue( + entry.field_changed, + entry.old_value, + entry.old_value_name + )}

)} {entry.new_value && (

{t('history.new')}{' '} - {entry.new_value} + {formatValue( + entry.field_changed, + entry.new_value, + entry.new_value_name + )}

)}
diff --git a/frontend/app/components/parking/bookings/EditBookingModal.tsx b/frontend/app/components/parking/bookings/EditBookingModal.tsx index 47382db1..b59ed3c4 100644 --- a/frontend/app/components/parking/bookings/EditBookingModal.tsx +++ b/frontend/app/components/parking/bookings/EditBookingModal.tsx @@ -8,6 +8,7 @@ import { parkingApi } from '@/app/lib/parking' import { formatDateTimeLocal } from '../helpers' import { FiEdit2, FiX, FiSearch, FiMapPin, FiTruck, FiSlash, FiUserX } from 'react-icons/fi' import { SelectDropdown } from '@/app/ui/components/SelectDropdown' +import { getMadridDate } from '@/app/lib/helpers/date' interface EditBookingModalProps { booking: ParkingBooking @@ -86,9 +87,7 @@ export function EditBookingModal({ const loadAvailableSpots = async () => { // Si la fecha de entrada está en el pasado, usar hoy como start_date - const startDate = isCheckinInPast - ? new Date().toISOString().split('T')[0] - : data.expected_checkin.split('T')[0] + const startDate = isCheckinInPast ? getMadridDate() : data.expected_checkin.split('T')[0] const endDate = data.expected_checkout.split('T')[0] // Validar que start_date < end_date antes de hacer la llamada diff --git a/frontend/app/lib/backoffice/export-utils.ts b/frontend/app/lib/backoffice/export-utils.ts index 9fbb506c..51815c7d 100644 --- a/frontend/app/lib/backoffice/export-utils.ts +++ b/frontend/app/lib/backoffice/export-utils.ts @@ -9,6 +9,7 @@ import { PDFDocument, rgb, StandardFonts } from 'pdf-lib' import { encodableFor } from '@/app/lib/helpers/pdfText' import type { InvoiceWithDetails } from './types' import { INVOICE_STATUS_LABELS, PAYMENT_METHOD_LABELS, formatCurrency } from './types' +import { getMadridDate } from '@/app/lib/helpers/date' /** * Format date for display in exports (DD/MM/YYYY Madrid). Returns '-' for null. @@ -100,7 +101,7 @@ export async function exportToExcel( XLSX.utils.book_append_sheet(wb, wsSummary, 'Resumen') // Generate filename - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') const finalFilename = filename || `facturas_pendientes_${timestamp}.xlsx` // Download file @@ -314,7 +315,7 @@ export async function exportToPdf( }) // Generate filename - const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const timestamp = getMadridDate().replace(/-/g, '') const finalFilename = filename || `facturas_pendientes_${timestamp}.pdf` // Save and download diff --git a/frontend/app/lib/maintenance/maintenance.ts b/frontend/app/lib/maintenance/maintenance.ts index e53e8e57..c79be613 100644 --- a/frontend/app/lib/maintenance/maintenance.ts +++ b/frontend/app/lib/maintenance/maintenance.ts @@ -68,6 +68,9 @@ export interface MaintenanceHistory { changed_by: string changed_at: string user_name?: string + // Usernames when the change is an assignment (old_value/new_value are user ids) + old_value_name?: string + new_value_name?: string } export interface ReportWithDetails extends MaintenanceReport { diff --git a/frontend/app/stores/useCashierStore.ts b/frontend/app/stores/useCashierStore.ts index eb5322c9..e2859d4a 100644 --- a/frontend/app/stores/useCashierStore.ts +++ b/frontend/app/stores/useCashierStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand' import { useShallow } from 'zustand/react/shallow' -import { formatDateForInput } from '@/app/lib/helpers/date' +import { formatDateForInput, getMadridDate } from '@/app/lib/helpers/date' import type { ShiftType, HistoryAction } from '@/app/lib/cashier/types' // ═══════════════════════════════════════════════════════ @@ -125,7 +125,7 @@ const initialState = { modalData: null, // Logs - logsDate: today.toISOString().split('T')[0], + logsDate: getMadridDate(), logsActionFilter: 'all' as HistoryAction | 'all', logsUserFilter: '', logsLimit: 50, @@ -268,7 +268,7 @@ export const useCashierStore = create((set, get) => ({ logsGoToToday: () => { set({ - logsDate: new Date().toISOString().split('T')[0], + logsDate: getMadridDate(), logsOffset: 0, }) },