Files
parcoursmob/core/application/vehicles-management.go
Arnaud Delcasse 039111c36c
Some checks failed
Build and Push Docker Image / build_and_push (push) Failing after 2m37s
feat: extra properties dynamiques, filtrage meta_status et alertes retard
2026-02-27 16:40:46 +01:00

1083 lines
30 KiB
Go
Executable File

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, " ")
}