diff --git a/docs/dynamics-ax.md b/docs/dynamics-ax.md index 339b112..82cfc63 100644 --- a/docs/dynamics-ax.md +++ b/docs/dynamics-ax.md @@ -71,7 +71,7 @@ De eksisterende community-regler `COM-AX-001` til `-007` (support, SQL Server-ve ### Batchjob -Planlagt start er `BATCHJOB.ORIGSTARTDATETIME` (UTC) i AX 2009 og senere, og `BATCH.STARTDATE` + `STARTTIME` (lokal tid, sekunder efter midnat) i Axapta 3.0 og AX 4.0; de lokale tider regnes om til UTC med SQL Servers aktuelle forskel mellem `GETDATE()` og `GETUTCDATE()` (en kørsel lige over et sommertidsskift kan derfor ligge en time ved siden af). +Planlagt start er `BATCHJOB.ORIGSTARTDATETIME` (UTC) i AX 2009 og senere, og `BATCH.STARTDATE` + `STARTTIME` (lokal tid, sekunder efter midnat) i Axapta 3.0 og AX 4.0; de lokale tider regnes om til UTC i AX-tidszonen med de sommertidsregler, der gjaldt på datoen. AX-tidszonen er `SQLInsight:DynamicsAx:LocalTimeZone` (fx `Romance Standard Time`; AOS'ens tidszone), ellers agentens egen, når den lige nu har samme forskel til UTC som SQL Server. Passer ingen af dem, bruges SQL Servers aktuelle forskel, og en datanote siger, at tider fra før et sommertidsskift kan være en time forkerte. En tid i timen, der springes over om foråret, eller som kommer to gange om efteråret, regnes med normaltid (om efteråret den sidste af de to), så et job aldrig ser forsinket ud pga. skiftet. Hvert jobs historik er SQLInsights egen (`insight.AxBatchRuns`, gemt `EventDays` = 90 dage): i AX 2009 og senere fra `BATCHJOBHISTORY`, læst efter RECID; Axapta 3.0 og 4.0 gemmer ingen historik, så SQLInsight registrerer selv de kørsler, den ser blive afsluttet (kørselstiden måles fra første gang, den så jobbet køre, og korte kørsler, der starter og slutter mellem to målinger, kommer ikke med). Et job i Axapta 3.0 er klasse + firma + batchgruppe, fordi AX 3.0 laver en ny række for hver gentagelse. diff --git a/src/SQLInsight.Agent/AgentOptions.cs b/src/SQLInsight.Agent/AgentOptions.cs index 122af17..42732d6 100644 --- a/src/SQLInsight.Agent/AgentOptions.cs +++ b/src/SQLInsight.Agent/AgentOptions.cs @@ -182,6 +182,9 @@ public sealed class DynamicsAxOptions /// How many days back the forecasts look. public int ForecastBasisDays { get; set; } = 30; + /// The AOS's time zone (Windows or IANA id) for Axapta 3.0/4.0's local times; empty: the agent's own zone when it matches the SQL Server. + public string? LocalTimeZone { get; set; } + public Core.DynamicsAx.AxThresholds ToThresholds() => new() { BatchLateMinutes = Math.Max(1, BatchLateMinutes), @@ -201,5 +204,6 @@ public sealed class DynamicsAxOptions RecId64WarnPercent = Math.Clamp(RecId64WarnPercent, 1, 100), BlockingSeconds = Math.Max(1, BlockingSeconds), ForecastBasisDays = Math.Clamp(ForecastBasisDays, 2, 365), + LocalTimeZone = string.IsNullOrWhiteSpace(LocalTimeZone) ? null : LocalTimeZone.Trim(), }; } diff --git a/src/SQLInsight.Agent/appsettings.json b/src/SQLInsight.Agent/appsettings.json index c5f8190..9ed5ddc 100644 --- a/src/SQLInsight.Agent/appsettings.json +++ b/src/SQLInsight.Agent/appsettings.json @@ -60,6 +60,7 @@ "NumberSequenceWarnPercent": 80, "ForecastWarnDays": 90, "RecId32WarnPercent": 50 + // "LocalTimeZone": "Romance Standard Time" // AOS'ens tidszone for Axapta 3.0/4.0's lokale tider; tom: agentens egen }, "Instances": [ // Servere tilføjes nemmest i webbrugerfladen (Servere og databaser). Her kan de også stå, fx (se docs/installation.md): diff --git a/src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs b/src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs index f8e863d..5dbcbef 100644 --- a/src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs +++ b/src/SQLInsight.Collectors/DynamicsAx/AxBatchCollector.cs @@ -1,3 +1,4 @@ +using System.Globalization; using SQLInsight.Core.DynamicsAx; using SQLInsight.Core.Instances; @@ -34,7 +35,7 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds? var databases = new List(); foreach (var database in ax.Databases) { - databases.Add(await CollectDatabaseAsync(context, database, previous, history, firstSeen, recentKeys).ConfigureAwait(false)); + databases.Add(await CollectDatabaseAsync(context, database, previous, history, firstSeen, recentKeys, _thresholds.LocalTimeZone).ConfigureAwait(false)); } return new AxBatchSnapshot @@ -49,7 +50,7 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds? } private static async Task CollectDatabaseAsync(CollectorContext context, AxDatabase database, AxBatchSnapshot? previous, AxBatchHistory history, - Dictionary firstSeen, HashSet recentKeys) + Dictionary firstSeen, HashSet recentKeys, string? localTimeZone) { var name = database.Database; var earlier = previous?.Databases.FirstOrDefault(d => string.Equals(d.Database, name, StringComparison.OrdinalIgnoreCase)); @@ -69,6 +70,16 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds? } var notes = new List(query.Notes); + + // Axapta 3.0/4.0 store local times: they become UTC in the AX time zone (see AxLocalTime). + var (zone, zoneNote) = adapter.LocalBatchTimes && rows.Count > 0 + ? AxLocalTime.Resolve(localTimeZone, rows[0].ServerUtcOffsetMinutes, context.Now.UtcDateTime) + : (null, null); + if (zoneNote is not null) + { + notes.Add(zoneNote); + } + var newRuns = new List(); long? watermark = earlier?.HistoryWatermark; @@ -96,9 +107,9 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds? var prelim = new List<(AxBatchJobRow Row, AxBatchJob Job)>(); foreach (var row in rows) { - var seenKey = AxBatchHistory.Key(name, "#" + row.RecId); + var seenKey = AxBatchHistory.Key(name, "#" + row.RecId.ToString(CultureInfo.InvariantCulture)); DateTime? seenBefore = previous?.FirstSeenExecuting.TryGetValue(seenKey, out var seen) == true ? seen : null; - var job = adapter.Interpret(row, history, name, null); + var job = adapter.Interpret(row, history, name, null, zone); if (job.Status == AxBatchStatus.Executing) { firstSeen[seenKey] = seenBefore ?? context.Now.UtcDateTime; @@ -119,7 +130,8 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds? // Pass 2: the jobs with their history's statistics, the interesting ones first. var now = context.Now.UtcDateTime; var jobs = prelim - .Select(p => adapter.Interpret(p.Row, history, name, firstSeen.TryGetValue(AxBatchHistory.Key(name, "#" + p.Row.RecId), out var s) ? s : null)) + .Select(p => adapter.Interpret(p.Row, history, name, + firstSeen.TryGetValue(AxBatchHistory.Key(name, "#" + p.Row.RecId.ToString(CultureInfo.InvariantCulture)), out var s) ? s : null, zone)) .Where(j => j.Status is not (AxBatchStatus.Finished or AxBatchStatus.Canceled) || (j.EndedUtc ?? j.PlannedStartUtc ?? now) >= now - FinishedShown) .OrderBy(j => j.Status switch { AxBatchStatus.Executing => 0, AxBatchStatus.Error => 1, AxBatchStatus.Waiting => 2, _ => 3 }) .ThenByDescending(j => j.RecId) diff --git a/src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs b/src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs index 63beb4d..f235bac 100644 --- a/src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs +++ b/src/SQLInsight.Collectors/DynamicsAx/AxSequenceCollector.cs @@ -52,8 +52,17 @@ public sealed class AxSequenceCollector(AxDatabaseDetector detector, AxThreshold var numbersQuery = adapter.NumberSequences(database); notes.AddRange(numbersQuery.Notes); var (numberRows, numberProblem) = await AxReader.ReadAsync(context, database, numbersQuery, MapNumberSequence).ConfigureAwait(false); - var sequences = numberRows?.Select(r => adapter.Interpret(r, Samples(AxSequenceSnapshot.NumberSequenceKind, adapter.SequenceKey(r), r.Next), now, _thresholds.ForecastBasisDays)) - .ToList(); + // Axapta 3.0/4.0 store the last cleanup as local time: it becomes UTC in the AX time zone (see AxLocalTime). + var (zone, zoneNote) = adapter.LocalBatchTimes && numberRows is { Count: > 0 } + ? AxLocalTime.Resolve(_thresholds.LocalTimeZone, numberRows[0].ServerUtcOffsetMinutes, now) + : (null, null); + if (zoneNote is not null) + { + notes.Add(zoneNote); + } + + var sequences = numberRows?.Select(r => adapter.Interpret(r, Samples(AxSequenceSnapshot.NumberSequenceKind, adapter.SequenceKey(r), r.Next), now, + _thresholds.ForecastBasisDays, zone)).ToList(); var recIdQuery = adapter.RecIds(database); notes.AddRange(recIdQuery.Notes); diff --git a/src/SQLInsight.Core/DynamicsAx/AxLocalTime.cs b/src/SQLInsight.Core/DynamicsAx/AxLocalTime.cs new file mode 100644 index 0000000..308d4dd --- /dev/null +++ b/src/SQLInsight.Core/DynamicsAx/AxLocalTime.cs @@ -0,0 +1,73 @@ +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."); + } +} diff --git a/src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs b/src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs index 40770fc..0b626c3 100644 --- a/src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs +++ b/src/SQLInsight.Core/DynamicsAx/AxSchemaAdapter.cs @@ -180,10 +180,11 @@ public abstract class AxSchemaAdapter } /// - /// A time as stored by AX in UTC: a local date plus seconds since midnight (3.0/4.0, using the server's offset when - /// read), or a UTC datetime. AX's empty date (1900-01-01) is null. + /// A time as stored by AX in UTC: a local date plus seconds since midnight (3.0/4.0, in the AX time zone; see + /// ), or a UTC datetime. AX's empty date (1900-01-01) is null. /// - public DateTime? ToUtc(DateTime? date, int? seconds, int offsetMinutes) + /// The AX time zone for local times (); null: the server's offset. + public DateTime? ToUtc(DateTime? date, int? seconds, int offsetMinutes, TimeZoneInfo? zone = null) { if (date is not { } d || d.Year <= 1900) { @@ -195,11 +196,10 @@ public abstract class AxSchemaAdapter return DateTime.SpecifyKind(d, DateTimeKind.Utc); } - var local = d.Date.AddSeconds(Math.Clamp(seconds ?? 0, 0, 86_399)); - return DateTime.SpecifyKind(local.AddMinutes(offsetMinutes), DateTimeKind.Utc); + return AxLocalTime.ToUtc(d, seconds, offsetMinutes, zone); } - public AxBatchJob Interpret(AxBatchJobRow row, AxBatchHistory history, string database, DateTime? firstSeenExecuting) + public AxBatchJob Interpret(AxBatchJobRow row, AxBatchHistory history, string database, DateTime? firstSeenExecuting, TimeZoneInfo? zone = null) { ArgumentNullException.ThrowIfNull(row); ArgumentNullException.ThrowIfNull(history); @@ -216,9 +216,9 @@ public abstract class AxSchemaAdapter Group = Trim(row.Group), StatusCode = row.Status, Status = BatchStatus(row.Status), - PlannedStartUtc = ToUtc(row.PlannedDate, row.PlannedSeconds, row.ServerUtcOffsetMinutes), - StartedUtc = ToUtc(row.StartDate, row.StartSeconds, row.ServerUtcOffsetMinutes), - EndedUtc = ToUtc(row.EndDate, row.EndSeconds, row.ServerUtcOffsetMinutes), + PlannedStartUtc = ToUtc(row.PlannedDate, row.PlannedSeconds, row.ServerUtcOffsetMinutes, zone), + StartedUtc = ToUtc(row.StartDate, row.StartSeconds, row.ServerUtcOffsetMinutes, zone), + EndedUtc = ToUtc(row.EndDate, row.EndSeconds, row.ServerUtcOffsetMinutes, zone), FirstSeenExecutingUtc = firstSeenExecuting, HistoryRuns = runs.Count, MedianSeconds = median, @@ -247,7 +247,7 @@ public abstract class AxSchemaAdapter return null; } - return new AxBatchRun(job.Key, $"J{job.RecId.ToString(CultureInfo.InvariantCulture)}@{start:yyyyMMddHHmmss}", job.Caption, job.Company, start, ended, job.Status); + return new AxBatchRun(job.Key, "J" + job.RecId.ToString(CultureInfo.InvariantCulture) + "@" + start.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture), job.Caption, job.Company, start, ended, job.Status); } /// A run from AX's batch history (2009 and later). @@ -272,7 +272,7 @@ public abstract class AxSchemaAdapter return (Trim(row.Company) ?? "*") + "/" + row.Code.Trim(); } - public AxNumberSequence Interpret(AxNumberSequenceRow row, IEnumerable samples, DateTime nowUtc, int forecastDays) + public AxNumberSequence Interpret(AxNumberSequenceRow row, IEnumerable samples, DateTime nowUtc, int forecastDays, TimeZoneInfo? zone = null) { ArgumentNullException.ThrowIfNull(row); var digits = row.Format?.Count(c => c == '#') is > 0 and var n ? n : (int?)null; @@ -280,10 +280,11 @@ public abstract class AxSchemaAdapter var max = capacity is { } c && c < row.Highest ? c : row.Highest; var span = (double)max - row.Lowest + 1; var used = span <= 0 ? 100 : Math.Clamp((row.Next - (double)row.Lowest) / span * 100, 0, 100); - var remaining = Math.Max(0, max - row.Next + 1); - var lastClean = row.LastCleanSeconds is null && row.LastCleanDate is { } utcClean && utcClean.Year > 1900 - ? DateTime.SpecifyKind(utcClean, DateTimeKind.Utc) - : ToLocalUtc(row.LastCleanDate, row.LastCleanSeconds, row.ServerUtcOffsetMinutes); + var remaining = (long)Math.Min(long.MaxValue, Math.Max(0m, (decimal)max - row.Next + 1)); + // Axapta 3.0/4.0: a local date plus seconds (LATESTCLEANTIME may be missing: midnight); AX 2009 and later: one UTC datetime. + var lastClean = LocalBatchTimes + ? AxLocalTime.ToUtc(row.LastCleanDate, row.LastCleanSeconds ?? 0, row.ServerUtcOffsetMinutes, zone) + : row.LastCleanDate is { Year: > 1900 } utcClean ? DateTime.SpecifyKind(utcClean, DateTimeKind.Utc) : null; return new AxNumberSequence { Key = SequenceKey(row), @@ -339,9 +340,6 @@ public abstract class AxSchemaAdapter }; } - private static DateTime? ToLocalUtc(DateTime? date, int? seconds, int offsetMinutes) => - date is { } d && d.Year > 1900 ? DateTime.SpecifyKind(d.Date.AddSeconds(Math.Clamp(seconds ?? 0, 0, 86_399)).AddMinutes(offsetMinutes), DateTimeKind.Utc) : null; - protected static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); // ---------------------------------------------------------------- shared SQL builders diff --git a/src/SQLInsight.Core/DynamicsAx/AxThresholds.cs b/src/SQLInsight.Core/DynamicsAx/AxThresholds.cs index 279662f..a4d522a 100644 --- a/src/SQLInsight.Core/DynamicsAx/AxThresholds.cs +++ b/src/SQLInsight.Core/DynamicsAx/AxThresholds.cs @@ -54,4 +54,10 @@ public sealed record AxThresholds /// How many days back the forecasts look for their oldest sample. public int ForecastBasisDays { get; init; } = 30; + + /// + /// The time zone of the AOS (Windows or IANA id) for Axapta 3.0/4.0's local batch and cleanup times; null: the agent's + /// own zone when it matches the SQL Server (see ). + /// + public string? LocalTimeZone { get; init; } } diff --git a/src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs b/src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs index 8e70137..db9be85 100644 --- a/src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs +++ b/src/SQLInsight.Rules/DynamicsAx/DynamicsAxRules.cs @@ -179,7 +179,7 @@ public sealed class AxBatchLateRule : DynamicsAxBatchRule new Evidence("Batchgruppe", job.Group ?? "–"), .. AxVersions.HasUtcBatchTimes(database.Version) ? Array.Empty() - : [new Evidence("Tid", "AX gemmer lokal dato + sekunder efter midnat; omregnet til UTC med SQL Servers tidszone.")], + : [new Evidence("Tid", "AX gemmer lokal dato + sekunder efter midnat; omregnet til UTC i AX-tidszonen med de sommertidsregler, der gjaldt på datoen (SQLInsight:DynamicsAx:LocalTimeZone, ellers agentens tidszone; se datanoterne under Dynamics AX → Batchjob).")], ]); } } diff --git a/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs b/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs index a3bc948..c784ac8 100644 --- a/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs +++ b/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs @@ -396,3 +396,82 @@ public class AxBatchHistoryWatermarkTests Assert.Contains($"SELECT TOP ({AxSchemaAdapter.MaxHistoryRowsAfterWatermark}) ", AxSchemaAdapter.Ax2012.BatchHistory(database, 50_000)!.Sql, StringComparison.Ordinal); } } + +public class AxLocalTimeTests +{ + private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen"); + + private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Utc); + + [Fact] + public void A_time_from_before_a_dst_change_uses_the_offset_of_its_own_date() + { + // Read in winter (server offset -60), planned in summer: 10:00 CEST is 08:00Z, not 09:00Z. + Assert.Equal(Utc(2026, 8, 1, 8, 0), AxLocalTime.ToUtc(new DateTime(2026, 8, 1), 36_000, -60, Copenhagen)); + Assert.Equal(Utc(2026, 1, 10, 9, 0), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), 36_000, -120, Copenhagen)); + } + + [Fact] + public void The_skipped_and_the_repeated_hour_get_standard_time_and_never_throw() + { + // 29 March 2026 02:30 does not exist in Copenhagen; 25 October 2026 02:30 exists twice. + Assert.Equal(Utc(2026, 3, 29, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 9_000, -120, Copenhagen)); + Assert.Equal(Utc(2026, 10, 25, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 25), 9_000, -60, Copenhagen)); + + // A job planned 01:50 CET on the spring night, read at 01:00Z (03:00 CEST): 10 minutes late, not 70. + var planned = AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 6_600, -120, Copenhagen)!.Value; + Assert.Equal(TimeSpan.FromMinutes(10), Utc(2026, 3, 29, 1, 0) - planned); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(86_399, 86_399)] + [InlineData(86_400, 86_400)] + public void Seconds_from_midnight_up_to_the_end_of_the_day(int seconds, int expected) => + Assert.Equal(Utc(2026, 1, 9, 23, 0).AddSeconds(expected), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen)); + + [Theory] + [InlineData(-1)] + [InlineData(90_000)] + public void Seconds_outside_a_day_are_unknown(int seconds) => Assert.Null(AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen)); + + [Fact] + public void Ax_empty_date_is_unknown_and_utc_versions_are_not_converted_again() + { + Assert.Null(AxLocalTime.ToUtc(new DateTime(1900, 1, 1), 3_600, -60, Copenhagen)); + var utc = Utc(2026, 7, 1, 12, 0); + Assert.Equal(utc, AxSchemaAdapter.Ax2009.ToUtc(utc, null, -120, Copenhagen)); + } + + [Fact] + public void Without_a_zone_the_servers_offset_is_rounded_to_a_quarter_of_an_hour() + { + Assert.Equal(Utc(2026, 10, 2, 0, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 2), 9_000, -119, null)); + Assert.Equal(-120, AxLocalTime.RoundedOffset(-121)); + } + + [Fact] + public void The_zone_is_the_setting_else_the_agents_when_it_matches_the_server_else_the_servers_offset_with_a_note() + { + var summer = Utc(2026, 7, 1, 12, 0); + Assert.Equal("Europe/Copenhagen", AxLocalTime.Resolve("Europe/Copenhagen", 0, summer).Zone?.Id); + Assert.Same(Copenhagen, AxLocalTime.Resolve(null, -120, summer, Copenhagen).Zone); + + var (zone, note) = AxLocalTime.Resolve(null, 0, summer, Copenhagen); + Assert.Null(zone); + Assert.Contains("LocalTimeZone", note, StringComparison.Ordinal); + + var (unknown, unknownNote) = AxLocalTime.Resolve("Ingen/Zone", -120, summer, Copenhagen); + Assert.Null(unknown); + Assert.Contains("Ingen/Zone", unknownNote, StringComparison.Ordinal); + } + + [Fact] + public void Axapta30_cleanup_without_a_time_column_is_local_midnight_not_utc() + { + var row = new AxNumberSequenceRow(1, "Debitor", "dat", null, 1, 999_999, 10, "######", false, true, false, false, null, + new DateTime(2026, 7, 1), null, null, -120); + var sequence = AxSchemaAdapter.Ax30.Interpret(row, [], Utc(2026, 7, 2, 0, 0), 30, Copenhagen); + Assert.Equal(Utc(2026, 6, 30, 22, 0), sequence.LastCleanUtc); + } +}