Review 1.4: AX 3.0/4.0 local times in the AX time zone, with its DST rules

Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus
seconds. They were turned into UTC with the SQL Server's offset at collection
time, so every time from the other side of a DST change was an hour off (a job
planned 01:50 CET on the spring night looked 70 minutes late at 03:00 CEST),
and a SQL Server running UTC behind a CET AOS shifted every time.

AxLocalTime converts with the rules of the AX time zone on that date:
SQLInsight:DynamicsAx:LocalTimeZone (Windows or IANA id), else the agent's own
zone when its offset matches the SQL Server's, else the server's offset (to the
nearest quarter hour) with a Danish data note. The skipped spring hour and the
repeated autumn hour get standard time (deterministic, never throws, never a
false "late"); seconds outside 0..86400 are unknown and 86400 is midnight. The
AX 3.0 cleanup time is local by version, also when LATESTCLEANTIME is missing.
AX 2009+ UTC times are untouched. Also: remaining numbers cannot overflow for
HIGHEST = Int64.MaxValue, and run keys use the invariant culture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Søren Eggert Lundsteen Olsen 2026-10-02 14:19:17 +02:00
parent fdaa7e76bf
commit 66ed4e11b3
10 changed files with 209 additions and 27 deletions

View file

@ -71,7 +71,7 @@ De eksisterende community-regler `COM-AX-001` til `-007` (support, SQL Server-ve
### Batchjob ### 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. 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.

View file

@ -182,6 +182,9 @@ public sealed class DynamicsAxOptions
/// <summary>How many days back the forecasts look.</summary> /// <summary>How many days back the forecasts look.</summary>
public int ForecastBasisDays { get; set; } = 30; public int ForecastBasisDays { get; set; } = 30;
/// <summary>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.</summary>
public string? LocalTimeZone { get; set; }
public Core.DynamicsAx.AxThresholds ToThresholds() => new() public Core.DynamicsAx.AxThresholds ToThresholds() => new()
{ {
BatchLateMinutes = Math.Max(1, BatchLateMinutes), BatchLateMinutes = Math.Max(1, BatchLateMinutes),
@ -201,5 +204,6 @@ public sealed class DynamicsAxOptions
RecId64WarnPercent = Math.Clamp(RecId64WarnPercent, 1, 100), RecId64WarnPercent = Math.Clamp(RecId64WarnPercent, 1, 100),
BlockingSeconds = Math.Max(1, BlockingSeconds), BlockingSeconds = Math.Max(1, BlockingSeconds),
ForecastBasisDays = Math.Clamp(ForecastBasisDays, 2, 365), ForecastBasisDays = Math.Clamp(ForecastBasisDays, 2, 365),
LocalTimeZone = string.IsNullOrWhiteSpace(LocalTimeZone) ? null : LocalTimeZone.Trim(),
}; };
} }

View file

@ -60,6 +60,7 @@
"NumberSequenceWarnPercent": 80, "NumberSequenceWarnPercent": 80,
"ForecastWarnDays": 90, "ForecastWarnDays": 90,
"RecId32WarnPercent": 50 "RecId32WarnPercent": 50
// "LocalTimeZone": "Romance Standard Time" // AOS'ens tidszone for Axapta 3.0/4.0's lokale tider; tom: agentens egen
}, },
"Instances": [ "Instances": [
// Servere tilføjes nemmest i webbrugerfladen (Servere og databaser). Her kan de også stå, fx (se docs/installation.md): // Servere tilføjes nemmest i webbrugerfladen (Servere og databaser). Her kan de også stå, fx (se docs/installation.md):

View file

@ -1,3 +1,4 @@
using System.Globalization;
using SQLInsight.Core.DynamicsAx; using SQLInsight.Core.DynamicsAx;
using SQLInsight.Core.Instances; using SQLInsight.Core.Instances;
@ -34,7 +35,7 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds?
var databases = new List<AxBatchDatabase>(); var databases = new List<AxBatchDatabase>();
foreach (var database in ax.Databases) 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 return new AxBatchSnapshot
@ -49,7 +50,7 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds?
} }
private static async Task<AxBatchDatabase> CollectDatabaseAsync(CollectorContext context, AxDatabase database, AxBatchSnapshot? previous, AxBatchHistory history, private static async Task<AxBatchDatabase> CollectDatabaseAsync(CollectorContext context, AxDatabase database, AxBatchSnapshot? previous, AxBatchHistory history,
Dictionary<string, DateTime> firstSeen, HashSet<string> recentKeys) Dictionary<string, DateTime> firstSeen, HashSet<string> recentKeys, string? localTimeZone)
{ {
var name = database.Database; var name = database.Database;
var earlier = previous?.Databases.FirstOrDefault(d => string.Equals(d.Database, name, StringComparison.OrdinalIgnoreCase)); 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<string>(query.Notes); var notes = new List<string>(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<AxBatchRun>(); var newRuns = new List<AxBatchRun>();
long? watermark = earlier?.HistoryWatermark; long? watermark = earlier?.HistoryWatermark;
@ -96,9 +107,9 @@ public sealed class AxBatchCollector(AxDatabaseDetector detector, AxThresholds?
var prelim = new List<(AxBatchJobRow Row, AxBatchJob Job)>(); var prelim = new List<(AxBatchJobRow Row, AxBatchJob Job)>();
foreach (var row in rows) 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; 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) if (job.Status == AxBatchStatus.Executing)
{ {
firstSeen[seenKey] = seenBefore ?? context.Now.UtcDateTime; 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. // Pass 2: the jobs with their history's statistics, the interesting ones first.
var now = context.Now.UtcDateTime; var now = context.Now.UtcDateTime;
var jobs = prelim 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) .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 }) .OrderBy(j => j.Status switch { AxBatchStatus.Executing => 0, AxBatchStatus.Error => 1, AxBatchStatus.Waiting => 2, _ => 3 })
.ThenByDescending(j => j.RecId) .ThenByDescending(j => j.RecId)

View file

@ -52,8 +52,17 @@ public sealed class AxSequenceCollector(AxDatabaseDetector detector, AxThreshold
var numbersQuery = adapter.NumberSequences(database); var numbersQuery = adapter.NumberSequences(database);
notes.AddRange(numbersQuery.Notes); notes.AddRange(numbersQuery.Notes);
var (numberRows, numberProblem) = await AxReader.ReadAsync(context, database, numbersQuery, MapNumberSequence).ConfigureAwait(false); 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)) // Axapta 3.0/4.0 store the last cleanup as local time: it becomes UTC in the AX time zone (see AxLocalTime).
.ToList(); 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); var recIdQuery = adapter.RecIds(database);
notes.AddRange(recIdQuery.Notes); notes.AddRange(recIdQuery.Notes);

View file

@ -0,0 +1,73 @@
namespace SQLInsight.Core.DynamicsAx;
/// <summary>
/// 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.
/// <list type="bullet">
/// <item>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).</item>
/// <item>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.</item>
/// <item>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.</item>
/// </list>
/// </summary>
public static class AxLocalTime
{
/// <summary>The setting that names the AX time zone.</summary>
public const string Setting = "SQLInsight:DynamicsAx:LocalTimeZone";
/// <summary>
/// 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.
/// </summary>
/// <param name="serverOffsetMinutes">DATEDIFF(minute, GETDATE(), GETUTCDATE()) on the SQL Server when read (UTC − local).</param>
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);
}
/// <summary>
/// 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.
/// </summary>
public static int RoundedOffset(int minutes) => (int)Math.Round(minutes / 15.0, MidpointRounding.AwayFromZero) * 15;
/// <summary>
/// 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.
/// </summary>
/// <param name="configured">SQLInsight:DynamicsAx:LocalTimeZone.</param>
/// <param name="serverOffsetMinutes">The SQL Server's offset (UTC − local) when read, or null when nothing was read.</param>
/// <param name="agentZone">The agent's own zone (TimeZoneInfo.Local; a parameter for tests).</param>
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.");
}
}

View file

@ -180,10 +180,11 @@ public abstract class AxSchemaAdapter
} }
/// <summary> /// <summary>
/// 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 /// 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
/// read), or a UTC datetime. AX's empty date (1900-01-01) is null. /// <see cref="AxLocalTime"/>), or a UTC datetime. AX's empty date (1900-01-01) is null.
/// </summary> /// </summary>
public DateTime? ToUtc(DateTime? date, int? seconds, int offsetMinutes) /// <param name="zone">The AX time zone for local times (<see cref="AxLocalTime.Resolve"/>); null: the server's offset.</param>
public DateTime? ToUtc(DateTime? date, int? seconds, int offsetMinutes, TimeZoneInfo? zone = null)
{ {
if (date is not { } d || d.Year <= 1900) if (date is not { } d || d.Year <= 1900)
{ {
@ -195,11 +196,10 @@ public abstract class AxSchemaAdapter
return DateTime.SpecifyKind(d, DateTimeKind.Utc); return DateTime.SpecifyKind(d, DateTimeKind.Utc);
} }
var local = d.Date.AddSeconds(Math.Clamp(seconds ?? 0, 0, 86_399)); return AxLocalTime.ToUtc(d, seconds, offsetMinutes, zone);
return DateTime.SpecifyKind(local.AddMinutes(offsetMinutes), DateTimeKind.Utc);
} }
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(row);
ArgumentNullException.ThrowIfNull(history); ArgumentNullException.ThrowIfNull(history);
@ -216,9 +216,9 @@ public abstract class AxSchemaAdapter
Group = Trim(row.Group), Group = Trim(row.Group),
StatusCode = row.Status, StatusCode = row.Status,
Status = BatchStatus(row.Status), Status = BatchStatus(row.Status),
PlannedStartUtc = ToUtc(row.PlannedDate, row.PlannedSeconds, row.ServerUtcOffsetMinutes), PlannedStartUtc = ToUtc(row.PlannedDate, row.PlannedSeconds, row.ServerUtcOffsetMinutes, zone),
StartedUtc = ToUtc(row.StartDate, row.StartSeconds, row.ServerUtcOffsetMinutes), StartedUtc = ToUtc(row.StartDate, row.StartSeconds, row.ServerUtcOffsetMinutes, zone),
EndedUtc = ToUtc(row.EndDate, row.EndSeconds, row.ServerUtcOffsetMinutes), EndedUtc = ToUtc(row.EndDate, row.EndSeconds, row.ServerUtcOffsetMinutes, zone),
FirstSeenExecutingUtc = firstSeenExecuting, FirstSeenExecutingUtc = firstSeenExecuting,
HistoryRuns = runs.Count, HistoryRuns = runs.Count,
MedianSeconds = median, MedianSeconds = median,
@ -247,7 +247,7 @@ public abstract class AxSchemaAdapter
return null; 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);
} }
/// <summary>A run from AX's batch history (2009 and later).</summary> /// <summary>A run from AX's batch history (2009 and later).</summary>
@ -272,7 +272,7 @@ public abstract class AxSchemaAdapter
return (Trim(row.Company) ?? "*") + "/" + row.Code.Trim(); return (Trim(row.Company) ?? "*") + "/" + row.Code.Trim();
} }
public AxNumberSequence Interpret(AxNumberSequenceRow row, IEnumerable<AxSample> samples, DateTime nowUtc, int forecastDays) public AxNumberSequence Interpret(AxNumberSequenceRow row, IEnumerable<AxSample> samples, DateTime nowUtc, int forecastDays, TimeZoneInfo? zone = null)
{ {
ArgumentNullException.ThrowIfNull(row); ArgumentNullException.ThrowIfNull(row);
var digits = row.Format?.Count(c => c == '#') is > 0 and var n ? n : (int?)null; 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 max = capacity is { } c && c < row.Highest ? c : row.Highest;
var span = (double)max - row.Lowest + 1; var span = (double)max - row.Lowest + 1;
var used = span <= 0 ? 100 : Math.Clamp((row.Next - (double)row.Lowest) / span * 100, 0, 100); 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 remaining = (long)Math.Min(long.MaxValue, Math.Max(0m, (decimal)max - row.Next + 1));
var lastClean = row.LastCleanSeconds is null && row.LastCleanDate is { } utcClean && utcClean.Year > 1900 // Axapta 3.0/4.0: a local date plus seconds (LATESTCLEANTIME may be missing: midnight); AX 2009 and later: one UTC datetime.
? DateTime.SpecifyKind(utcClean, DateTimeKind.Utc) var lastClean = LocalBatchTimes
: ToLocalUtc(row.LastCleanDate, row.LastCleanSeconds, row.ServerUtcOffsetMinutes); ? 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 return new AxNumberSequence
{ {
Key = SequenceKey(row), 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(); protected static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// ---------------------------------------------------------------- shared SQL builders // ---------------------------------------------------------------- shared SQL builders

View file

@ -54,4 +54,10 @@ public sealed record AxThresholds
/// <summary>How many days back the forecasts look for their oldest sample.</summary> /// <summary>How many days back the forecasts look for their oldest sample.</summary>
public int ForecastBasisDays { get; init; } = 30; public int ForecastBasisDays { get; init; } = 30;
/// <summary>
/// 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 <see cref="AxLocalTime.Resolve"/>).
/// </summary>
public string? LocalTimeZone { get; init; }
} }

View file

@ -179,7 +179,7 @@ public sealed class AxBatchLateRule : DynamicsAxBatchRule
new Evidence("Batchgruppe", job.Group ?? "–"), new Evidence("Batchgruppe", job.Group ?? "–"),
.. AxVersions.HasUtcBatchTimes(database.Version) .. AxVersions.HasUtcBatchTimes(database.Version)
? Array.Empty<Evidence>() ? Array.Empty<Evidence>()
: [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).")],
]); ]);
} }
} }

View file

@ -396,3 +396,82 @@ public class AxBatchHistoryWatermarkTests
Assert.Contains($"SELECT TOP ({AxSchemaAdapter.MaxHistoryRowsAfterWatermark}) ", AxSchemaAdapter.Ax2012.BatchHistory(database, 50_000)!.Sql, StringComparison.Ordinal); 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);
}
}