namespace SQLInsight.Core.DynamicsAx;
///
/// Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus seconds since midnight, in the time of the
/// machine that wrote them (the AOS, or the batch client in a 2-tier installation). They become UTC with the rules of
/// that time zone on that date, so a time from before a change between summer and winter time is not an hour off.
///
/// - The zone is SQLInsight:DynamicsAx:LocalTimeZone when set (a Windows or IANA id), else the agent's own zone when
/// its offset right now is the SQL Server's (the usual case: agent, SQL Server and AOS in one country).
/// - Otherwise the SQL Server's current offset is used for every time, with a note that times from the other side of a
/// change can be an hour off.
/// - A local time that does not exist (the hour skipped in spring) or exists twice (the hour repeated in autumn) gets
/// the zone's standard offset: deterministic, never an exception, and in autumn the later of the two moments, so a job
/// is never reported late because of the repeated hour.
///
///
public static class AxLocalTime
{
/// The setting that names the AX time zone.
public const string Setting = "SQLInsight:DynamicsAx:LocalTimeZone";
///
/// A local AX date and seconds since midnight as UTC. AX's empty date (1900-01-01 or earlier) and seconds outside
/// 0..86400 are unknown (null); 86400 is midnight at the end of the day.
///
/// DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the SQL Server when read (UTC − local).
public static DateTime? ToUtc(DateTime? date, int? seconds, int serverOffsetMinutes, TimeZoneInfo? zone)
{
if (date is not { } d || d.Year <= 1900 || seconds is < 0 or > 86_400)
{
return null;
}
var local = DateTime.SpecifyKind(d.Date.AddSeconds(seconds ?? 0), DateTimeKind.Unspecified);
var offset = zone is null ? TimeSpan.FromMinutes(-RoundedOffset(serverOffsetMinutes)) : zone.GetUtcOffset(local);
return DateTime.SpecifyKind(local - offset, DateTimeKind.Utc);
}
///
/// The SQL Server's offset to the nearest quarter of an hour: GETDATE() and GETUTCDATE() are two calls, and
/// DATEDIFF(minute) counts minute boundaries, so the raw value can be a minute off.
///
public static int RoundedOffset(int minutes) => (int)Math.Round(minutes / 15.0, MidpointRounding.AwayFromZero) * 15;
///
/// The zone for a database's local AX times, or null to use the SQL Server's current offset, and a note (Danish) when
/// that is less exact than it could be.
///
/// SQLInsight:DynamicsAx:LocalTimeZone.
/// The SQL Server's offset (UTC − local) when read, or null when nothing was read.
/// The agent's own zone (TimeZoneInfo.Local; a parameter for tests).
public static (TimeZoneInfo? Zone, string? Note) Resolve(string? configured, int? serverOffsetMinutes, DateTime nowUtc, TimeZoneInfo? agentZone = null)
{
if (!string.IsNullOrWhiteSpace(configured))
{
return TimeZoneInfo.TryFindSystemTimeZoneById(configured.Trim(), out var zone)
? (zone, null)
: (null, $"Tidszonen \"{configured.Trim()}\" ({Setting}) findes ikke på agentens server. AX' lokale tider omregnes i stedet med SQL Servers "
+ "nuværende forskel til UTC, så tider fra før et skift mellem sommer- og vintertid kan være en time forkerte.");
}
agentZone ??= TimeZoneInfo.Local;
var now = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
if (serverOffsetMinutes is not { } server || -(int)Math.Round(agentZone.GetUtcOffset(now).TotalMinutes) == RoundedOffset(server))
{
return (agentZone, null);
}
return (null, $"AX' lokale tider (Axapta 3.0/4.0) omregnes med SQL Servers nuværende forskel til UTC, fordi agentens tidszone ({agentZone.Id}) "
+ $"har en anden forskel lige nu. Tider fra før et skift mellem sommer- og vintertid kan derfor være en time forkerte. Sæt {Setting} "
+ "til AOS'ens tidszone for at få dem helt rigtige.");
}
}