package application import ( "context" "fmt" "math" "sort" "strconv" "strings" "time" "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification" "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/sorting" filestorage "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/storage" fleets "git.coopgo.io/coopgo-platform/fleets/grpcapi" fleetsstorage "git.coopgo.io/coopgo-platform/fleets/storage" groupsmanagement "git.coopgo.io/coopgo-platform/groups-management/grpcapi" "git.coopgo.io/coopgo-platform/groups-management/storage" mobilityaccounts "git.coopgo.io/coopgo-platform/mobility-accounts/grpcapi" mobilityaccountsstorage "git.coopgo.io/coopgo-platform/mobility-accounts/storage" "github.com/google/uuid" "github.com/rs/zerolog/log" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) type VehiclesManagementOverviewResult struct { Vehicles []fleetsstorage.Vehicle VehiclesMap map[string]fleetsstorage.Vehicle DriversMap map[string]mobilityaccountsstorage.Account Bookings []fleetsstorage.Booking } func (h *ApplicationHandler) GetVehiclesManagementOverview(ctx context.Context, groupID, vehicleType, status, dateStart, dateEnd, vType, vStatus string) (*VehiclesManagementOverviewResult, error) { request := &fleets.GetVehiclesRequest{ Namespaces: []string{"parcoursmob"}, } resp, err := h.services.GRPC.Fleets.GetVehicles(ctx, request) if err != nil { return nil, fmt.Errorf("failed to get vehicles: %w", err) } vehicles := []fleetsstorage.Vehicle{} bookings := []fleetsstorage.Booking{} vehiclesMap := map[string]fleetsstorage.Vehicle{} isManualStatus := h.config.GetString("modules.vehicles.status_management") == "manual" // Parse date filters var startdate time.Time if dateStart != "" { if parsed, err := time.Parse("2006-01-02", dateStart); err == nil { startdate = parsed } } var enddate time.Time if dateEnd != "" { if parsed, err := time.Parse("2006-01-02", dateEnd); err == nil { enddate = parsed.Add(24 * time.Hour) } } for _, vehicle := range resp.Vehicles { if h.filterVehicleByGroup(vehicle, groupID) { v := vehicle.ToStorageType() vehicleBookings := []fleetsstorage.Booking{} for _, b := range v.Bookings { log.Debug().Any("booking", b).Msg("debug") if !b.Deleted { include := true // Apply date filters (intersection: booking overlaps with [startdate, enddate]) if !startdate.IsZero() && b.Enddate.Before(startdate) { include = false } if !enddate.IsZero() && b.Startdate.After(enddate) { include = false } // Apply vehicle type filter on bookings if include && vehicleType != "" && v.Type != vehicleType { include = false } // Apply status filter on bookings if include && status != "" { isAdminUnavail, _ := b.Data["administrator_unavailability"].(bool) if isAdminUnavail { include = false } else if isManualStatus { if strings.HasPrefix(status, "meta:") { metaParts := strings.Split(strings.TrimPrefix(status, "meta:"), ",") metaSet := map[string]bool{} for _, ms := range metaParts { metaSet[ms] = true } allowedStatuses := h.resolveMetaStatuses(metaSet) if !allowedStatuses[b.ManualStatus] { include = false } } else if b.ManualStatus != status { include = false } } else { var filterStatusInt int switch status { case "FORTHCOMING": filterStatusInt = 1 case "ONGOING": filterStatusInt = 0 case "TERMINATED": filterStatusInt = -1 default: filterStatusInt = 999 } if b.Status() != filterStatusInt { include = false } } } if include { bookings = append(bookings, b) } } if b.Unavailableto.After(time.Now()) { vehicleBookings = append(vehicleBookings, b) } } v.Bookings = vehicleBookings // Always add to vehiclesMap for booking lookups vehiclesMap[v.ID] = v // Apply vehicle type filter if vType != "" && v.Type != vType { continue } // Apply vehicle reservation status filter if vStatus != "" { hasActive := false hasUpcoming := false hasRetired := false for _, b := range v.Bookings { isAdminUnavail, _ := b.Data["administrator_unavailability"].(bool) if isAdminUnavail { hasRetired = true } else if b.Status() == 0 { hasActive = true } else if b.Status() == 1 { hasUpcoming = true } } match := false switch vStatus { case "DISPONIBLE": match = !hasActive && !hasUpcoming && !hasRetired case "RESERVE": match = hasUpcoming case "EN_PRET": match = hasActive case "RETIRE": match = hasRetired } if !match { continue } } vehicles = append(vehicles, v) } } driversMap, _ := h.services.GetBeneficiariesMap(ctx) sort.Sort(sorting.VehiclesByLicencePlate(vehicles)) sort.Sort(sorting.BookingsByStartdate(bookings)) return &VehiclesManagementOverviewResult{ Vehicles: vehicles, VehiclesMap: vehiclesMap, DriversMap: driversMap, Bookings: bookings, }, nil } func (h *ApplicationHandler) filterVehicleByGroup(v *fleets.Vehicle, groupID string) bool { if groupID == "" { return false } for _, n := range v.Administrators { if n == groupID { return true } } return false } type VehiclesManagementBookingsListResult struct { VehiclesMap map[string]fleetsstorage.Vehicle DriversMap map[string]mobilityaccountsstorage.Account Bookings []fleetsstorage.Booking CacheID string } func (h *ApplicationHandler) GetVehiclesManagementBookingsList(ctx context.Context, groupID, status, startDate, endDate, vehicleType string) (*VehiclesManagementBookingsListResult, error) { request := &fleets.GetVehiclesRequest{ Namespaces: []string{"parcoursmob"}, IncludeDeleted: true, } resp, err := h.services.GRPC.Fleets.GetVehicles(ctx, request) if err != nil { return nil, fmt.Errorf("failed to get vehicles: %w", err) } bookings := []fleetsstorage.Booking{} vehiclesMap := map[string]fleetsstorage.Vehicle{} // Parse start date filter var startdate time.Time if startDate != "" { if parsed, err := time.Parse("2006-01-02", startDate); err == nil { startdate = parsed } } // Parse end date filter var enddate time.Time if endDate != "" { if parsed, err := time.Parse("2006-01-02", endDate); err == nil { enddate = parsed.Add(24 * time.Hour) // End of day } } for _, vehicle := range resp.Vehicles { if h.filterVehicleByGroup(vehicle, groupID) { v := vehicle.ToStorageType() vehiclesMap[v.ID] = v for _, b := range v.Bookings { if isAdminUnavail, ok := b.Data["administrator_unavailability"].(bool); !ok || !isAdminUnavail { // Apply vehicle type filter if vehicleType != "" && v.Type != vehicleType { continue } // Apply status filter if status != "" { if h.config.GetString("modules.vehicles.status_management") == "manual" { if strings.HasPrefix(status, "meta:") { metaParts := strings.Split(strings.TrimPrefix(status, "meta:"), ",") metaSet := map[string]bool{} for _, ms := range metaParts { metaSet[ms] = true } allowedStatuses := h.resolveMetaStatuses(metaSet) if !allowedStatuses[b.ManualStatus] { continue } } else if b.ManualStatus != status { continue } } else { bookingStatus := b.Status() statusInt := 0 if b.Deleted { statusInt = -2 // Use -2 for cancelled to distinguish from terminated } else { statusInt = bookingStatus } // Map status string to int var filterStatusInt int switch status { case "FORTHCOMING": filterStatusInt = 1 case "ONGOING": filterStatusInt = 0 case "TERMINATED": filterStatusInt = -1 case "CANCELLED": filterStatusInt = -2 default: filterStatusInt = 999 // Invalid status, won't match anything } if statusInt != filterStatusInt { continue } } } // Apply date filter (intersection: booking overlaps with [startdate, enddate]) if !startdate.IsZero() && b.Enddate.Before(startdate) { continue } if !enddate.IsZero() && b.Startdate.After(enddate) { continue } bookings = append(bookings, b) } } } } sort.Sort(sorting.BookingsByStartdate(bookings)) cacheID := uuid.NewString() h.cache.PutWithTTL(cacheID, bookings, 1*time.Hour) driversMap, _ := h.services.GetBeneficiariesMap(ctx) return &VehiclesManagementBookingsListResult{ VehiclesMap: vehiclesMap, DriversMap: driversMap, Bookings: bookings, CacheID: cacheID, }, nil } func (h *ApplicationHandler) CreateVehicle(ctx context.Context, name, vehicleType, informations, licencePlate, kilometers string, automatic bool, address map[string]any, otherProperties map[string]any) (string, error) { g := ctx.Value(identification.GroupKey) if g == nil { return "", fmt.Errorf("no group found in context") } group := g.(storage.Group) dataMap := map[string]any{} if name != "" { dataMap["name"] = name } if address != nil { dataMap["address"] = address } if informations != "" { dataMap["informations"] = informations } if licencePlate != "" { dataMap["licence_plate"] = licencePlate } dataMap["automatic"] = automatic if kilometers != "" { dataMap["kilometers"] = kilometers } // Add other properties for key, value := range otherProperties { dataMap[key] = value } data, err := structpb.NewValue(dataMap) if err != nil { return "", fmt.Errorf("failed to create data struct: %w", err) } vehicle := &fleets.Vehicle{ Id: uuid.NewString(), Namespace: "parcoursmob", Type: vehicleType, Administrators: []string{group.ID}, Data: data.GetStructValue(), } request := &fleets.AddVehicleRequest{ Vehicle: vehicle, } _, err = h.services.GRPC.Fleets.AddVehicle(ctx, request) if err != nil { return "", fmt.Errorf("failed to add vehicle: %w", err) } return vehicle.Id, nil } func (h *ApplicationHandler) GetVehicleTypes(ctx context.Context) ([]string, error) { return h.config.GetStringSlice("modules.fleets.vehicle_types"), nil } type VehicleDisplayResult struct { Vehicle fleetsstorage.Vehicle Beneficiaries map[string]mobilityaccountsstorage.Account } func (h *ApplicationHandler) GetVehicleDisplay(ctx context.Context, vehicleID string) (*VehicleDisplayResult, error) { request := &fleets.GetVehicleRequest{ Vehicleid: vehicleID, } resp, err := h.services.GRPC.Fleets.GetVehicle(ctx, request) if err != nil { return nil, fmt.Errorf("failed to get vehicle: %w", err) } beneficiaries, err := h.services.GetBeneficiariesMap(ctx) if err != nil { return nil, fmt.Errorf("failed to get beneficiaries: %w", err) } vehicle := resp.Vehicle.ToStorageType() // Sort bookings by start date (most recent first) sort.Slice(vehicle.Bookings, func(i, j int) bool { return vehicle.Bookings[i].Startdate.After(vehicle.Bookings[j].Startdate) }) return &VehicleDisplayResult{ Vehicle: vehicle, Beneficiaries: beneficiaries, }, nil } func (h *ApplicationHandler) UpdateBooking(ctx context.Context, bookingID string, startdate, enddate *time.Time, unavailablefrom, unavailableto string) error { booking, err := h.services.GetBooking(bookingID) if err != nil { return fmt.Errorf("failed to get booking: %w", err) } newbooking, _ := fleets.BookingFromStorageType(&booking) if startdate != nil { newbooking.Startdate = timestamppb.New(*startdate) if startdate.Before(newbooking.Unavailablefrom.AsTime()) { newbooking.Unavailablefrom = timestamppb.New(*startdate) } } if enddate != nil { newbooking.Enddate = timestamppb.New(*enddate) if enddate.After(newbooking.Unavailableto.AsTime()) || enddate.Equal(newbooking.Unavailableto.AsTime()) { newbooking.Unavailableto = timestamppb.New(enddate.Add(24 * time.Hour)) } } if unavailablefrom != "" { newunavailablefrom, _ := time.Parse("2006-01-02", unavailablefrom) newbooking.Unavailablefrom = timestamppb.New(newunavailablefrom) } if unavailableto != "" { newunavailableto, _ := time.Parse("2006-01-02", unavailableto) newbooking.Unavailableto = timestamppb.New(newunavailableto) } request := &fleets.UpdateBookingRequest{ Booking: newbooking, } _, err = h.services.GRPC.Fleets.UpdateBooking(ctx, request) return err } type BookingDisplayResult struct { Booking fleetsstorage.Booking Vehicle fleetsstorage.Vehicle Beneficiary mobilityaccountsstorage.Account Group storage.Group Documents []filestorage.FileInfo FileTypesMap map[string]string Alternatives []any ComputedExtraProperties map[string]string } func (h *ApplicationHandler) GetBookingDisplay(ctx context.Context, bookingID string) (*BookingDisplayResult, error) { booking, err := h.services.GetBooking(bookingID) if err != nil { return nil, fmt.Errorf("failed to get booking: %w", err) } beneficiary := mobilityaccountsstorage.Account{} if booking.Driver != "" { beneficiaryrequest := &mobilityaccounts.GetAccountRequest{ Id: booking.Driver, } beneficiaryresp, err := h.services.GRPC.MobilityAccounts.GetAccount(ctx, beneficiaryrequest) if err != nil { return nil, fmt.Errorf("failed to get beneficiary: %w", err) } beneficiary = beneficiaryresp.Account.ToStorageType() } grouprequest := &groupsmanagement.GetGroupRequest{ Id: booking.Vehicle.Administrators[0], } groupresp, err := h.services.GRPC.GroupsManagement.GetGroup(ctx, grouprequest) if err != nil { return nil, fmt.Errorf("failed to get group: %w", err) } alternativerequest := &fleets.GetVehiclesRequest{ Namespaces: []string{"parcoursmob"}, Types: []string{booking.Vehicle.Type}, Administrators: booking.Vehicle.Administrators, AvailabilityFrom: timestamppb.New(booking.Startdate), AvailabilityTo: timestamppb.New(booking.Enddate.Add(24 * time.Hour)), } alternativeresp, err := h.services.GRPC.Fleets.GetVehicles(ctx, alternativerequest) if err != nil { log.Error().Err(err).Msg("failed to get alternative vehicles") } alternatives := []any{} for _, a := range alternativeresp.Vehicles { alternatives = append(alternatives, a.ToStorageType()) } documents := h.filestorage.List(filestorage.PREFIX_BOOKINGS + "/" + bookingID) fileTypesMap := h.config.GetStringMapString("storage.files.file_types") computedProps := h.computeExtraProperties(booking) return &BookingDisplayResult{ Booking: booking, Vehicle: booking.Vehicle, Beneficiary: beneficiary, Group: groupresp.Group.ToStorageType(), Documents: documents, FileTypesMap: fileTypesMap, Alternatives: alternatives, ComputedExtraProperties: computedProps, }, nil } func (h *ApplicationHandler) ChangeBookingVehicle(ctx context.Context, bookingID, newVehicleID string) error { booking, err := h.services.GetBooking(bookingID) if err != nil { return fmt.Errorf("failed to get booking: %w", err) } booking.Vehicleid = newVehicleID b, _ := fleets.BookingFromStorageType(&booking) request := &fleets.UpdateBookingRequest{ Booking: b, } _, err = h.services.GRPC.Fleets.UpdateBooking(ctx, request) return err } func (h *ApplicationHandler) MakeVehicleUnavailable(ctx context.Context, vehicleID, unavailablefrom, unavailableto, comment, currentUserID string, currentUserClaims map[string]any) error { g := ctx.Value(identification.GroupKey) if g == nil { return fmt.Errorf("no group found in context") } currentGroup := g.(storage.Group) unavailablefromTime, _ := time.Parse("2006-01-02", unavailablefrom) unavailabletoTime, _ := time.Parse("2006-01-02", unavailableto) data := map[string]any{ "comment": comment, "administrator_unavailability": true, "booked_by": map[string]any{ "user": map[string]any{ "id": currentUserID, "display_name": currentUserClaims["display_name"], }, "group": map[string]any{ "id": currentGroup.ID, "name": currentGroup.Data["name"], }, }, } datapb, err := structpb.NewStruct(data) if err != nil { return fmt.Errorf("failed to create data struct: %w", err) } booking := &fleets.Booking{ Id: uuid.NewString(), Vehicleid: vehicleID, Unavailablefrom: timestamppb.New(unavailablefromTime), Unavailableto: timestamppb.New(unavailabletoTime), Data: datapb, } request := &fleets.CreateBookingRequest{ Booking: booking, } _, err = h.services.GRPC.Fleets.CreateBooking(ctx, request) return err } func (h *ApplicationHandler) DeleteBooking(ctx context.Context, bookingID string) error { request := &fleets.DeleteBookingRequest{ Id: bookingID, } _, err := h.services.GRPC.Fleets.DeleteBooking(ctx, request) return err } func (h *ApplicationHandler) GetBookingForUnbooking(ctx context.Context, bookingID string) (fleetsstorage.Booking, error) { request := &fleets.GetBookingRequest{ Bookingid: bookingID, } resp, err := h.services.GRPC.Fleets.GetBooking(ctx, request) if err != nil { return fleetsstorage.Booking{}, fmt.Errorf("failed to get booking: %w", err) } return resp.Booking.ToStorageType(), nil } func (h *ApplicationHandler) UnbookVehicle(ctx context.Context, bookingID, motif, currentUserID string, currentUserClaims map[string]any, currentGroup any) error { group := currentGroup.(storage.Group) // Prepare deletion metadata (microservice will add deleted_at automatically) deletionMetadata := map[string]any{ "deleted_by": map[string]any{ "user": map[string]any{ "id": currentUserID, "display_name": currentUserClaims["first_name"].(string) + " " + currentUserClaims["last_name"].(string), "email": currentUserClaims["email"], }, "group": map[string]any{ "id": group.ID, "name": group.Data["name"], }, }, "reason": motif, } deletionMetadataPb, err := structpb.NewStruct(deletionMetadata) if err != nil { return fmt.Errorf("failed to create deletion metadata: %w", err) } // Use the microservice's delete endpoint with metadata deleteRequest := &fleets.DeleteBookingRequest{ Id: bookingID, DeletionMetadata: deletionMetadataPb, } _, err = h.services.GRPC.Fleets.DeleteBooking(ctx, deleteRequest) return err } type VehicleForUpdateResult struct { Vehicle fleetsstorage.Vehicle VehicleTypes []string } func (h *ApplicationHandler) GetVehicleForUpdate(ctx context.Context, vehicleID string) (*VehicleForUpdateResult, error) { request := &fleets.GetVehicleRequest{ Vehicleid: vehicleID, } resp, err := h.services.GRPC.Fleets.GetVehicle(ctx, request) if err != nil { return nil, fmt.Errorf("failed to get vehicle: %w", err) } vehicleTypes := h.config.GetStringSlice("modules.fleets.vehicle_types") return &VehicleForUpdateResult{ Vehicle: resp.Vehicle.ToStorageType(), VehicleTypes: vehicleTypes, }, nil } func (h *ApplicationHandler) UpdateVehicle(ctx context.Context, vehicleID, name, vehicleType, informations, licencePlate, kilometers string, automatic bool, address map[string]any, otherProperties map[string]any) (string, error) { getRequest := &fleets.GetVehicleRequest{ Vehicleid: vehicleID, } resp, err := h.services.GRPC.Fleets.GetVehicle(ctx, getRequest) if err != nil { return "", fmt.Errorf("failed to get vehicle: %w", err) } // Start with existing data to preserve all fields dataMap := resp.Vehicle.Data.AsMap() if dataMap == nil { dataMap = map[string]any{} } // Update with new values if name != "" { dataMap["name"] = name } if address != nil { dataMap["address"] = address } if informations != "" { dataMap["informations"] = informations } if licencePlate != "" { dataMap["licence_plate"] = licencePlate } if kilometers != "" { dataMap["kilometers"] = kilometers } dataMap["automatic"] = automatic // Add other properties for key, value := range otherProperties { dataMap[key] = value } data, err := structpb.NewValue(dataMap) if err != nil { return "", fmt.Errorf("failed to create data struct: %w", err) } updateRequest := &fleets.UpdateVehicleRequest{ Vehicle: &fleets.Vehicle{ Id: vehicleID, Namespace: resp.Vehicle.Namespace, Type: vehicleType, Administrators: resp.Vehicle.Administrators, Data: data.GetStructValue(), }, } updateResp, err := h.services.GRPC.Fleets.UpdateVehicle(ctx, updateRequest) if err != nil { return "", fmt.Errorf("failed to update vehicle: %w", err) } return updateResp.Vehicle.Id, nil } // getStatusOptions extracts status options from Viper config, handling both // Go defaults ([]map[string]any) and YAML-parsed ([]interface{}) types. func getStatusOptions(raw interface{}) []map[string]any { if options, ok := raw.([]map[string]any); ok { return options } if rawSlice, ok := raw.([]interface{}); ok { result := make([]map[string]any, 0, len(rawSlice)) for _, item := range rawSlice { if opt, ok := item.(map[string]any); ok { result = append(result, opt) } } return result } return nil } // getBookingExtraProperties extracts booking extra property definitions from Viper config, // handling both Go defaults ([]map[string]any) and YAML-parsed ([]interface{}) types. func getBookingExtraProperties(raw interface{}) []map[string]any { if props, ok := raw.([]map[string]any); ok { return props } if rawSlice, ok := raw.([]interface{}); ok { result := make([]map[string]any, 0, len(rawSlice)) for _, item := range rawSlice { if prop, ok := item.(map[string]any); ok { result = append(result, prop) } } return result } return nil } func (h *ApplicationHandler) UpdateBookingStatus(ctx context.Context, bookingID, newStatus, comment, userID string, userClaims map[string]any, currentGroup any, extraProperties map[string]string) error { group := currentGroup.(storage.Group) // Validate that newStatus is a valid status option options := getStatusOptions(h.config.Get("modules.vehicles.status_options")) validStatus := false for _, opt := range options { if name, ok := opt["name"].(string); ok && name == newStatus { validStatus = true break } } if !validStatus { return fmt.Errorf("invalid status: %s", newStatus) } booking, err := h.services.GetBooking(bookingID) if err != nil { return fmt.Errorf("failed to get booking: %w", err) } oldStatus := booking.ManualStatus booking.ManualStatus = newStatus booking.StatusHistory = append(booking.StatusHistory, fleetsstorage.StatusHistoryEntry{ FromStatus: oldStatus, ToStatus: newStatus, UserID: userID, UserName: fmt.Sprintf("%s %s", userClaims["first_name"], userClaims["last_name"]), GroupID: group.ID, GroupName: fmt.Sprintf("%s", group.Data["name"]), Date: time.Now(), Comment: comment, }) // Process extra properties if len(extraProperties) > 0 { propDefs := getBookingExtraProperties(h.config.Get("modules.vehicles.booking_extra_properties")) // Build a lookup map of property definitions by name propDefsMap := map[string]map[string]any{} for _, def := range propDefs { if name, ok := def["name"].(string); ok { propDefsMap[name] = def } } // Initialize extra_properties in booking data if needed storedExtras, _ := booking.Data["extra_properties"].(map[string]any) if storedExtras == nil { storedExtras = map[string]any{} } for propName, propValue := range extraProperties { if propValue == "" { continue } def, exists := propDefsMap[propName] if !exists { continue } if target, ok := def["target"].(string); ok && target != "" { // Update structural booking field switch target { case "unavailableto": if t, err := time.Parse("2006-01-02", propValue); err == nil { booking.Unavailableto = t } case "unavailablefrom": if t, err := time.Parse("2006-01-02", propValue); err == nil { booking.Unavailablefrom = t } case "enddate": paris, _ := time.LoadLocation("Europe/Paris") if t, err := time.ParseInLocation("2006-01-02T15:04", propValue, paris); err == nil { booking.Enddate = t if t.After(booking.Unavailableto) || t.Equal(booking.Unavailableto) { booking.Unavailableto = t.Add(24 * time.Hour) } } case "startdate": paris, _ := time.LoadLocation("Europe/Paris") if t, err := time.ParseInLocation("2006-01-02T15:04", propValue, paris); err == nil { booking.Startdate = t if t.Before(booking.Unavailablefrom) { booking.Unavailablefrom = t } } } } else { // Store in extra_properties storedExtras[propName] = propValue } } booking.Data["extra_properties"] = storedExtras } b, err := fleets.BookingFromStorageType(&booking) if err != nil { return fmt.Errorf("failed to convert booking: %w", err) } _, err = h.services.GRPC.Fleets.UpdateBooking(ctx, &fleets.UpdateBookingRequest{ Booking: b, }) return err } // --- Computed extra properties --- func (h *ApplicationHandler) computeExtraProperties(booking fleetsstorage.Booking) map[string]string { defs := getBookingExtraProperties(h.config.Get("modules.vehicles.booking_extra_properties")) extras, _ := booking.Data["extra_properties"].(map[string]any) if extras == nil { extras = map[string]any{} } result := map[string]string{} for _, def := range defs { defType, _ := def["type"].(string) if defType != "computed" { continue } name, _ := def["name"].(string) operation, _ := def["operation"].(string) unit, _ := def["unit"].(string) operands := resolveStringSlice(def["operands"]) if name == "" || operation == "" || len(operands) == 0 { continue } val := evaluateOperation(operation, operands, booking, extras) if val != "" { if unit != "" { val = val + " " + unit } result[name] = val } } return result } // resolveStringSlice handles both Go defaults ([]string) and YAML-parsed ([]interface{}) types. func resolveStringSlice(raw interface{}) []string { if s, ok := raw.([]string); ok { return s } if rawSlice, ok := raw.([]interface{}); ok { result := make([]string, 0, len(rawSlice)) for _, item := range rawSlice { if str, ok := item.(string); ok { result = append(result, str) } } return result } return nil } func evaluateOperation(operation string, operands []string, booking fleetsstorage.Booking, extras map[string]any) string { switch operation { case "add": if len(operands) < 2 { return "" } a, okA := resolveNumericOperand(operands[0], booking, extras) b, okB := resolveNumericOperand(operands[1], booking, extras) if !okA || !okB { return "" } return formatNumber(a + b) case "subtract": if len(operands) < 2 { return "" } a, okA := resolveNumericOperand(operands[0], booking, extras) b, okB := resolveNumericOperand(operands[1], booking, extras) if !okA || !okB { return "" } return formatNumber(a - b) case "multiply": if len(operands) < 2 { return "" } a, okA := resolveNumericOperand(operands[0], booking, extras) b, okB := resolveNumericOperand(operands[1], booking, extras) if !okA || !okB { return "" } return formatNumber(a * b) case "divide": if len(operands) < 2 { return "" } a, okA := resolveNumericOperand(operands[0], booking, extras) b, okB := resolveNumericOperand(operands[1], booking, extras) if !okA || !okB || b == 0 { return "" } return formatNumber(a / b) case "duration": if len(operands) < 2 { return "" } d1, ok1 := resolveDateOperand(operands[0], booking, extras) d2, ok2 := resolveDateOperand(operands[1], booking, extras) if !ok1 || !ok2 { return "" } return formatDuration(d2.Sub(d1)) } return "" } func resolveNumericOperand(name string, booking fleetsstorage.Booking, extras map[string]any) (float64, bool) { if v, ok := extras[name]; ok { switch val := v.(type) { case float64: return val, true case string: if f, err := strconv.ParseFloat(val, 64); err == nil { return f, true } } } return 0, false } func resolveDateOperand(name string, booking fleetsstorage.Booking, extras map[string]any) (time.Time, bool) { if strings.HasPrefix(name, "booking.") { field := strings.TrimPrefix(name, "booking.") switch field { case "startdate": return booking.Startdate, !booking.Startdate.IsZero() case "enddate": return booking.Enddate, !booking.Enddate.IsZero() case "unavailablefrom": return booking.Unavailablefrom, !booking.Unavailablefrom.IsZero() case "unavailableto": return booking.Unavailableto, !booking.Unavailableto.IsZero() } } if v, ok := extras[name]; ok { if dateStr, ok := v.(string); ok { for _, layout := range []string{"2006-01-02T15:04", "2006-01-02"} { if t, err := time.Parse(layout, dateStr); err == nil { return t, true } } } } return time.Time{}, false } func formatNumber(v float64) string { if v == math.Trunc(v) { return strconv.FormatInt(int64(v), 10) } return strconv.FormatFloat(v, 'f', 2, 64) } // resolveMetaStatuses returns a set of status names matching the given meta_status values. // metaValues is a set like {"open": true, "active": true}. func (h *ApplicationHandler) resolveMetaStatuses(metaValues map[string]bool) map[string]bool { result := map[string]bool{} options := getStatusOptions(h.config.Get("modules.vehicles.status_options")) for _, opt := range options { ms, _ := opt["meta_status"].(string) name, _ := opt["name"].(string) if metaValues[ms] { result[name] = true } } return result } func formatDuration(d time.Duration) string { if d < 0 { d = -d } totalHours := int(d.Hours()) days := totalHours / 24 hours := totalHours % 24 parts := []string{} if days > 0 { s := "jour" if days > 1 { s = "jours" } parts = append(parts, fmt.Sprintf("%d %s", days, s)) } if hours > 0 { s := "heure" if hours > 1 { s = "heures" } parts = append(parts, fmt.Sprintf("%d %s", hours, s)) } if len(parts) == 0 { return "0 heure" } return strings.Join(parts, " ") }