MdpMonitorService.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. namespace Admin.NET.Plugin.AiDOP.Order;
  2. /// <summary>
  3. /// 数据中台统一 MDP 运行监控。
  4. /// </summary>
  5. [ApiDescriptionSettings(Order = 322, Description = "统一MDP运行监控")]
  6. [Route("api/DataPlatform")]
  7. [AllowAnonymous]
  8. [NonUnify]
  9. public class MdpMonitorService : IDynamicApiController, ITransient
  10. {
  11. private static readonly Dictionary<string, string> ModuleJobCodes = new(StringComparer.OrdinalIgnoreCase)
  12. {
  13. ["S1"] = "S1_MDP_SYNC_TRANSFORM",
  14. ["S2"] = "S2_MDP_SYNC_TRANSFORM",
  15. ["S3"] = "S3_MDP_SYNC_TRANSFORM",
  16. ["S4"] = "S4_MDP_SYNC_TRANSFORM"
  17. };
  18. private readonly ISqlSugarClient _db;
  19. private readonly UserManager _userManager;
  20. public MdpMonitorService(ISqlSugarClient db, UserManager userManager)
  21. {
  22. _db = db;
  23. _userManager = userManager;
  24. }
  25. [DisplayName("MDP模块选项")]
  26. [HttpGet("mdp-monitor/modules")]
  27. public object GetModules()
  28. {
  29. return ModuleJobCodes
  30. .OrderBy(u => u.Key)
  31. .Select(u => new { moduleCode = u.Key, jobCode = u.Value })
  32. .ToList();
  33. }
  34. [DisplayName("MDP最近运行状态")]
  35. [HttpGet("mdp-monitor/latest")]
  36. public async Task<object> GetLatest([FromQuery] MdpMonitorQueryInput input)
  37. {
  38. var tenantId = _userManager.TenantId;
  39. var (whereSql, pars) = BuildWhere(input, tenantId);
  40. return await _db.Ado.SqlQuerySingleAsync<MdpMonitorRunLogRow>(
  41. $"""
  42. {SelectColumnsSql()}
  43. FROM mdp_transform_run_log
  44. WHERE {whereSql}
  45. ORDER BY start_time DESC, id DESC
  46. LIMIT 1
  47. """,
  48. pars)
  49. ?? new MdpMonitorRunLogRow();
  50. }
  51. [DisplayName("MDP运行日志列表")]
  52. [HttpGet("mdp-monitor/list")]
  53. public async Task<object> GetList([FromQuery] MdpMonitorListInput input)
  54. {
  55. var tenantId = _userManager.TenantId;
  56. var page = input.Page <= 0 ? 1 : input.Page;
  57. var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
  58. var offset = (page - 1) * pageSize;
  59. var (whereSql, pars) = BuildWhere(input, tenantId);
  60. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM mdp_transform_run_log WHERE {whereSql}", pars);
  61. var list = await _db.Ado.SqlQueryAsync<MdpMonitorRunLogRow>(
  62. $"""
  63. {SelectColumnsSql()}
  64. FROM mdp_transform_run_log
  65. WHERE {whereSql}
  66. ORDER BY start_time DESC, id DESC
  67. LIMIT {pageSize} OFFSET {offset}
  68. """,
  69. pars);
  70. return new { total, page, pageSize, list };
  71. }
  72. [DisplayName("MDP运行日志详情")]
  73. [HttpGet("mdp-monitor/detail/{id}")]
  74. public async Task<object> GetDetail(long id, [FromQuery] MdpMonitorQueryInput input)
  75. {
  76. var tenantId = _userManager.TenantId;
  77. var (whereSql, pars) = BuildWhere(input, tenantId);
  78. pars.Add(new SugarParameter("@Id", id));
  79. var row = await _db.Ado.SqlQuerySingleAsync<MdpMonitorRunLogRow>(
  80. $"""
  81. {SelectColumnsSql()}
  82. FROM mdp_transform_run_log
  83. WHERE id=@Id AND {whereSql}
  84. LIMIT 1
  85. """,
  86. pars);
  87. return row ?? throw Oops.Oh("运行日志不存在");
  88. }
  89. [DisplayName("MDP同步链路详情")]
  90. [HttpGet("mdp-monitor/lineage")]
  91. public async Task<object> GetLineage([FromQuery] MdpMonitorLineageInput input)
  92. {
  93. var tenantId = _userManager.TenantId;
  94. var moduleCode = ResolveModuleCode(input.ModuleCode, input.JobCode);
  95. if (string.IsNullOrWhiteSpace(moduleCode))
  96. throw Oops.Oh("请选择 MDP 模块");
  97. var jobCode = ResolveJobCode(moduleCode, input.JobCode);
  98. var entityPrefix = $"{moduleCode}_%";
  99. var entities = await _db.Ado.SqlQueryAsync<MdpLineageEntityRow>(
  100. """
  101. SELECT e.id AS Id, e.entity_code AS EntityCode, e.entity_name AS EntityName,
  102. e.entity_type AS EntityType, s.source_code AS SourceCode,
  103. s.source_name AS SourceName, s.source_type AS SourceType,
  104. s.db_type AS SourceDbType, s.db_host AS SourceDbHost,
  105. s.db_port AS SourceDbPort, s.db_name AS SourceDbName,
  106. e.source_table_name AS SourceTableName, e.source_api_path AS SourceApiPath,
  107. s.db_type AS TargetDbType, s.db_host AS TargetDbHost,
  108. s.db_port AS TargetDbPort, s.db_name AS TargetDbName,
  109. e.target_table_name AS TargetTableName, e.sync_mode AS SyncMode,
  110. e.incr_column AS IncrColumn, e.status AS Status
  111. FROM mdp_entity e
  112. LEFT JOIN mdp_source s ON s.id = e.source_id
  113. WHERE e.entity_code LIKE @EntityPrefix AND e.tenant_id = @TenantId
  114. ORDER BY e.entity_code
  115. """,
  116. new SugarParameter("@EntityPrefix", entityPrefix),
  117. new SugarParameter("@TenantId", tenantId));
  118. if (entities.Count == 0)
  119. {
  120. return new MdpLineageOutput
  121. {
  122. ModuleCode = moduleCode,
  123. JobCode = jobCode,
  124. Stages = BuildStageDescriptions(moduleCode),
  125. Entities = new List<MdpLineageEntityRow>()
  126. };
  127. }
  128. var entityIds = string.Join(",", entities.Select(u => u.Id));
  129. var mappings = await _db.Ado.SqlQueryAsync<MdpLineageFieldMappingRow>(
  130. $"""
  131. SELECT entity_id AS EntityId, source_field AS SourceField, target_field AS TargetField,
  132. field_type AS FieldType, transform_script AS TransformScript,
  133. const_value AS ConstValue, lookup_table AS LookupTable,
  134. is_required AS IsRequired, default_value AS DefaultValue, sort_order AS SortOrder
  135. FROM mdp_field_mapping
  136. WHERE entity_id IN ({entityIds})
  137. ORDER BY entity_id, sort_order, target_field
  138. """);
  139. var mappingsByEntity = mappings.GroupBy(u => u.EntityId).ToDictionary(u => u.Key, u => u.ToList());
  140. Dictionary<long, MdpLineageSyncLogRow> syncLogsByEntity = new();
  141. if (!string.IsNullOrWhiteSpace(input.BatchId))
  142. {
  143. var syncLogs = await _db.Ado.SqlQueryAsync<MdpLineageSyncLogRow>(
  144. """
  145. SELECT entity_id AS EntityId, entity_name AS EntityName, status AS Status,
  146. rows_read AS RowsRead, rows_insert AS RowsInsert, rows_update AS RowsUpdate,
  147. rows_skip AS RowsSkip, rows_error AS RowsError,
  148. sync_start AS SyncStart, sync_end AS SyncEnd, duration_ms AS DurationMs,
  149. error_msg AS ErrorMsg
  150. FROM mdp_sync_log
  151. WHERE sync_batch_id = @BatchId
  152. AND tenant_id = @TenantId
  153. AND entity_id IN (
  154. SELECT id FROM mdp_entity WHERE entity_code LIKE @EntityPrefix
  155. )
  156. ORDER BY sync_start, id
  157. """,
  158. new SugarParameter("@BatchId", input.BatchId.Trim()),
  159. new SugarParameter("@EntityPrefix", entityPrefix),
  160. new SugarParameter("@TenantId", tenantId));
  161. syncLogsByEntity = syncLogs
  162. .GroupBy(u => u.EntityId)
  163. .ToDictionary(u => u.Key, u => u.OrderByDescending(x => x.SyncStart).First());
  164. }
  165. foreach (var entity in entities)
  166. {
  167. entity.SourceFullName = BuildObjectFullName(entity.SourceDbType, entity.SourceDbHost, entity.SourceDbPort, entity.SourceDbName, entity.SourceTableName ?? entity.SourceApiPath);
  168. entity.TargetFullName = BuildObjectFullName(entity.TargetDbType, entity.TargetDbHost, entity.TargetDbPort, entity.TargetDbName, entity.TargetTableName);
  169. if (mappingsByEntity.TryGetValue(entity.Id, out var entityMappings))
  170. {
  171. entity.FieldMappings = entityMappings;
  172. entity.FieldMappingCount = entityMappings.Count;
  173. }
  174. if (syncLogsByEntity.TryGetValue(entity.Id, out var syncLog))
  175. entity.SyncLog = syncLog;
  176. }
  177. return new MdpLineageOutput
  178. {
  179. ModuleCode = moduleCode,
  180. JobCode = jobCode,
  181. BatchId = input.BatchId,
  182. Stages = BuildStageDescriptions(moduleCode),
  183. Entities = entities
  184. };
  185. }
  186. private static (string WhereSql, List<SugarParameter> Parameters) BuildWhere(MdpMonitorQueryInput input, long tenantId)
  187. {
  188. var where = new List<string> { "IFNULL(job_code, '') LIKE '%MDP%'", "tenant_id = @TenantId" };
  189. var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
  190. var jobCode = ResolveJobCode(input.ModuleCode, input.JobCode);
  191. if (!string.IsNullOrWhiteSpace(jobCode))
  192. {
  193. where.Add("job_code=@JobCode");
  194. pars.Add(new SugarParameter("@JobCode", jobCode));
  195. }
  196. if (!string.IsNullOrWhiteSpace(input.BatchId))
  197. {
  198. where.Add("batch_id LIKE @BatchId");
  199. pars.Add(new SugarParameter("@BatchId", $"%{input.BatchId.Trim()}%"));
  200. }
  201. if (!string.IsNullOrWhiteSpace(input.Status))
  202. {
  203. where.Add("status=@Status");
  204. pars.Add(new SugarParameter("@Status", input.Status.Trim().ToUpperInvariant()));
  205. }
  206. if (input.StartTime.HasValue)
  207. {
  208. where.Add("start_time >= @StartTime");
  209. pars.Add(new SugarParameter("@StartTime", input.StartTime.Value));
  210. }
  211. if (input.EndTime.HasValue)
  212. {
  213. where.Add("start_time <= @EndTime");
  214. pars.Add(new SugarParameter("@EndTime", input.EndTime.Value));
  215. }
  216. return (string.Join(" AND ", where), pars);
  217. }
  218. private static string? ResolveJobCode(string? moduleCode, string? jobCode)
  219. {
  220. if (!string.IsNullOrWhiteSpace(jobCode))
  221. return jobCode.Trim().ToUpperInvariant();
  222. if (string.IsNullOrWhiteSpace(moduleCode))
  223. return null;
  224. return ModuleJobCodes.TryGetValue(moduleCode.Trim(), out var mapped) ? mapped : null;
  225. }
  226. private static string? ResolveModuleCode(string? moduleCode, string? jobCode)
  227. {
  228. if (!string.IsNullOrWhiteSpace(moduleCode))
  229. return moduleCode.Trim().ToUpperInvariant();
  230. if (string.IsNullOrWhiteSpace(jobCode))
  231. return null;
  232. var normalizedJobCode = jobCode.Trim();
  233. return ModuleJobCodes.FirstOrDefault(u => string.Equals(u.Value, normalizedJobCode, StringComparison.OrdinalIgnoreCase)).Key;
  234. }
  235. private static string? BuildObjectFullName(string? dbType, string? host, int? port, string? dbName, string? objectName)
  236. {
  237. if (string.IsNullOrWhiteSpace(objectName))
  238. return null;
  239. var databaseObject = string.IsNullOrWhiteSpace(dbName) ? objectName : $"{dbName}.{objectName}";
  240. var hostPart = string.IsNullOrWhiteSpace(host) ? null : port.HasValue ? $"{host}:{port}" : host;
  241. return string.Join(" / ", new[] { dbType, hostPart, databaseObject }.Where(u => !string.IsNullOrWhiteSpace(u)));
  242. }
  243. private static List<MdpLineageStageRow> BuildStageDescriptions(string moduleCode)
  244. {
  245. if (string.Equals(moduleCode, "S1", StringComparison.OrdinalIgnoreCase))
  246. {
  247. return new List<MdpLineageStageRow>
  248. {
  249. new() { StageCode = "STAGING", StageName = "贴源同步", Layer = "mdp_stg", Description = "按 mdp_entity 登记的 S1 源对象抽取数据,保留 raw_data JSON 便于追溯。", InputObjects = "旧系统 / 当前库源对象", OutputObjects = "mdp_stg_so, mdp_stg_ship_trans", Execution = "S1MdpSyncTransformService.SyncStagingAsync" },
  250. new() { StageCode = "STANDARD", StageName = "标准层转换", Layer = "mdp_std", Description = "解析贴源 raw_data,做字段标准化、租户兜底和幂等写入。", InputObjects = "mdp_stg_so, mdp_stg_ship_trans", OutputObjects = "mdp_std_so, mdp_std_ship_trans", Execution = "S1MdpSyncTransformService.BuildStandardCommands" },
  251. new() { StageCode = "DWD", StageName = "DWD宽表", Layer = "dwd", Description = "沉淀 S1 订单交付事实,供订单交付、看板和诊断读取。", InputObjects = "mdp_std_so, mdp_std_ship_trans", OutputObjects = "dwd_ship_trans", Execution = "S1MdpSyncTransformService.BuildDwdAsync" },
  252. new() { StageCode = "KPI", StageName = "指标写入", Layer = "ado_s9", Description = "计算 S1 L1 指标并写入统一指标值表。", InputObjects = "mdp_std_so, dwd_ship_trans", OutputObjects = "ado_s9_kpi_value_l1_day", Execution = "S1MdpSyncTransformService.BuildS1KpiValuesAsync" }
  253. };
  254. }
  255. if (string.Equals(moduleCode, "S3", StringComparison.OrdinalIgnoreCase))
  256. {
  257. return new List<MdpLineageStageRow>
  258. {
  259. new() { StageCode = "STAGING", StageName = "贴源同步", Layer = "mdp_stg", Description = "按 mdp_entity 登记的 S3 源对象抽取数据,保留 raw_data JSON 便于追溯。", InputObjects = "S3 源对象", OutputObjects = "mdp_stg_*", Execution = "S3MdpSyncTransformService.SyncStagingAsync" },
  260. new() { StageCode = "STANDARD", StageName = "标准层转换", Layer = "mdp_std", Description = "将供应、物料、采购、交付等对象标准化。", InputObjects = "mdp_stg_*", OutputObjects = "mdp_std_*", Execution = "S3MdpSyncTransformService.BuildStandardCommands" },
  261. new() { StageCode = "DWD", StageName = "DWD宽表", Layer = "dwd", Description = "生成供应交付、齐套、风险等分析宽表。", InputObjects = "mdp_std_*", OutputObjects = "dwd_supplier_delivery / dwd_material_readiness 等", Execution = "S3MdpSyncTransformService.BuildDwdAsync" },
  262. new() { StageCode = "KPI", StageName = "指标写入", Layer = "ado_s9", Description = "写入 S3 供应协同指标。", InputObjects = "mdp_std_* / dwd_*", OutputObjects = "ado_s9_kpi_value_*", Execution = "S3MdpSyncTransformService.BuildS3KpiValuesAsync" }
  263. };
  264. }
  265. if (string.Equals(moduleCode, "S4", StringComparison.OrdinalIgnoreCase))
  266. {
  267. return new List<MdpLineageStageRow>
  268. {
  269. new() { StageCode = "STAGING", StageName = "S4 贴源同步", Layer = "mdp_stg_s4_*", Description = "S4 专属 IQC/发货/退货/欠料贴源;共享采购主链仍由 S3 维护,S4 同时统计共享贴源行数。", InputObjects = "PurOrdRctDetail / scm_shdzb / srm_polist_ds / dwd_material_shortage", OutputObjects = "mdp_stg_s4_iqc / mdp_stg_s4_shipment / mdp_stg_s4_return / mdp_stg_s4_shortage", Execution = "S4MdpSyncTransformService.SyncStagingAsync" },
  270. new() { StageCode = "STANDARD", StageName = "S4 标准层转换", Layer = "mdp_std_s4_*", Description = "将 S4 贴源对象标准化,并统计 S3 共享标准层行数。", InputObjects = "mdp_stg_s4_*", OutputObjects = "mdp_std_s4_iqc / mdp_std_s4_shipment / mdp_std_s4_return / mdp_std_s4_shortage", Execution = "S4MdpSyncTransformService.BuildStandardCommands" },
  271. new() { StageCode = "DWD", StageName = "采购执行宽表", Layer = "dwd", Description = "写入 dwd_s4_purchase_execution、dwd_po_trans(扩展字段)、dwd_qc_trans。", InputObjects = "dwd_supplier_delivery / mdp_std_s4_*", OutputObjects = "dwd_s4_purchase_execution / dwd_po_trans / dwd_qc_trans", Execution = "S4MdpSyncTransformService.BuildDwdAsync" },
  272. new() { StageCode = "KPI", StageName = "指标写入", Layer = "ado_s9", Description = "写入 S4 采购执行 L1/L2/L3 指标(S4_L1_001~004、L2/L3 全量)。", InputObjects = "mdp_std_delivery_schedule / dwd_supplier_delivery / dwd_s4_purchase_execution", OutputObjects = "ado_s9_kpi_value_l1/l2/l3_day", Execution = "S4MdpSyncTransformService.BuildS4KpiValuesAsync" }
  273. };
  274. }
  275. return new List<MdpLineageStageRow>
  276. {
  277. new() { StageCode = "STAGING", StageName = "贴源同步", Layer = "mdp_stg", Description = "按 mdp_entity 登记源对象抽取数据。", InputObjects = "源对象", OutputObjects = "mdp_stg_*", Execution = "模块 MDP 同步服务" },
  278. new() { StageCode = "STANDARD", StageName = "标准层转换", Layer = "mdp_std", Description = "标准层/DWD/KPI 当前由模块后端 Service 承载。", InputObjects = "mdp_stg_*", OutputObjects = "mdp_std_* / dwd_* / 指标表", Execution = "模块 MDP 转换服务" }
  279. };
  280. }
  281. private static string SelectColumnsSql()
  282. {
  283. return """
  284. SELECT id AS Id, tenant_id AS TenantId, job_code AS JobCode, job_name AS JobName, trigger_type AS TriggerType,
  285. batch_id AS BatchId, status AS Status, start_time AS StartTime, end_time AS EndTime, duration_ms AS DurationMs,
  286. stage_rows AS StageRows, standard_rows AS StandardRows, dwd_rows AS DwdRows,
  287. error_message AS ErrorMessage, summary_json AS SummaryJson, create_time AS CreateTime, update_time AS UpdateTime
  288. """;
  289. }
  290. }
  291. public class MdpMonitorQueryInput
  292. {
  293. public string? ModuleCode { get; set; }
  294. public string? JobCode { get; set; }
  295. public string? BatchId { get; set; }
  296. public string? Status { get; set; }
  297. public DateTime? StartTime { get; set; }
  298. public DateTime? EndTime { get; set; }
  299. }
  300. public sealed class MdpMonitorListInput : MdpMonitorQueryInput
  301. {
  302. public int Page { get; set; } = 1;
  303. public int PageSize { get; set; } = 10;
  304. }
  305. public sealed class MdpMonitorLineageInput : MdpMonitorQueryInput
  306. {
  307. }
  308. public sealed class MdpMonitorRunLogRow
  309. {
  310. public long Id { get; set; }
  311. public long TenantId { get; set; }
  312. public string? JobCode { get; set; }
  313. public string? JobName { get; set; }
  314. public string? TriggerType { get; set; }
  315. public string? BatchId { get; set; }
  316. public string? Status { get; set; }
  317. public DateTime? StartTime { get; set; }
  318. public DateTime? EndTime { get; set; }
  319. public int? DurationMs { get; set; }
  320. public int? StageRows { get; set; }
  321. public int? StandardRows { get; set; }
  322. public int? DwdRows { get; set; }
  323. public string? ErrorMessage { get; set; }
  324. public string? SummaryJson { get; set; }
  325. public DateTime? CreateTime { get; set; }
  326. public DateTime? UpdateTime { get; set; }
  327. }
  328. public sealed class MdpLineageOutput
  329. {
  330. public string? ModuleCode { get; set; }
  331. public string? JobCode { get; set; }
  332. public string? BatchId { get; set; }
  333. public List<MdpLineageStageRow> Stages { get; set; } = new();
  334. public List<MdpLineageEntityRow> Entities { get; set; } = new();
  335. }
  336. public sealed class MdpLineageStageRow
  337. {
  338. public string? StageCode { get; set; }
  339. public string? StageName { get; set; }
  340. public string? Layer { get; set; }
  341. public string? Description { get; set; }
  342. public string? InputObjects { get; set; }
  343. public string? OutputObjects { get; set; }
  344. public string? Execution { get; set; }
  345. }
  346. public sealed class MdpLineageEntityRow
  347. {
  348. public long Id { get; set; }
  349. public string? EntityCode { get; set; }
  350. public string? EntityName { get; set; }
  351. public string? EntityType { get; set; }
  352. public string? SourceCode { get; set; }
  353. public string? SourceName { get; set; }
  354. public string? SourceType { get; set; }
  355. public string? SourceDbType { get; set; }
  356. public string? SourceDbHost { get; set; }
  357. public int? SourceDbPort { get; set; }
  358. public string? SourceDbName { get; set; }
  359. public string? SourceTableName { get; set; }
  360. public string? SourceApiPath { get; set; }
  361. public string? SourceFullName { get; set; }
  362. public string? TargetDbType { get; set; }
  363. public string? TargetDbHost { get; set; }
  364. public int? TargetDbPort { get; set; }
  365. public string? TargetDbName { get; set; }
  366. public string? TargetTableName { get; set; }
  367. public string? TargetFullName { get; set; }
  368. public string? SyncMode { get; set; }
  369. public string? IncrColumn { get; set; }
  370. public int? Status { get; set; }
  371. public int FieldMappingCount { get; set; }
  372. public List<MdpLineageFieldMappingRow> FieldMappings { get; set; } = new();
  373. public MdpLineageSyncLogRow? SyncLog { get; set; }
  374. }
  375. public sealed class MdpLineageFieldMappingRow
  376. {
  377. public long EntityId { get; set; }
  378. public string? SourceField { get; set; }
  379. public string? TargetField { get; set; }
  380. public string? FieldType { get; set; }
  381. public string? TransformScript { get; set; }
  382. public string? ConstValue { get; set; }
  383. public string? LookupTable { get; set; }
  384. public bool IsRequired { get; set; }
  385. public string? DefaultValue { get; set; }
  386. public int SortOrder { get; set; }
  387. }
  388. public sealed class MdpLineageSyncLogRow
  389. {
  390. public long EntityId { get; set; }
  391. public string? EntityName { get; set; }
  392. public string? Status { get; set; }
  393. public long? RowsRead { get; set; }
  394. public long? RowsInsert { get; set; }
  395. public long? RowsUpdate { get; set; }
  396. public long? RowsSkip { get; set; }
  397. public long? RowsError { get; set; }
  398. public DateTime? SyncStart { get; set; }
  399. public DateTime? SyncEnd { get; set; }
  400. public int? DurationMs { get; set; }
  401. public string? ErrorMsg { get; set; }
  402. }