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:
parent
fdaa7e76bf
commit
66ed4e11b3
10 changed files with 209 additions and 27 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ public sealed class DynamicsAxOptions
|
|||
/// <summary>How many days back the forecasts look.</summary>
|
||||
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()
|
||||
{
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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<AxBatchDatabase>();
|
||||
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<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 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);
|
||||
|
||||
// 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>();
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
73
src/SQLInsight.Core/DynamicsAx/AxLocalTime.cs
Normal file
73
src/SQLInsight.Core/DynamicsAx/AxLocalTime.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -180,10 +180,11 @@ public abstract class AxSchemaAdapter
|
|||
}
|
||||
|
||||
/// <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
|
||||
/// 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
|
||||
/// <see cref="AxLocalTime"/>), or a UTC datetime. AX's empty date (1900-01-01) is null.
|
||||
/// </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)
|
||||
{
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <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();
|
||||
}
|
||||
|
||||
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);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -54,4 +54,10 @@ public sealed record AxThresholds
|
|||
|
||||
/// <summary>How many days back the forecasts look for their oldest sample.</summary>
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ public sealed class AxBatchLateRule : DynamicsAxBatchRule
|
|||
new Evidence("Batchgruppe", job.Group ?? "–"),
|
||||
.. AxVersions.HasUtcBatchTimes(database.Version)
|
||||
? 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).")],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue