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>
735 lines
38 KiB
C#
735 lines
38 KiB
C#
using System.Globalization;
|
||
|
||
namespace SQLInsight.Core.DynamicsAx;
|
||
|
||
/// <summary>
|
||
/// Everything SQLInsight knows about one Dynamics AX version's tables, in one place: which tables and columns the AX
|
||
/// checks read (and so which tables the optional read access must cover), the batch STATUS enum, and whether times are
|
||
/// local or UTC. Every query is built against the columns the database actually has (sys.columns, via
|
||
/// <see cref="AxDetection"/>), so a schema that differs gives "ikke målbar" with the exact column, never an error.
|
||
/// <para>
|
||
/// Only <see cref="Ax30Adapter"/> is verified against a real customer schema (docs/dynamics-ax/schema-ax30.tsv). AX 4.0,
|
||
/// 2009, 2012 and F&O are built from Microsoft's documented schemas and NOT verified against customer data; their
|
||
/// documented tables and columns are in docs/dynamics-ax/schema-ax*.documented.tsv. When a customer supplies a real
|
||
/// schema, drop it in as docs/dynamics-ax/schema-ax2009.tsv or schema-ax2012.tsv: the adapter tests then check this
|
||
/// adapter against it instead (AxSchemaFile).
|
||
/// </para>
|
||
/// </summary>
|
||
public abstract class AxSchemaAdapter
|
||
{
|
||
/// <summary>How many batch jobs are read per collection (newest RECID first).</summary>
|
||
public const int MaxBatchJobs = 1000;
|
||
|
||
/// <summary>How many history rows are read per collection.</summary>
|
||
public const int MaxHistoryRows = 1000;
|
||
|
||
/// <summary>RecIds are handed out in blocks per AOS, so history rows may arrive slightly out of RECID order: re-read this far back.</summary>
|
||
public const long HistoryOverlap = 2000;
|
||
|
||
/// <summary>
|
||
/// Rows read after the watermark: the overlap plus <see cref="MaxHistoryRows"/> new rows. AX 4.0 and later hand out
|
||
/// RecIds per table, so BATCHJOBHISTORY's RecIds are dense; with no more rows than the overlap the read would return the
|
||
/// same old rows every time and the watermark would never move again.
|
||
/// </summary>
|
||
public const int MaxHistoryRowsAfterWatermark = (int)HistoryOverlap + MaxHistoryRows;
|
||
|
||
/// <summary>How many number sequences are read (the most used first).</summary>
|
||
public const int MaxNumberSequences = 5000;
|
||
|
||
/// <summary>How many RecId sequences are read (the highest first).</summary>
|
||
public const int MaxRecIdSequences = 500;
|
||
|
||
/// <summary>DATEDIFF from the server's local time to UTC: turns AX 3.0/4.0's local times into UTC.</summary>
|
||
protected const string UtcOffset = "DATEDIFF(minute, GETDATE(), GETUTCDATE())";
|
||
|
||
public static readonly AxSchemaAdapter Ax30 = new Ax30Adapter();
|
||
public static readonly AxSchemaAdapter Ax40 = new Ax40Adapter();
|
||
public static readonly AxSchemaAdapter Ax2009 = new Ax2009Adapter();
|
||
public static readonly AxSchemaAdapter Ax2012 = new Ax2012Adapter();
|
||
public static readonly AxSchemaAdapter FinanceOperations = new FinanceOperationsAdapter();
|
||
|
||
public static IReadOnlyList<AxSchemaAdapter> All => [Ax30, Ax40, Ax2009, Ax2012, FinanceOperations];
|
||
|
||
/// <summary>The adapter for a version, or null when the version is unknown (then no AX data checks run).</summary>
|
||
public static AxSchemaAdapter? For(AxVersion version) => version switch
|
||
{
|
||
AxVersion.Axapta30 => Ax30,
|
||
AxVersion.Ax40 => Ax40,
|
||
AxVersion.Ax2009 => Ax2009,
|
||
AxVersion.Ax2012 or AxVersion.Ax2012R2 => Ax2012,
|
||
AxVersion.FinanceOperations => FinanceOperations,
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>The versions this adapter serves.</summary>
|
||
public abstract IReadOnlyList<AxVersion> Versions { get; }
|
||
|
||
/// <summary>The schema file name under docs/dynamics-ax (without .tsv / .documented.tsv): schema-ax30, schema-ax2009 …</summary>
|
||
public abstract string SchemaFileName { get; }
|
||
|
||
/// <summary>The tables the batch checks read.</summary>
|
||
public abstract IReadOnlyList<string> BatchTables { get; }
|
||
|
||
/// <summary>The tables the number sequence checks read.</summary>
|
||
public abstract IReadOnlyList<string> NumberSequenceTables { get; }
|
||
|
||
/// <summary>The tables the RecId check reads (SQLDICTIONARY only for table names).</summary>
|
||
public static IReadOnlyList<string> RecIdTables => ["SYSTEMSEQUENCES", "SQLDICTIONARY"];
|
||
|
||
/// <summary>Every table the optional AX read access covers for this version: batch, number sequences, RecId, companies.</summary>
|
||
public IReadOnlyList<string> ReadTables => [.. BatchTables.Concat(NumberSequenceTables).Concat(RecIdTables).Append("DATAAREA").Distinct(StringComparer.OrdinalIgnoreCase)];
|
||
|
||
/// <summary>Columns each check cannot do without, per table (the adapter tests check them against the schema files).</summary>
|
||
public abstract IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns { get; }
|
||
|
||
/// <summary>Batch times are a local date plus seconds since midnight (3.0/4.0), not UTC datetimes.</summary>
|
||
public abstract bool LocalBatchTimes { get; }
|
||
|
||
/// <summary>The version's batch STATUS enum.</summary>
|
||
public abstract AxBatchStatus BatchStatus(int status);
|
||
|
||
/// <summary>Whether the batch STATUS mapping is verified against customer data.</summary>
|
||
public virtual bool BatchStatusVerified => false;
|
||
|
||
public abstract AxQuery BatchJobs(AxDatabase database);
|
||
|
||
/// <summary>Completed runs from AX's own history after <paramref name="afterRecId"/> (newest first when null), or null: no history table in this version.</summary>
|
||
public virtual AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => null;
|
||
|
||
public abstract AxQuery NumberSequences(AxDatabase database);
|
||
|
||
/// <summary>The companies: DATAAREA (ID, NAME, ISVIRTUAL).</summary>
|
||
public static AxQuery Companies(AxDatabase database)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(database);
|
||
if (Unreadable(database, ["DATAAREA"]) is { } reason)
|
||
{
|
||
return AxQuery.Unmeasurable(reason);
|
||
}
|
||
|
||
var select = new AxSelect(database, "DATAAREA", "d")
|
||
.Add("ID", "nvarchar(10)", required: true)
|
||
.Add("NAME", "nvarchar(60)")
|
||
.Add("ISVIRTUAL");
|
||
return select.MissingReason() is { } missing
|
||
? AxQuery.Unmeasurable(missing)
|
||
: AxQuery.Ok($"SELECT TOP (500) {select.SelectList} FROM {database.Qualified("DATAAREA")} d ORDER BY d.{AxSql.Identifier("ID")}");
|
||
}
|
||
|
||
/// <summary>RecId allocation per table (per company in AX 3.0): SYSTEMSEQUENCES rows named SEQNO, the highest first.</summary>
|
||
public virtual AxQuery RecIds(AxDatabase database)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(database);
|
||
if (Unreadable(database, ["SYSTEMSEQUENCES"]) is { } reason)
|
||
{
|
||
return AxQuery.Unmeasurable(reason);
|
||
}
|
||
|
||
var s = new AxSelect(database, "SYSTEMSEQUENCES", "s");
|
||
var notes = new List<string>();
|
||
var name = database.CanRead("SQLDICTIONARY") && database.HasColumn("SQLDICTIONARY", "NAME") && database.HasColumn("SQLDICTIONARY", "FIELDID")
|
||
&& database.HasColumn("SQLDICTIONARY", "TABLEID")
|
||
? $"(SELECT TOP 1 d.{AxSql.Identifier("NAME")} FROM {database.Qualified("SQLDICTIONARY")} d WHERE d.{AxSql.Identifier("TABLEID")} = s.{AxSql.Identifier("TABID")} AND d.{AxSql.Identifier("FIELDID")} = 0)"
|
||
: "CAST(NULL AS nvarchar(40))";
|
||
if (name.StartsWith("CAST", StringComparison.Ordinal))
|
||
{
|
||
notes.Add("Tabelnavne vises ikke: agenten må ikke læse SQLDICTIONARY (eller den mangler NAME/FIELDID). Tabellerne vises med deres tabel-id.");
|
||
}
|
||
|
||
s.Add("TABID", required: true)
|
||
.Add("DATAAREAID", "nvarchar(10)")
|
||
.AddExpression("CAST(" + s.Column("NEXTVAL", "bigint", required: true) + " AS bigint)")
|
||
.AddExpression("CAST(" + s.Column("MAXVAL", "bigint") + " AS bigint)")
|
||
.AddExpression(name);
|
||
var filter = database.HasColumn("SYSTEMSEQUENCES", "NAME") ? $" WHERE s.{AxSql.Identifier("NAME")} = N'SEQNO'" : string.Empty;
|
||
return s.MissingReason() is { } missing
|
||
? AxQuery.Unmeasurable(missing)
|
||
: AxQuery.Ok($"SELECT TOP ({MaxRecIdSequences}) {s.SelectList} FROM {database.Qualified("SYSTEMSEQUENCES")} s{filter} ORDER BY s.{AxSql.Identifier("NEXTVAL")} DESC", [.. notes, .. s.Notes]);
|
||
}
|
||
|
||
/// <summary>
|
||
/// "Ikke målbar" with the exact GRANT, when the agent may not read one of <paramref name="tables"/> (or it is missing).
|
||
/// </summary>
|
||
protected static string? Unreadable(AxDatabase database, IReadOnlyList<string> tables)
|
||
{
|
||
var missing = tables.Where(t => !database.HasTable(t)).ToList();
|
||
if (missing.Count > 0)
|
||
{
|
||
return $"Ikke målbar: {database.Database} har ikke {(missing.Count == 1 ? "tabellen" : "tabellerne")} {string.Join(", ", missing.Select(t => database.Schema + "." + t))}, "
|
||
+ $"som {AxVersions.Text(database.Version)} forventes at have.";
|
||
}
|
||
|
||
var denied = tables.Where(t => !database.CanRead(t)).ToList();
|
||
return denied.Count == 0 ? null : AxReadAccess.NotMeasurable(database, denied);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- interpretation (no SQL; unit tested)
|
||
|
||
/// <summary>A stable key for a job, so its runs can be compared over time.</summary>
|
||
public virtual string JobKey(AxBatchJobRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
return row.RecId.ToString(CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
/// <summary>What the UI calls a job: its caption, or class, company and group when AX stores no caption (3.0/4.0).</summary>
|
||
public virtual string Caption(AxBatchJobRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
return string.IsNullOrWhiteSpace(row.Caption) ? $"Batchjob {row.RecId.ToString(CultureInfo.InvariantCulture)}" : row.Caption.Trim();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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>
|
||
/// <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)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (!LocalBatchTimes)
|
||
{
|
||
return DateTime.SpecifyKind(d, DateTimeKind.Utc);
|
||
}
|
||
|
||
return AxLocalTime.ToUtc(d, seconds, offsetMinutes, zone);
|
||
}
|
||
|
||
public AxBatchJob Interpret(AxBatchJobRow row, AxBatchHistory history, string database, DateTime? firstSeenExecuting, TimeZoneInfo? zone = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
ArgumentNullException.ThrowIfNull(history);
|
||
var key = JobKey(row);
|
||
var runs = history.Runs(database, key);
|
||
var (median, p90) = AxBatchHistory.Statistics(runs);
|
||
var last = runs.Count > 0 ? runs[^1] : null;
|
||
return new AxBatchJob
|
||
{
|
||
Key = key,
|
||
RecId = row.RecId,
|
||
Caption = Caption(row),
|
||
Company = Trim(row.Company),
|
||
Group = Trim(row.Group),
|
||
StatusCode = row.Status,
|
||
Status = BatchStatus(row.Status),
|
||
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,
|
||
P90Seconds = p90,
|
||
LastRunSeconds = last?.Duration.TotalSeconds,
|
||
LastRunStatus = last?.Status,
|
||
LastRunEndedUtc = last?.EndedUtc,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// A completed run seen in the job table itself: a job that SQLInsight saw executing and now has an end time. The
|
||
/// start is the actual start when AX stores it (2009 and later), else when SQLInsight first saw it executing.
|
||
/// </summary>
|
||
public static AxBatchRun? ObservedRun(AxBatchJob job, DateTime? seenExecutingSince)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(job);
|
||
if (job.Status is not (AxBatchStatus.Finished or AxBatchStatus.Error) || job.EndedUtc is not { } ended)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var started = job.StartedUtc ?? seenExecutingSince;
|
||
if (started is not { } start || ended < start)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
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>
|
||
public AxBatchRun? HistoryRun(AxBatchHistoryRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
var start = ToUtc(row.StartDate, null, row.ServerUtcOffsetMinutes);
|
||
var end = ToUtc(row.EndDate, null, row.ServerUtcOffsetMinutes);
|
||
if (start is not { } s || end is not { } e || e < s || row.JobRecId is not { } job)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return new AxBatchRun(job.ToString(CultureInfo.InvariantCulture), "H" + row.RecId.ToString(CultureInfo.InvariantCulture),
|
||
Trim(row.Caption), Trim(row.Company), s, e, BatchStatus(row.Status));
|
||
}
|
||
|
||
/// <summary>Stable key of a number sequence for its sampled history.</summary>
|
||
public virtual string SequenceKey(AxNumberSequenceRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
return (Trim(row.Company) ?? "*") + "/" + row.Code.Trim();
|
||
}
|
||
|
||
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;
|
||
long? capacity = digits is { } d ? d >= 19 ? long.MaxValue : (long)Math.Pow(10, d) - 1 : null;
|
||
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 = (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),
|
||
Code = row.Code.Trim(),
|
||
Company = Trim(row.Company),
|
||
Text = Trim(row.Text),
|
||
Lowest = row.Lowest,
|
||
Highest = row.Highest,
|
||
Next = row.Next,
|
||
Format = row.Format,
|
||
FormatDigits = digits,
|
||
FormatCapacity = capacity,
|
||
Max = max,
|
||
UsedPercent = used,
|
||
Remaining = remaining,
|
||
Blocked = row.Blocked,
|
||
Continuous = row.Continuous,
|
||
Cyclic = row.Cyclic,
|
||
Manual = row.Manual,
|
||
Referenced = row.Referenced,
|
||
LastCleanUtc = lastClean,
|
||
CleanupKnown = row.LastCleanDate is not null,
|
||
CleanIntervalHours = row.CleanIntervalHours,
|
||
Forecast = row.Manual || row.Cyclic ? null : AxForecast.From(samples, nowUtc, row.Next, max, forecastDays),
|
||
};
|
||
}
|
||
|
||
public static string RecIdKey(AxRecIdRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
return row.TableId.ToString(CultureInfo.InvariantCulture) + "/" + (Trim(row.Company) ?? "*");
|
||
}
|
||
|
||
/// <param name="nextValIsInt">SYSTEMSEQUENCES.NEXTVAL (and RECID) is int: AX 3.0's 32-bit RecId.</param>
|
||
public static AxRecIdSequence Interpret(AxRecIdRow row, bool nextValIsInt, IEnumerable<AxSample> samples, DateTime nowUtc, int forecastDays)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
var typeMax = nextValIsInt ? AxVersions.Int32RecIdMax : long.MaxValue;
|
||
var max = row.MaxVal is > 0 and var m && m < typeMax ? m : typeMax;
|
||
var used = Math.Clamp((double)row.NextVal / max * 100, 0, 100);
|
||
return new AxRecIdSequence
|
||
{
|
||
Key = RecIdKey(row),
|
||
TableId = row.TableId,
|
||
TableName = Trim(row.TableName),
|
||
Company = Trim(row.Company),
|
||
Next = row.NextVal,
|
||
Max = max,
|
||
Bits = nextValIsInt ? 32 : 64,
|
||
UsedPercent = used,
|
||
Remaining = Math.Max(0, max - row.NextVal),
|
||
Forecast = AxForecast.From(samples, nowUtc, row.NextVal, max, forecastDays),
|
||
};
|
||
}
|
||
|
||
protected static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
// ---------------------------------------------------------------- shared SQL builders
|
||
|
||
/// <summary>
|
||
/// NUMBERSEQUENCETABLE with the company from DATAAREAID (3.0, 4.0, 2009) or NUMBERSEQUENCESCOPE (2012), and whether a
|
||
/// NUMBERSEQUENCEREFERENCE uses it. The most used sequences first, so TOP keeps those that matter.
|
||
/// </summary>
|
||
internal static AxQuery NumberSequenceQuery(AxDatabase database, string companyExpression, string? companyJoin, string? referencedExpression, IEnumerable<string> extraNotes)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(database);
|
||
if (Unreadable(database, ["NUMBERSEQUENCETABLE"]) is { } reason)
|
||
{
|
||
return AxQuery.Unmeasurable(reason);
|
||
}
|
||
|
||
var n = new AxSelect(database, "NUMBERSEQUENCETABLE", "n");
|
||
string Big(string column) => "CAST(" + n.Column(column, "bigint", required: true) + " AS bigint)";
|
||
string Flag(string column, string lost) => "ISNULL(" + n.Column(column, whatIsLost: lost) + ", 0)";
|
||
|
||
// AX 2009 and later store the last cleanup as one UTC datetime; 3.0 as a local date plus seconds.
|
||
string cleanDate, cleanSeconds;
|
||
if (database.HasColumn("NUMBERSEQUENCETABLE", "LATESTCLEANDATETIME"))
|
||
{
|
||
cleanDate = n.Column("LATESTCLEANDATETIME", "datetime");
|
||
cleanSeconds = "CAST(NULL AS int)";
|
||
}
|
||
else
|
||
{
|
||
cleanDate = n.Column("LATESTCLEANDATE", "datetime", whatIsLost: "seneste oprydning af kontinuerlige nummerserier kendes ikke.");
|
||
cleanSeconds = n.Column("LATESTCLEANTIME");
|
||
}
|
||
|
||
n.AddExpression(Big("RECID"))
|
||
.AddExpression(n.Column("NUMBERSEQUENCE", "nvarchar(20)", required: true))
|
||
.AddExpression(companyExpression)
|
||
.AddExpression(n.Column("TXT", "nvarchar(60)"))
|
||
.AddExpression(Big("LOWEST"))
|
||
.AddExpression(Big("HIGHEST"))
|
||
.AddExpression(Big("NEXTREC"))
|
||
.AddExpression(n.Column("FORMAT", "nvarchar(40)", whatIsLost: "formatets bredde kan ikke sammenlignes med HIGHEST."))
|
||
.AddExpression(Flag("BLOCKED", "spærrede nummerserier kan ikke ses."))
|
||
.AddExpression(Flag("CONTINUOUS", "kontinuerlige nummerserier kan ikke ses."))
|
||
.AddExpression(Flag("CYCLIC", "cykliske nummerserier kan ikke ses (de regnes som almindelige)."))
|
||
.AddExpression(Flag("MANUAL", "manuelle nummerserier kan ikke ses (de regnes som almindelige)."))
|
||
.AddExpression(referencedExpression ?? "CAST(NULL AS int)")
|
||
.AddExpression(cleanDate)
|
||
.AddExpression(cleanSeconds)
|
||
.AddExpression("CAST(" + n.Column("CLEANINTERVAL", "float") + " AS float)")
|
||
.AddExpression(UtcOffset);
|
||
if (n.MissingReason() is { } missing)
|
||
{
|
||
return AxQuery.Unmeasurable(missing);
|
||
}
|
||
|
||
var used = $"(CAST(n.{AxSql.Identifier("NEXTREC")} AS float) - n.{AxSql.Identifier("LOWEST")}) / NULLIF(CAST(n.{AxSql.Identifier("HIGHEST")} AS float) - n.{AxSql.Identifier("LOWEST")}, 0)";
|
||
return AxQuery.Ok(
|
||
$"SELECT TOP ({MaxNumberSequences}) {n.SelectList} FROM {database.Qualified("NUMBERSEQUENCETABLE")} n{(companyJoin is null ? string.Empty : " " + companyJoin)} ORDER BY {used} DESC",
|
||
[.. n.Notes, .. extraNotes]);
|
||
}
|
||
|
||
/// <summary>The old batch table BATCH of AX 3.0 and 4.0: a local start date plus seconds since midnight, no caption.</summary>
|
||
protected static AxQuery LegacyBatchJobs(AxDatabase database)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(database);
|
||
if (Unreadable(database, ["BATCH"]) is { } reason)
|
||
{
|
||
return AxQuery.Unmeasurable(reason);
|
||
}
|
||
|
||
var b = new AxSelect(database, "BATCH", "b");
|
||
b.AddExpression("CAST(" + b.Column("RECID", "bigint", required: true) + " AS bigint)")
|
||
.Add("CLASSNUM", whatIsLost: "jobbene kan ikke skelnes på klasse.")
|
||
.AddExpression("CAST(NULL AS nvarchar(60))")
|
||
.Add("COMPANY", "nvarchar(10)")
|
||
.Add("GROUPID", "nvarchar(20)")
|
||
.Add("STATUS", required: true)
|
||
.Add("STARTDATE", "datetime", required: true)
|
||
.Add("STARTTIME", required: true)
|
||
.AddExpression("CAST(NULL AS datetime)")
|
||
.AddExpression("CAST(NULL AS int)")
|
||
.Add("ENDDATE", "datetime", whatIsLost: "kørselstider kan ikke måles.")
|
||
.Add("ENDTIME")
|
||
.AddExpression(UtcOffset);
|
||
return b.MissingReason() is { } missing
|
||
? AxQuery.Unmeasurable(missing)
|
||
: AxQuery.Ok($"SELECT TOP ({MaxBatchJobs}) {b.SelectList} FROM {database.Qualified("BATCH")} b ORDER BY b.{AxSql.Identifier("RECID")} DESC", b.Notes);
|
||
}
|
||
|
||
/// <summary>BATCHJOB of AX 2009 and later: one row per job, planned start ORIGSTARTDATETIME, actual STARTDATETIME, all UTC.</summary>
|
||
protected static AxQuery JobBatchJobs(AxDatabase database)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(database);
|
||
if (Unreadable(database, ["BATCHJOB"]) is { } reason)
|
||
{
|
||
return AxQuery.Unmeasurable(reason);
|
||
}
|
||
|
||
var j = new AxSelect(database, "BATCHJOB", "j");
|
||
j.AddExpression("CAST(" + j.Column("RECID", "bigint", required: true) + " AS bigint)")
|
||
.AddExpression("CAST(NULL AS int)")
|
||
.Add("CAPTION", "nvarchar(100)", whatIsLost: "jobbene vises med RECID i stedet for navn.")
|
||
.Add("COMPANY", "nvarchar(10)")
|
||
.AddExpression("CAST(NULL AS nvarchar(20))")
|
||
.Add("STATUS", required: true)
|
||
.Add("ORIGSTARTDATETIME", "datetime", required: true)
|
||
.AddExpression("CAST(NULL AS int)")
|
||
.Add("STARTDATETIME", "datetime", whatIsLost: "den faktiske starttid kendes ikke; SQLInsight bruger, hvornår den selv så jobbet køre.")
|
||
.AddExpression("CAST(NULL AS int)")
|
||
.Add("ENDDATETIME", "datetime", whatIsLost: "sluttider kendes ikke.")
|
||
.AddExpression("CAST(NULL AS int)")
|
||
.AddExpression(UtcOffset);
|
||
return j.MissingReason() is { } missing
|
||
? AxQuery.Unmeasurable(missing)
|
||
: AxQuery.Ok($"SELECT TOP ({MaxBatchJobs}) {j.SelectList} FROM {database.Qualified("BATCHJOB")} j ORDER BY j.{AxSql.Identifier("RECID")} DESC", j.Notes);
|
||
}
|
||
|
||
/// <summary>
|
||
/// BATCHJOBHISTORY of AX 2009 and later, by RECID (always indexed in AX): the newest rows the first time, then the rows
|
||
/// after the previous read, re-reading <see cref="HistoryOverlap"/> RecIds back for blocks handed out by other AOS'es.
|
||
/// </summary>
|
||
protected static AxQuery? JobBatchHistory(AxDatabase database, long? afterRecId)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(database);
|
||
if (!database.HasTable("BATCHJOBHISTORY"))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (Unreadable(database, ["BATCHJOBHISTORY"]) is { } reason)
|
||
{
|
||
return AxQuery.Unmeasurable(reason);
|
||
}
|
||
|
||
var h = new AxSelect(database, "BATCHJOBHISTORY", "h");
|
||
h.AddExpression("CAST(" + h.Column("RECID", "bigint", required: true) + " AS bigint)")
|
||
.AddExpression("CAST(" + h.Column("BATCHJOBID", "bigint", required: true) + " AS bigint)")
|
||
.Add("CAPTION", "nvarchar(100)")
|
||
.Add("COMPANY", "nvarchar(10)")
|
||
.Add("STATUS", required: true)
|
||
.Add("STARTDATETIME", "datetime", required: true)
|
||
.Add("ENDDATETIME", "datetime", required: true)
|
||
.AddExpression(UtcOffset);
|
||
if (h.MissingReason() is { } missing)
|
||
{
|
||
return AxQuery.Unmeasurable(missing);
|
||
}
|
||
|
||
var recId = "h." + AxSql.Identifier("RECID");
|
||
return AxQuery.Ok(afterRecId is { } after
|
||
? $"SELECT TOP ({MaxHistoryRowsAfterWatermark}) {h.SelectList} FROM {database.Qualified("BATCHJOBHISTORY")} h WHERE {recId} > {AxSql.Number(Math.Max(0, after - HistoryOverlap))} ORDER BY {recId}"
|
||
: $"SELECT TOP ({MaxHistoryRows}) {h.SelectList} FROM {database.Qualified("BATCHJOBHISTORY")} h ORDER BY {recId} DESC", h.Notes);
|
||
}
|
||
|
||
/// <summary>The 2009+ BatchStatus enum (Microsoft's documentation of BatchStatus).</summary>
|
||
protected static AxBatchStatus JobStatus(int status) => status switch
|
||
{
|
||
0 => AxBatchStatus.Hold,
|
||
1 => AxBatchStatus.Waiting,
|
||
2 => AxBatchStatus.Executing,
|
||
3 => AxBatchStatus.Error,
|
||
4 => AxBatchStatus.Finished,
|
||
5 => AxBatchStatus.Ready,
|
||
6 => AxBatchStatus.NotRun,
|
||
7 => AxBatchStatus.Cancelling,
|
||
8 => AxBatchStatus.Canceled,
|
||
_ => AxBatchStatus.Unknown,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Axapta 2.5/3.0. Verified against the real AX 3.0 schema from a customer (docs/dynamics-ax/schema-ax30.tsv): tables in
|
||
/// the bmssa schema, 32-bit RECID, BATCH with STARTDATE (datetime, local) + STARTTIME (int, seconds since midnight,
|
||
/// local), NUMBERSEQUENCETABLE with NEXTREC/HIGHEST/FORMAT/BLOCKED/INUSE per DATAAREAID, SYSTEMSEQUENCES with
|
||
/// NEXTVAL/MAXVAL per TABID and DATAAREAID. The batch STATUS values are NOT verified against customer data (the schema
|
||
/// file has no data): the UI shows the raw values found so the mapping can be checked.
|
||
/// </summary>
|
||
public sealed class Ax30Adapter : AxSchemaAdapter
|
||
{
|
||
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Axapta30];
|
||
public override string SchemaFileName => "schema-ax30";
|
||
public override IReadOnlyList<string> BatchTables => ["BATCH"];
|
||
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
|
||
public override bool LocalBatchTimes => true;
|
||
|
||
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
|
||
{
|
||
["BATCH"] = ["RECID", "CLASSNUM", "COMPANY", "GROUPID", "STATUS", "STARTDATE", "STARTTIME", "ENDDATE", "ENDTIME"],
|
||
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "DATAAREAID", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
|
||
"LATESTCLEANDATE", "LATESTCLEANTIME", "CLEANINTERVAL"],
|
||
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCE", "DATAAREAID"],
|
||
["SYSTEMSEQUENCES"] = ["TABID", "DATAAREAID", "NEXTVAL", "MAXVAL", "NAME"],
|
||
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
|
||
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
|
||
};
|
||
|
||
/// <summary>
|
||
/// The Axapta 3.0 BatchStatus enum as SQLInsight assumes it: 0 Venter, 1 Udfører, 2 Fejl, 3 Afsluttet, 4 Tilbageholdt.
|
||
/// NOT verified against customer data; check with SELECT STATUS, COUNT(*) FROM bmssa.BATCH GROUP BY STATUS and the
|
||
/// batch list in AX. The web UI shows the values found.
|
||
/// </summary>
|
||
public override AxBatchStatus BatchStatus(int status) => status switch
|
||
{
|
||
0 => AxBatchStatus.Waiting,
|
||
1 => AxBatchStatus.Executing,
|
||
2 => AxBatchStatus.Error,
|
||
3 => AxBatchStatus.Finished,
|
||
4 => AxBatchStatus.Hold,
|
||
_ => AxBatchStatus.Unknown,
|
||
};
|
||
|
||
public override string JobKey(AxBatchJobRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
|
||
// AX 3.0 makes a new BATCH row for each recurrence, so a job is its class, company and batch group.
|
||
return string.Join('|', row.ClassNumber?.ToString(CultureInfo.InvariantCulture) ?? "?", Trim(row.Company) ?? string.Empty, Trim(row.Group) ?? string.Empty);
|
||
}
|
||
|
||
public override string Caption(AxBatchJobRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
var text = row.ClassNumber is { } c ? $"Klasse {c.ToString(CultureInfo.InvariantCulture)}" : $"Batch {row.RecId.ToString(CultureInfo.InvariantCulture)}";
|
||
return Trim(row.Group) is { } group ? $"{text} (gruppe {group})" : text;
|
||
}
|
||
|
||
public override AxQuery BatchJobs(AxDatabase database) => LegacyBatchJobs(database);
|
||
|
||
public override AxQuery NumberSequences(AxDatabase database) => LegacyNumberSequences(database);
|
||
|
||
/// <summary>NUMBERSEQUENCETABLE per DATAAREAID; references by code and company (3.0, 4.0, 2009).</summary>
|
||
internal static AxQuery LegacyNumberSequences(AxDatabase database)
|
||
{
|
||
var notes = new List<string>();
|
||
string? referenced = null;
|
||
if (database.CanRead("NUMBERSEQUENCEREFERENCE") && database.HasColumn("NUMBERSEQUENCEREFERENCE", "NUMBERSEQUENCE")
|
||
&& database.HasColumn("NUMBERSEQUENCEREFERENCE", "DATAAREAID") && database.HasColumn("NUMBERSEQUENCETABLE", "DATAAREAID"))
|
||
{
|
||
referenced = $"CASE WHEN EXISTS (SELECT 1 FROM {database.Qualified("NUMBERSEQUENCEREFERENCE")} r WHERE r.{AxSql.Identifier("NUMBERSEQUENCE")} = n.{AxSql.Identifier("NUMBERSEQUENCE")} "
|
||
+ $"AND r.{AxSql.Identifier("DATAAREAID")} = n.{AxSql.Identifier("DATAAREAID")}) THEN 1 ELSE 0 END";
|
||
}
|
||
else
|
||
{
|
||
notes.Add("Det kan ikke ses, om en spærret nummerserie stadig bruges: agenten må ikke læse NUMBERSEQUENCEREFERENCE (eller den mangler kolonner).");
|
||
}
|
||
|
||
var company = database.HasColumn("NUMBERSEQUENCETABLE", "DATAAREAID") ? "n." + AxSql.Identifier("DATAAREAID") : "CAST(NULL AS nvarchar(10))";
|
||
return NumberSequenceQuery(database, company, null, referenced, notes);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// AX 4.0: like 3.0 (the old BATCH table with local date + seconds, NUMBERSEQUENCETABLE per DATAAREAID) but dbo and
|
||
/// 64-bit RECID. Built from Microsoft's documentation and the 3.0 schema – NOT verified against customer data; the batch
|
||
/// STATUS values are assumed to be 3.0's.
|
||
/// </summary>
|
||
public sealed class Ax40Adapter : AxSchemaAdapter
|
||
{
|
||
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax40];
|
||
public override string SchemaFileName => "schema-ax40";
|
||
public override IReadOnlyList<string> BatchTables => ["BATCH"];
|
||
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
|
||
public override bool LocalBatchTimes => true;
|
||
|
||
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => Ax30.RequiredColumns;
|
||
|
||
public override AxBatchStatus BatchStatus(int status) => Ax30.BatchStatus(status);
|
||
|
||
public override string JobKey(AxBatchJobRow row) => Ax30.JobKey(row);
|
||
|
||
public override string Caption(AxBatchJobRow row) => Ax30.Caption(row);
|
||
|
||
public override AxQuery BatchJobs(AxDatabase database) => LegacyBatchJobs(database);
|
||
|
||
public override AxQuery NumberSequences(AxDatabase database) => Ax30Adapter.LegacyNumberSequences(database);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AX 2009: batch framework with BATCHJOB (one row per job: STATUS, ORIGSTARTDATETIME planned, STARTDATETIME and
|
||
/// ENDDATETIME actual, all UTC) and BATCHJOBHISTORY; NUMBERSEQUENCETABLE per DATAAREAID with NUMBERSEQUENCE, NEXTREC,
|
||
/// HIGHEST; SYSTEMSEQUENCES per table; 64-bit RECID. Baseret på Microsofts dokumenterede skema – ikke verificeret mod
|
||
/// kundedata.
|
||
/// </summary>
|
||
public sealed class Ax2009Adapter : AxSchemaAdapter
|
||
{
|
||
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax2009];
|
||
public override string SchemaFileName => "schema-ax2009";
|
||
public override IReadOnlyList<string> BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"];
|
||
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCEREFERENCE"];
|
||
public override bool LocalBatchTimes => false;
|
||
|
||
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
|
||
{
|
||
["BATCHJOB"] = ["RECID", "CAPTION", "COMPANY", "STATUS", "ORIGSTARTDATETIME", "STARTDATETIME", "ENDDATETIME"],
|
||
["BATCHJOBHISTORY"] = ["RECID", "BATCHJOBID", "CAPTION", "COMPANY", "STATUS", "STARTDATETIME", "ENDDATETIME"],
|
||
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "DATAAREAID", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
|
||
"LATESTCLEANDATETIME", "CLEANINTERVAL"],
|
||
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCE", "DATAAREAID"],
|
||
["SYSTEMSEQUENCES"] = ["TABID", "DATAAREAID", "NEXTVAL", "MAXVAL", "NAME"],
|
||
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
|
||
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
|
||
};
|
||
|
||
public override AxBatchStatus BatchStatus(int status) => JobStatus(status);
|
||
|
||
public override AxQuery BatchJobs(AxDatabase database) => JobBatchJobs(database);
|
||
|
||
public override AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => JobBatchHistory(database, afterRecId);
|
||
|
||
public override AxQuery NumberSequences(AxDatabase database) => Ax30Adapter.LegacyNumberSequences(database);
|
||
}
|
||
|
||
/// <summary>
|
||
/// AX 2012 (RTM/FP and R2/R3): BATCHJOB/BATCHJOBHISTORY as in 2009; NUMBERSEQUENCETABLE keyed by RECID with
|
||
/// NUMBERSEQUENCESCOPE (the company is NUMBERSEQUENCESCOPE.DATAAREA, empty for shared sequences) and
|
||
/// NUMBERSEQUENCEREFERENCE.NUMBERSEQUENCEID; a PARTITION column on every table from R2, whose model store is the
|
||
/// separate <db>_model database. Baseret på Microsofts dokumenterede skema – ikke verificeret mod kundedata.
|
||
/// </summary>
|
||
public class Ax2012Adapter : AxSchemaAdapter
|
||
{
|
||
public override IReadOnlyList<AxVersion> Versions => [AxVersion.Ax2012, AxVersion.Ax2012R2];
|
||
public override string SchemaFileName => "schema-ax2012";
|
||
public override IReadOnlyList<string> BatchTables => ["BATCHJOB", "BATCHJOBHISTORY"];
|
||
public override IReadOnlyList<string> NumberSequenceTables => ["NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE", "NUMBERSEQUENCEREFERENCE"];
|
||
public override bool LocalBatchTimes => false;
|
||
|
||
public override IReadOnlyDictionary<string, IReadOnlyList<string>> RequiredColumns => new Dictionary<string, IReadOnlyList<string>>
|
||
{
|
||
["BATCHJOB"] = ["RECID", "CAPTION", "COMPANY", "STATUS", "ORIGSTARTDATETIME", "STARTDATETIME", "ENDDATETIME"],
|
||
["BATCHJOBHISTORY"] = ["RECID", "BATCHJOBID", "CAPTION", "COMPANY", "STATUS", "STARTDATETIME", "ENDDATETIME"],
|
||
["NUMBERSEQUENCETABLE"] = ["RECID", "NUMBERSEQUENCE", "NUMBERSEQUENCESCOPE", "TXT", "LOWEST", "HIGHEST", "NEXTREC", "FORMAT", "BLOCKED", "CONTINUOUS", "CYCLIC", "MANUAL",
|
||
"LATESTCLEANDATETIME", "CLEANINTERVAL"],
|
||
["NUMBERSEQUENCESCOPE"] = ["RECID", "DATAAREA"],
|
||
["NUMBERSEQUENCEREFERENCE"] = ["NUMBERSEQUENCEID"],
|
||
["SYSTEMSEQUENCES"] = ["TABID", "NEXTVAL", "MAXVAL", "NAME"],
|
||
["SQLDICTIONARY"] = ["TABLEID", "FIELDID", "NAME", "RECID"],
|
||
["DATAAREA"] = ["ID", "NAME", "ISVIRTUAL"],
|
||
};
|
||
|
||
public override AxBatchStatus BatchStatus(int status) => JobStatus(status);
|
||
|
||
public override AxQuery BatchJobs(AxDatabase database) => JobBatchJobs(database);
|
||
|
||
public override AxQuery? BatchHistory(AxDatabase database, long? afterRecId) => JobBatchHistory(database, afterRecId);
|
||
|
||
/// <summary>A 2012 sequence is identified by its RECID: the same code can exist in several scopes.</summary>
|
||
public override string SequenceKey(AxNumberSequenceRow row)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(row);
|
||
return row.RecId.ToString(CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
public override AxQuery NumberSequences(AxDatabase database)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(database);
|
||
var notes = new List<string>();
|
||
var company = "CAST(NULL AS nvarchar(10))";
|
||
string? join = null;
|
||
if (database.CanRead("NUMBERSEQUENCESCOPE") && database.HasColumn("NUMBERSEQUENCESCOPE", "DATAAREA") && database.HasColumn("NUMBERSEQUENCESCOPE", "RECID")
|
||
&& database.HasColumn("NUMBERSEQUENCETABLE", "NUMBERSEQUENCESCOPE"))
|
||
{
|
||
company = "NULLIF(sc." + AxSql.Identifier("DATAAREA") + ", N'')";
|
||
join = $"LEFT JOIN {database.Qualified("NUMBERSEQUENCESCOPE")} sc ON sc.{AxSql.Identifier("RECID")} = n.{AxSql.Identifier("NUMBERSEQUENCESCOPE")}";
|
||
}
|
||
else
|
||
{
|
||
notes.Add("Firma vises ikke: agenten må ikke læse NUMBERSEQUENCESCOPE (eller den mangler DATAAREA).");
|
||
}
|
||
|
||
string? referenced = null;
|
||
if (database.CanRead("NUMBERSEQUENCEREFERENCE") && database.HasColumn("NUMBERSEQUENCEREFERENCE", "NUMBERSEQUENCEID"))
|
||
{
|
||
referenced = $"CASE WHEN EXISTS (SELECT 1 FROM {database.Qualified("NUMBERSEQUENCEREFERENCE")} r WHERE r.{AxSql.Identifier("NUMBERSEQUENCEID")} = n.{AxSql.Identifier("RECID")}) THEN 1 ELSE 0 END";
|
||
}
|
||
else
|
||
{
|
||
notes.Add("Det kan ikke ses, om en spærret nummerserie stadig bruges: agenten må ikke læse NUMBERSEQUENCEREFERENCE (eller den mangler NUMBERSEQUENCEID).");
|
||
}
|
||
|
||
return NumberSequenceQuery(database, company, join, referenced, notes);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dynamics 365 Finance & Operations on-premises: recognised, and read with the AX 2012 layout (BATCHJOB,
|
||
/// NUMBERSEQUENCETABLE with NUMBERSEQUENCESCOPE), which F&O kept. Only the general and column-checked checks run;
|
||
/// NOT verified against customer data.
|
||
/// </summary>
|
||
public sealed class FinanceOperationsAdapter : Ax2012Adapter
|
||
{
|
||
public override IReadOnlyList<AxVersion> Versions => [AxVersion.FinanceOperations];
|
||
public override string SchemaFileName => "schema-fo";
|
||
}
|