605 lines
22 KiB
C#
605 lines
22 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using LittleShop.DTOs;
|
|
using LittleShop.Models;
|
|
|
|
namespace LittleShop.Services;
|
|
|
|
public interface IRoyalMailService
|
|
{
|
|
Task<CreateShipmentResult> CreateShipmentAsync(CreateShipmentRequest request);
|
|
Task<TrackingResult> GetTrackingInfoAsync(string trackingNumber);
|
|
Task<List<RoyalMailServiceOption>> GetAvailableServicesAsync(decimal weight, string country);
|
|
Task<decimal> CalculateShippingCostAsync(decimal weight, string country, string serviceCode);
|
|
Task<byte[]?> GenerateShippingLabelAsync(string shipmentId);
|
|
Task<bool> CancelShipmentAsync(string shipmentId);
|
|
}
|
|
|
|
public class RoyalMailShippingService : IRoyalMailService
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly ILogger<RoyalMailShippingService> _logger;
|
|
private readonly bool _isProduction;
|
|
private string? _accessToken;
|
|
private DateTime _tokenExpiry;
|
|
|
|
public RoyalMailShippingService(
|
|
HttpClient httpClient,
|
|
IConfiguration configuration,
|
|
ILogger<RoyalMailShippingService> logger)
|
|
{
|
|
_httpClient = httpClient;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
_isProduction = !string.IsNullOrEmpty(_configuration["RoyalMail:ClientId"]);
|
|
|
|
if (_isProduction)
|
|
{
|
|
_httpClient.BaseAddress = new Uri("https://api.royalmail.net/");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("Royal Mail service running in development mode (API credentials not configured)");
|
|
}
|
|
}
|
|
|
|
public async Task<CreateShipmentResult> CreateShipmentAsync(CreateShipmentRequest request)
|
|
{
|
|
if (!_isProduction)
|
|
{
|
|
return CreateMockShipmentResult(request);
|
|
}
|
|
|
|
try
|
|
{
|
|
await EnsureAuthenticatedAsync();
|
|
|
|
var payload = new
|
|
{
|
|
shipmentType = "Delivery",
|
|
serviceCode = request.ServiceCode ?? "1st Class",
|
|
recipientName = request.RecipientName,
|
|
recipientAddress = new
|
|
{
|
|
addressLine1 = request.AddressLine1,
|
|
addressLine2 = request.AddressLine2,
|
|
city = request.City,
|
|
postCode = request.PostCode,
|
|
country = request.Country
|
|
},
|
|
senderAddress = new
|
|
{
|
|
addressLine1 = _configuration["RoyalMail:SenderAddress1"] ?? "123 Business St",
|
|
city = _configuration["RoyalMail:SenderCity"] ?? "London",
|
|
postCode = _configuration["RoyalMail:SenderPostCode"] ?? "SW1A 1AA",
|
|
country = "United Kingdom"
|
|
},
|
|
weight = request.WeightInGrams,
|
|
dimensions = new
|
|
{
|
|
length = request.Length ?? 20,
|
|
width = request.Width ?? 15,
|
|
height = request.Height ?? 5
|
|
},
|
|
value = request.Value,
|
|
currency = "GBP",
|
|
reference = request.Reference
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(payload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
|
|
|
var response = await _httpClient.PostAsync("shipping/v2/shipments", content);
|
|
var responseContent = await response.Content.ReadAsStringAsync();
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var result = JsonSerializer.Deserialize<RoyalMailShipmentResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
return new CreateShipmentResult
|
|
{
|
|
Success = true,
|
|
ShipmentId = result?.ShipmentId ?? Guid.NewGuid().ToString(),
|
|
TrackingNumber = result?.TrackingNumber ?? GenerateMockTrackingNumber(),
|
|
LabelUrl = result?.LabelUrl,
|
|
EstimatedDeliveryDate = DateTime.UtcNow.AddDays(2),
|
|
Cost = request.EstimatedCost ?? 5.50m
|
|
};
|
|
}
|
|
else
|
|
{
|
|
_logger.LogError("Royal Mail API error: {StatusCode} - {Response}",
|
|
response.StatusCode, responseContent);
|
|
|
|
return new CreateShipmentResult
|
|
{
|
|
Success = false,
|
|
ErrorMessage = $"Royal Mail API error: {response.StatusCode}"
|
|
};
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error creating Royal Mail shipment");
|
|
return new CreateShipmentResult
|
|
{
|
|
Success = false,
|
|
ErrorMessage = ex.Message
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<TrackingResult> GetTrackingInfoAsync(string trackingNumber)
|
|
{
|
|
if (!_isProduction)
|
|
{
|
|
return CreateMockTrackingResult(trackingNumber);
|
|
}
|
|
|
|
try
|
|
{
|
|
await EnsureAuthenticatedAsync();
|
|
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
|
|
|
var response = await _httpClient.GetAsync($"tracking/v2/items/{trackingNumber}");
|
|
var responseContent = await response.Content.ReadAsStringAsync();
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var result = JsonSerializer.Deserialize<RoyalMailTrackingResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
return new TrackingResult
|
|
{
|
|
Success = true,
|
|
TrackingNumber = trackingNumber,
|
|
Status = result?.Status ?? "In Transit",
|
|
LastUpdate = result?.LastUpdate ?? DateTime.UtcNow,
|
|
EstimatedDelivery = result?.EstimatedDelivery,
|
|
TrackingEvents = result?.Events?.Select(e => new TrackingEvent
|
|
{
|
|
Timestamp = e.Timestamp,
|
|
Location = e.Location,
|
|
Description = e.Description,
|
|
Status = e.Status
|
|
}).ToList() ?? new List<TrackingEvent>()
|
|
};
|
|
}
|
|
else
|
|
{
|
|
return new TrackingResult
|
|
{
|
|
Success = false,
|
|
TrackingNumber = trackingNumber,
|
|
ErrorMessage = $"Tracking not found: {response.StatusCode}"
|
|
};
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting tracking info for {TrackingNumber}", trackingNumber);
|
|
return new TrackingResult
|
|
{
|
|
Success = false,
|
|
TrackingNumber = trackingNumber,
|
|
ErrorMessage = ex.Message
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<List<RoyalMailServiceOption>> GetAvailableServicesAsync(decimal weight, string country)
|
|
{
|
|
if (!_isProduction)
|
|
{
|
|
return CreateMockServices(weight, country);
|
|
}
|
|
|
|
try
|
|
{
|
|
await EnsureAuthenticatedAsync();
|
|
|
|
var query = $"services?weight={weight}&country={country}";
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
|
|
|
var response = await _httpClient.GetAsync($"shipping/v2/{query}");
|
|
var responseContent = await response.Content.ReadAsStringAsync();
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var services = JsonSerializer.Deserialize<List<RoyalMailShippingServiceResponse>>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
return services?.Select(s => new RoyalMailServiceOption
|
|
{
|
|
Code = s.Code,
|
|
Name = s.Name,
|
|
Description = s.Description,
|
|
Price = s.Price,
|
|
EstimatedDeliveryDays = s.EstimatedDeliveryDays,
|
|
MaxWeight = s.MaxWeight,
|
|
SupportsTracking = s.SupportsTracking
|
|
}).ToList() ?? new List<RoyalMailServiceOption>();
|
|
}
|
|
|
|
return new List<RoyalMailServiceOption>();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting Royal Mail services");
|
|
return CreateMockServices(weight, country);
|
|
}
|
|
}
|
|
|
|
public async Task<decimal> CalculateShippingCostAsync(decimal weight, string country, string serviceCode)
|
|
{
|
|
if (!_isProduction)
|
|
{
|
|
return CalculateMockShippingCost(weight, country, serviceCode);
|
|
}
|
|
|
|
var services = await GetAvailableServicesAsync(weight, country);
|
|
var service = services.FirstOrDefault(s => s.Code == serviceCode);
|
|
return service?.Price ?? 0m;
|
|
}
|
|
|
|
public async Task<byte[]?> GenerateShippingLabelAsync(string shipmentId)
|
|
{
|
|
if (!_isProduction)
|
|
{
|
|
return GenerateMockLabel(shipmentId);
|
|
}
|
|
|
|
try
|
|
{
|
|
await EnsureAuthenticatedAsync();
|
|
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
|
|
|
var response = await _httpClient.GetAsync($"shipping/v2/shipments/{shipmentId}/label");
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
return await response.Content.ReadAsByteArrayAsync();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error generating shipping label for {ShipmentId}", shipmentId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> CancelShipmentAsync(string shipmentId)
|
|
{
|
|
if (!_isProduction)
|
|
{
|
|
_logger.LogInformation("Mock: Cancelled shipment {ShipmentId}", shipmentId);
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
await EnsureAuthenticatedAsync();
|
|
|
|
_httpClient.DefaultRequestHeaders.Authorization =
|
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
|
|
|
var response = await _httpClient.DeleteAsync($"shipping/v2/shipments/{shipmentId}");
|
|
return response.IsSuccessStatusCode;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error cancelling shipment {ShipmentId}", shipmentId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task EnsureAuthenticatedAsync()
|
|
{
|
|
if (!string.IsNullOrEmpty(_accessToken) && DateTime.UtcNow < _tokenExpiry)
|
|
return;
|
|
|
|
var clientId = _configuration["RoyalMail:ClientId"];
|
|
var clientSecret = _configuration["RoyalMail:ClientSecret"];
|
|
|
|
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
|
|
{
|
|
throw new InvalidOperationException("Royal Mail API credentials not configured");
|
|
}
|
|
|
|
var authPayload = new
|
|
{
|
|
grant_type = "client_credentials",
|
|
client_id = clientId,
|
|
client_secret = clientSecret
|
|
};
|
|
|
|
var json = JsonSerializer.Serialize(authPayload);
|
|
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var response = await _httpClient.PostAsync("oauth2/token", content);
|
|
var responseContent = await response.Content.ReadAsStringAsync();
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var authResult = JsonSerializer.Deserialize<RoyalMailTokenResponse>(responseContent, new JsonSerializerOptions
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
|
|
_accessToken = authResult?.AccessToken;
|
|
_tokenExpiry = DateTime.UtcNow.AddSeconds(authResult?.ExpiresIn ?? 3600);
|
|
|
|
_logger.LogInformation("Successfully authenticated with Royal Mail API");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogError("Failed to authenticate with Royal Mail API: {StatusCode} - {Response}",
|
|
response.StatusCode, responseContent);
|
|
throw new InvalidOperationException("Royal Mail API authentication failed");
|
|
}
|
|
}
|
|
|
|
// Mock methods for development
|
|
private CreateShipmentResult CreateMockShipmentResult(CreateShipmentRequest request)
|
|
{
|
|
var trackingNumber = GenerateMockTrackingNumber();
|
|
|
|
_logger.LogInformation("Mock: Created shipment for {Recipient} to {City}, {Country} - Tracking: {TrackingNumber}",
|
|
request.RecipientName, request.City, request.Country, trackingNumber);
|
|
|
|
return new CreateShipmentResult
|
|
{
|
|
Success = true,
|
|
ShipmentId = Guid.NewGuid().ToString(),
|
|
TrackingNumber = trackingNumber,
|
|
EstimatedDeliveryDate = DateTime.UtcNow.AddDays(GetMockDeliveryDays(request.Country)),
|
|
Cost = CalculateMockShippingCost(request.WeightInGrams, request.Country, request.ServiceCode ?? "1st Class"),
|
|
LabelUrl = $"https://mock-royal-mail/labels/{trackingNumber}.pdf"
|
|
};
|
|
}
|
|
|
|
private TrackingResult CreateMockTrackingResult(string trackingNumber)
|
|
{
|
|
var statuses = new[] { "Collected", "In Transit", "Out for Delivery", "Delivered" };
|
|
var randomStatus = statuses[new Random().Next(statuses.Length)];
|
|
|
|
return new TrackingResult
|
|
{
|
|
Success = true,
|
|
TrackingNumber = trackingNumber,
|
|
Status = randomStatus,
|
|
LastUpdate = DateTime.UtcNow.AddHours(-2),
|
|
EstimatedDelivery = DateTime.UtcNow.AddDays(1),
|
|
TrackingEvents = new List<TrackingEvent>
|
|
{
|
|
new() { Timestamp = DateTime.UtcNow.AddDays(-1), Location = "London", Description = "Item collected", Status = "Collected" },
|
|
new() { Timestamp = DateTime.UtcNow.AddHours(-6), Location = "Sorting Office", Description = "Item processed", Status = "In Transit" },
|
|
new() { Timestamp = DateTime.UtcNow.AddHours(-2), Location = "Local Depot", Description = "Item arriving at delivery office", Status = randomStatus }
|
|
}
|
|
};
|
|
}
|
|
|
|
private List<RoyalMailServiceOption> CreateMockServices(decimal weight, string country)
|
|
{
|
|
var services = new List<RoyalMailServiceOption>();
|
|
|
|
if (country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
services.Add(new RoyalMailServiceOption
|
|
{
|
|
Code = "1st Class",
|
|
Name = "First Class",
|
|
Description = "Next working day delivery",
|
|
Price = CalculateMockShippingCost(weight, country, "1st Class"),
|
|
EstimatedDeliveryDays = 1,
|
|
MaxWeight = 2000,
|
|
SupportsTracking = true
|
|
});
|
|
|
|
services.Add(new RoyalMailServiceOption
|
|
{
|
|
Code = "2nd Class",
|
|
Name = "Second Class",
|
|
Description = "2-3 working days delivery",
|
|
Price = CalculateMockShippingCost(weight, country, "2nd Class"),
|
|
EstimatedDeliveryDays = 3,
|
|
MaxWeight = 2000,
|
|
SupportsTracking = false
|
|
});
|
|
|
|
services.Add(new RoyalMailServiceOption
|
|
{
|
|
Code = "Signed For",
|
|
Name = "Signed For 1st Class",
|
|
Description = "Next working day with signature",
|
|
Price = CalculateMockShippingCost(weight, country, "Signed For"),
|
|
EstimatedDeliveryDays = 1,
|
|
MaxWeight = 2000,
|
|
SupportsTracking = true
|
|
});
|
|
}
|
|
else
|
|
{
|
|
services.Add(new RoyalMailServiceOption
|
|
{
|
|
Code = "International Standard",
|
|
Name = "International Standard",
|
|
Description = "5-7 working days to Europe",
|
|
Price = CalculateMockShippingCost(weight, country, "International Standard"),
|
|
EstimatedDeliveryDays = 7,
|
|
MaxWeight = 2000,
|
|
SupportsTracking = true
|
|
});
|
|
|
|
services.Add(new RoyalMailServiceOption
|
|
{
|
|
Code = "International Tracked",
|
|
Name = "International Tracked & Signed",
|
|
Description = "3-5 working days with tracking",
|
|
Price = CalculateMockShippingCost(weight, country, "International Tracked"),
|
|
EstimatedDeliveryDays = 5,
|
|
MaxWeight = 2000,
|
|
SupportsTracking = true
|
|
});
|
|
}
|
|
|
|
return services;
|
|
}
|
|
|
|
private decimal CalculateMockShippingCost(decimal weight, string country, string serviceCode)
|
|
{
|
|
var baseRate = country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase) ? 2.50m : 8.50m;
|
|
var weightRate = weight / 100 * 0.50m; // £0.50 per 100g
|
|
|
|
var serviceMultiplier = serviceCode switch
|
|
{
|
|
"1st Class" => 1.2m,
|
|
"2nd Class" => 1.0m,
|
|
"Signed For" => 1.5m,
|
|
"International Standard" => 2.0m,
|
|
"International Tracked" => 3.0m,
|
|
_ => 1.0m
|
|
};
|
|
|
|
return Math.Round(baseRate + weightRate * serviceMultiplier, 2);
|
|
}
|
|
|
|
private int GetMockDeliveryDays(string country)
|
|
{
|
|
return country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase) ? 2 : 7;
|
|
}
|
|
|
|
private string GenerateMockTrackingNumber()
|
|
{
|
|
var random = new Random();
|
|
return $"RM{random.Next(100000000, 999999999)}GB";
|
|
}
|
|
|
|
private byte[] GenerateMockLabel(string shipmentId)
|
|
{
|
|
// Generate a simple mock PDF label (in production this would be the actual Royal Mail label)
|
|
var mockPdfContent = $"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>\nendobj\n4 0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Royal Mail Label - {shipmentId}) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000174 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n267\n%%EOF";
|
|
return Encoding.UTF8.GetBytes(mockPdfContent);
|
|
}
|
|
}
|
|
|
|
// DTOs and response models
|
|
public class CreateShipmentRequest
|
|
{
|
|
public string RecipientName { get; set; } = string.Empty;
|
|
public string AddressLine1 { get; set; } = string.Empty;
|
|
public string? AddressLine2 { get; set; }
|
|
public string City { get; set; } = string.Empty;
|
|
public string PostCode { get; set; } = string.Empty;
|
|
public string Country { get; set; } = "United Kingdom";
|
|
public decimal WeightInGrams { get; set; }
|
|
public decimal? Length { get; set; }
|
|
public decimal? Width { get; set; }
|
|
public decimal? Height { get; set; }
|
|
public decimal Value { get; set; }
|
|
public string? ServiceCode { get; set; }
|
|
public string? Reference { get; set; }
|
|
public decimal? EstimatedCost { get; set; }
|
|
}
|
|
|
|
public class CreateShipmentResult
|
|
{
|
|
public bool Success { get; set; }
|
|
public string? ShipmentId { get; set; }
|
|
public string? TrackingNumber { get; set; }
|
|
public string? LabelUrl { get; set; }
|
|
public DateTime? EstimatedDeliveryDate { get; set; }
|
|
public decimal Cost { get; set; }
|
|
public string? ErrorMessage { get; set; }
|
|
}
|
|
|
|
public class TrackingResult
|
|
{
|
|
public bool Success { get; set; }
|
|
public string TrackingNumber { get; set; } = string.Empty;
|
|
public string Status { get; set; } = string.Empty;
|
|
public DateTime LastUpdate { get; set; }
|
|
public DateTime? EstimatedDelivery { get; set; }
|
|
public List<TrackingEvent> TrackingEvents { get; set; } = new();
|
|
public string? ErrorMessage { get; set; }
|
|
}
|
|
|
|
public class TrackingEvent
|
|
{
|
|
public DateTime Timestamp { get; set; }
|
|
public string Location { get; set; } = string.Empty;
|
|
public string Description { get; set; } = string.Empty;
|
|
public string Status { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class RoyalMailServiceOption
|
|
{
|
|
public string Code { get; set; } = string.Empty;
|
|
public string Name { get; set; } = string.Empty;
|
|
public string Description { get; set; } = string.Empty;
|
|
public decimal Price { get; set; }
|
|
public int EstimatedDeliveryDays { get; set; }
|
|
public decimal MaxWeight { get; set; }
|
|
public bool SupportsTracking { get; set; }
|
|
}
|
|
|
|
// API Response models
|
|
public class RoyalMailTokenResponse
|
|
{
|
|
public string AccessToken { get; set; } = string.Empty;
|
|
public int ExpiresIn { get; set; }
|
|
public string TokenType { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class RoyalMailShipmentResponse
|
|
{
|
|
public string ShipmentId { get; set; } = string.Empty;
|
|
public string TrackingNumber { get; set; } = string.Empty;
|
|
public string? LabelUrl { get; set; }
|
|
public DateTime EstimatedDelivery { get; set; }
|
|
}
|
|
|
|
public class RoyalMailTrackingResponse
|
|
{
|
|
public string Status { get; set; } = string.Empty;
|
|
public DateTime LastUpdate { get; set; }
|
|
public DateTime? EstimatedDelivery { get; set; }
|
|
public List<RoyalMailTrackingEvent> Events { get; set; } = new();
|
|
}
|
|
|
|
public class RoyalMailTrackingEvent
|
|
{
|
|
public DateTime Timestamp { get; set; }
|
|
public string Location { get; set; } = string.Empty;
|
|
public string Description { get; set; } = string.Empty;
|
|
public string Status { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class RoyalMailShippingServiceResponse
|
|
{
|
|
public string Code { get; set; } = string.Empty;
|
|
public string Name { get; set; } = string.Empty;
|
|
public string Description { get; set; } = string.Empty;
|
|
public decimal Price { get; set; }
|
|
public int EstimatedDeliveryDays { get; set; }
|
|
public decimal MaxWeight { get; set; }
|
|
public bool SupportsTracking { get; set; }
|
|
} |