ModuleRebuildServiceTests.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
  2. using Admin.NET.Plugin.AiDOP.Entity.SmartOps;
  3. using Admin.NET.Plugin.AiDOP.Supply;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  6. public class ModuleRebuildServiceTests
  7. {
  8. [Fact]
  9. public async Task SecondEnqueue_SameScope_ReturnsConflict()
  10. {
  11. var store = new MemoryJobStore();
  12. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  13. var first = await svc.EnqueueAsync("S2", 9, 1, 1);
  14. var second = await svc.EnqueueAsync("S2", 9, 1, 1);
  15. Assert.Equal(202, first.StatusCode);
  16. Assert.Equal(409, second.StatusCode);
  17. Assert.Equal(first.Body.JobId, second.Body.JobId);
  18. }
  19. [Fact]
  20. public async Task OtherTenantEnqueue_NotBlocked()
  21. {
  22. var store = new MemoryJobStore();
  23. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  24. var a = await svc.EnqueueAsync("S2", 9, 1, 1);
  25. var b = await svc.EnqueueAsync("S2", 10, 1, 2);
  26. Assert.Equal(202, a.StatusCode);
  27. Assert.Equal(202, b.StatusCode);
  28. Assert.NotEqual(a.Body.JobId, b.Body.JobId);
  29. }
  30. [Fact]
  31. public async Task DifferentModule_SameTenant_NotBlocked()
  32. {
  33. var store = new MemoryJobStore();
  34. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  35. var a = await svc.EnqueueAsync("S2", 9, 1, 1);
  36. var b = await svc.EnqueueAsync("S3", 9, 1, 1);
  37. Assert.Equal(202, a.StatusCode);
  38. Assert.Equal(202, b.StatusCode);
  39. }
  40. [Fact]
  41. public async Task GetById_OtherTenant_NotFound()
  42. {
  43. var store = new MemoryJobStore();
  44. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  45. var created = await svc.EnqueueAsync("S2", 9, 1, 1);
  46. var row = await store.GetByIdAsync("S2", created.Body.JobId!.Value, 10, 1);
  47. Assert.Null(row);
  48. }
  49. [Fact]
  50. public void Scope_RejectsZeroTenant()
  51. {
  52. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S2", 0, 1));
  53. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S2", 9, 0));
  54. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S2", 9, 9));
  55. Assert.Throws<InvalidOperationException>(() => MdpRebuildScope.Create("S9", 9, 1));
  56. }
  57. [Fact]
  58. public void SqlInject_AddsTenantToEveryWhere()
  59. {
  60. var sql = """
  61. SELECT 1 FROM t WHERE a=1
  62. UNION ALL
  63. SELECT 1 FROM t WHERE b=2
  64. """;
  65. var injected = MdpSqlScope.InjectTenantFactory(sql);
  66. Assert.Equal(2, injected.Split("@TenantId", StringSplitOptions.None).Length - 1);
  67. }
  68. [Fact]
  69. public void TenantOnlySqlInject_DoesNotRequireFactoryColumn()
  70. {
  71. var injected = MdpSqlScope.InjectTenantOnly("SELECT 1 FROM mdp_std_delivery_schedule ds WHERE ds.tenant_id > 0");
  72. Assert.Contains("ds.tenant_id=@TenantId", injected);
  73. Assert.DoesNotContain("factory_id", injected, StringComparison.OrdinalIgnoreCase);
  74. Assert.DoesNotContain("@FactoryId", injected);
  75. }
  76. [Fact]
  77. public void TenantOnlySqlInject_QualifiesRootAndNestedJoinScopes()
  78. {
  79. var sql = """
  80. SELECT po.id
  81. FROM mdp_std_purchase_order po
  82. LEFT JOIN (
  83. SELECT tenant_id, po_no
  84. FROM mdp_std_delivery_schedule
  85. WHERE po_no <> ''
  86. ) ds ON po.tenant_id=ds.tenant_id
  87. WHERE po.po_no <> ''
  88. """;
  89. var injected = MdpSqlScope.InjectTenantOnly(sql);
  90. Assert.Contains("FROM mdp_std_delivery_schedule\n WHERE tenant_id=@TenantId", injected);
  91. Assert.Contains("WHERE po.tenant_id=@TenantId", injected);
  92. }
  93. [Fact]
  94. public void S3ScopeDiscovery_ReadsNormalizedFactoryColumn()
  95. {
  96. var sql = MdpRebuildScopeCatalog.GetFactScopeSql("S3");
  97. Assert.NotNull(sql);
  98. Assert.Contains("COALESCE(NULLIF(factory_id, 0), 1) AS FactoryId", sql);
  99. }
  100. [Fact]
  101. public void S3DefaultInbound_ExcludesArchivedInvMaster()
  102. {
  103. Assert.DoesNotContain(S3MdpEntityConfig.All, x =>
  104. x.EntityCode == "S3_INVENTORY" ||
  105. x.SourceTable.Equals("InvMaster", StringComparison.OrdinalIgnoreCase));
  106. }
  107. [Fact]
  108. public async Task Success_WritesHundredPercent()
  109. {
  110. var store = new MemoryJobStore();
  111. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  112. await svc.EnqueueAsync("S2", 9, 1, 1);
  113. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  114. var handler = new FakeHandler();
  115. await svc.RunClaimedAsync(job, handler, new InMemoryModuleRebuildLock(), CancellationToken.None);
  116. var row = store.Latest("S2", 9, 1);
  117. Assert.Equal(ModuleRebuildStatus.Success, row.Status);
  118. Assert.Equal(100, row.ProgressPercent);
  119. }
  120. [Fact]
  121. public async Task AlreadyRunning_RequeuesJob()
  122. {
  123. var store = new MemoryJobStore();
  124. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  125. await svc.EnqueueAsync("S2", 9, 1, 1);
  126. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  127. var lck = new InMemoryModuleRebuildLock();
  128. await using var held = await lck.TryAcquireAsync(MdpRebuildScope.Create("S2", 9, 1), "holder");
  129. await svc.RunClaimedAsync(job, new FakeHandler(), lck, CancellationToken.None);
  130. Assert.Equal(ModuleRebuildStatus.Queued, store.Latest("S2", 9, 1).Status);
  131. }
  132. [Fact]
  133. public async Task Enqueue_InitialQueuedZeroPercent()
  134. {
  135. var store = new MemoryJobStore();
  136. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  137. var accepted = await svc.EnqueueAsync("S4", 9, 1, 1, "AUTO");
  138. var row = store.Latest("S4", 9, 1);
  139. Assert.Equal(202, accepted.StatusCode);
  140. Assert.Equal(ModuleRebuildStatus.Queued, row.Status);
  141. Assert.Equal(ModuleRebuildStages.Queued, row.CurrentStage);
  142. Assert.Equal(0, row.ProgressPercent);
  143. Assert.Equal(0, row.StageIndex);
  144. Assert.Equal(4, row.StageTotal);
  145. Assert.Equal("AUTO", row.TriggerType);
  146. }
  147. [Fact]
  148. public async Task ConcurrentEnqueue_SameScope_OnlyOneActive()
  149. {
  150. var store = new MemoryJobStore();
  151. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  152. var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => svc.EnqueueAsync("S2", 9, 1, 1)));
  153. Assert.Equal(1, results.Count(x => x.StatusCode == 202));
  154. Assert.Equal(19, results.Count(x => x.StatusCode == 409));
  155. Assert.Single(store.Rows.Where(x => x.Status is ModuleRebuildStatus.Queued or ModuleRebuildStatus.Running));
  156. Assert.True(results.Where(x => x.StatusCode == 409).All(x => x.Body.JobId == results.First(y => y.StatusCode == 202).Body.JobId));
  157. }
  158. [Fact]
  159. public async Task DifferentFactory_NotBlocked()
  160. {
  161. var store = new MemoryJobStore();
  162. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  163. var a = await svc.EnqueueAsync("S2", 9, 1, 1);
  164. var b = await svc.EnqueueAsync("S2", 9, 2, 1);
  165. Assert.Equal(202, a.StatusCode);
  166. Assert.Equal(202, b.StatusCode);
  167. Assert.NotEqual(a.Body.JobId, b.Body.JobId);
  168. }
  169. [Fact]
  170. public async Task DisabledModule_Returns404()
  171. {
  172. var store = new MemoryJobStore();
  173. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new DisabledModuleRebuildCapability());
  174. var result = await svc.EnqueueAsync("S2", 9, 1, 1);
  175. Assert.Equal(404, result.StatusCode);
  176. Assert.Empty(store.Rows);
  177. }
  178. [Fact]
  179. public async Task Failed_KeepsStageAndPercent()
  180. {
  181. var store = new MemoryJobStore();
  182. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  183. await svc.EnqueueAsync("S2", 9, 1, 1);
  184. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  185. await svc.RunClaimedAsync(job, new ThrowingHandler(), new InMemoryModuleRebuildLock(), CancellationToken.None);
  186. var row = store.Latest("S2", 9, 1);
  187. Assert.Equal(ModuleRebuildStatus.Failed, row.Status);
  188. Assert.Equal(ModuleRebuildStages.Kpi, row.FailedStage);
  189. Assert.Equal(65, row.ProgressPercent);
  190. Assert.NotEqual(100, row.ProgressPercent);
  191. }
  192. [Fact]
  193. public async Task Cancellation_MarksCancelled()
  194. {
  195. var store = new MemoryJobStore();
  196. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  197. await svc.EnqueueAsync("S2", 9, 1, 1);
  198. var job = await store.ClaimNextQueuedAsync(new[] { "S2" });
  199. using var cts = new CancellationTokenSource();
  200. var run = svc.RunClaimedAsync(job, new HangingHandler(), new InMemoryModuleRebuildLock(), cts.Token);
  201. await Task.Delay(200);
  202. cts.Cancel();
  203. await run;
  204. Assert.Equal(ModuleRebuildStatus.Cancelled, store.Latest("S2", 9, 1).Status);
  205. }
  206. [Fact]
  207. public async Task FailStale_ClosesRunningWithoutHeartbeat()
  208. {
  209. var store = new MemoryJobStore();
  210. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  211. await svc.EnqueueAsync("S2", 9, 1, 1);
  212. var job = store.Latest("S2", 9, 1);
  213. job.Status = ModuleRebuildStatus.Running;
  214. job.HeartbeatAt = DateTime.Now.AddHours(-1);
  215. await svc.FailStaleAsync();
  216. Assert.Equal(ModuleRebuildStatus.Failed, store.Latest("S2", 9, 1).Status);
  217. Assert.Contains("服务中断", store.Latest("S2", 9, 1).ErrorMessage);
  218. }
  219. [Fact]
  220. public async Task Progress_DoesNotRegress()
  221. {
  222. var store = new MemoryJobStore();
  223. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  224. await svc.EnqueueAsync("S2", 9, 1, 1);
  225. var job = store.Latest("S2", 9, 1);
  226. await svc.ApplyProgressAsync(job.Id, "S2", new ModuleProgressUpdate(ModuleRebuildStages.Staging, 1, 20, "stg"));
  227. await svc.ApplyProgressAsync(job.Id, "S2", new ModuleProgressUpdate(ModuleRebuildStages.Standard, 2, 40, "std"));
  228. await svc.ApplyProgressAsync(job.Id, "S2", new ModuleProgressUpdate(ModuleRebuildStages.Dwd, 3, 60, "dwd"));
  229. var percents = store.ProgressHistory.Select(x => x.Percent).ToArray();
  230. for (var i = 1; i < percents.Length; i++)
  231. Assert.True(percents[i] >= percents[i - 1], $"percent regress {percents[i - 1]} -> {percents[i]}");
  232. Assert.Equal(60, job.ProgressPercent);
  233. }
  234. [Fact]
  235. public void StageIndex_IsMonotonicPerModule()
  236. {
  237. Assert.True(ModuleRebuildStages.ToStageIndex("S2", ModuleRebuildStages.Staging)
  238. < ModuleRebuildStages.ToStageIndex("S2", ModuleRebuildStages.Standard));
  239. Assert.True(ModuleRebuildStages.ToStageIndex("S4", ModuleRebuildStages.Dwd)
  240. < ModuleRebuildStages.ToStageIndex("S4", ModuleRebuildStages.Kpi));
  241. Assert.True(ModuleRebuildStages.ToStageIndex("S5", ModuleRebuildStages.T8Inbound)
  242. < ModuleRebuildStages.ToStageIndex("S5", ModuleRebuildStages.KpiCalculating));
  243. Assert.Equal(5, ModuleRebuildStages.StageTotal("S1"));
  244. Assert.Equal(4, ModuleRebuildStages.StageTotal("S4"));
  245. Assert.Equal(4, ModuleRebuildStages.StageTotal("S7"));
  246. }
  247. [Fact]
  248. public void LockName_IsolatesTenantFactoryModule()
  249. {
  250. var a = MdpRebuildScope.Create("S2", 9, 1);
  251. var b = MdpRebuildScope.Create("S2", 10, 1);
  252. var c = MdpRebuildScope.Create("S3", 9, 1);
  253. Assert.Equal("S2_MDP_FULL:9:1", a.LockName);
  254. Assert.NotEqual(a.LockName, b.LockName);
  255. Assert.NotEqual(a.LockName, c.LockName);
  256. }
  257. [Fact]
  258. public void SqlInject_DoesNotDoubleInject()
  259. {
  260. var sql = "SELECT 1 FROM t w WHERE w.tenant_id=@TenantId AND a=1";
  261. var injected = MdpSqlScope.InjectTenantFactory(sql);
  262. Assert.Equal(1, injected.Split("@TenantId", StringSplitOptions.None).Length - 1);
  263. }
  264. [Fact]
  265. public async Task JobRunner_EnqueuesEachScope()
  266. {
  267. var store = new MemoryJobStore();
  268. var svc = new ModuleRebuildService(store, new ModuleRebuildQueue(), new AlwaysOnModuleRebuildCapability());
  269. var a = await svc.EnqueueAsync("S2", 9, 1, null, "BOOTSTRAP");
  270. var b = await svc.EnqueueAsync("S2", 10, 1, null, "BOOTSTRAP");
  271. Assert.Equal(202, a.StatusCode);
  272. Assert.Equal(202, b.StatusCode);
  273. Assert.Equal(2, store.Rows.Count);
  274. Assert.All(store.Rows, x => Assert.Equal("BOOTSTRAP", x.TriggerType));
  275. }
  276. private sealed class FakeHandler : IModuleRebuildHandler
  277. {
  278. public string ModuleCode => "S2";
  279. public Task<ModuleRebuildResult> RunAsync(MdpRebuildScope scope, string triggerType, long jobId, Func<ModuleProgressUpdate, Task> report, CancellationToken cancellationToken) =>
  280. Task.FromResult(new ModuleRebuildResult { BatchId = "b1", StageRows = 1 });
  281. }
  282. private sealed class ThrowingHandler : IModuleRebuildHandler
  283. {
  284. public string ModuleCode => "S2";
  285. public async Task<ModuleRebuildResult> RunAsync(MdpRebuildScope scope, string triggerType, long jobId, Func<ModuleProgressUpdate, Task> report, CancellationToken cancellationToken)
  286. {
  287. await report(new ModuleProgressUpdate(ModuleRebuildStages.Kpi, 4, 65, "正在重算 KPI"));
  288. throw new InvalidOperationException("kpi failed");
  289. }
  290. }
  291. private sealed class HangingHandler : IModuleRebuildHandler
  292. {
  293. public string ModuleCode => "S2";
  294. public async Task<ModuleRebuildResult> RunAsync(MdpRebuildScope scope, string triggerType, long jobId, Func<ModuleProgressUpdate, Task> report, CancellationToken cancellationToken)
  295. {
  296. await Task.Delay(Timeout.Infinite, cancellationToken);
  297. return new ModuleRebuildResult();
  298. }
  299. }
  300. private sealed class DisabledModuleRebuildCapability : IModuleRebuildCapability
  301. {
  302. public bool IsEnabled(string moduleCode) => false;
  303. public int MaxParallelScopes => 2;
  304. }
  305. private sealed class MemoryJobStore : IModuleRebuildJobStore
  306. {
  307. public List<AdoModuleDashboardRebuildJob> Rows { get; } = new();
  308. public List<(long JobId, int Percent, string Stage)> ProgressHistory { get; } = new();
  309. private long _id = 1;
  310. private readonly object _gate = new();
  311. public AdoModuleDashboardRebuildJob Latest(string module, long tenantId, long factoryId)
  312. {
  313. lock (_gate)
  314. return Rows.Where(x => x.ModuleCode == module && x.TenantId == tenantId && x.FactoryId == factoryId).OrderByDescending(x => x.Id).First();
  315. }
  316. public Task<AdoModuleDashboardRebuildJob> InsertQueuedAsync(AdoModuleDashboardRebuildJob row, CancellationToken ct = default)
  317. {
  318. lock (_gate)
  319. {
  320. row.Id = _id++;
  321. Rows.Add(row);
  322. return Task.FromResult(row);
  323. }
  324. }
  325. public Task<AdoModuleDashboardRebuildJob> FindActiveAsync(string moduleCode, long tenantId, long factoryId, CancellationToken ct = default)
  326. {
  327. lock (_gate)
  328. return Task.FromResult(Rows.Where(x => x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId
  329. && x.Status is ModuleRebuildStatus.Queued or ModuleRebuildStatus.Running)
  330. .OrderBy(x => x.Id).FirstOrDefault());
  331. }
  332. public Task<AdoModuleDashboardRebuildJob> GetByIdAsync(string moduleCode, long id, long tenantId, long factoryId, CancellationToken ct = default)
  333. {
  334. lock (_gate)
  335. return Task.FromResult(Rows.FirstOrDefault(x => x.Id == id && x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId));
  336. }
  337. public Task<AdoModuleDashboardRebuildJob> GetLatestAsync(string moduleCode, long tenantId, long factoryId, CancellationToken ct = default)
  338. {
  339. lock (_gate)
  340. return Task.FromResult(Rows.Where(x => x.ModuleCode == moduleCode && x.TenantId == tenantId && x.FactoryId == factoryId)
  341. .OrderByDescending(x => x.Id).FirstOrDefault());
  342. }
  343. public Task<AdoModuleDashboardRebuildJob> ClaimNextQueuedAsync(IReadOnlyCollection<string> enabledModules, CancellationToken ct = default)
  344. {
  345. lock (_gate)
  346. {
  347. var row = Rows.Where(x => x.Status == ModuleRebuildStatus.Queued && enabledModules.Contains(x.ModuleCode))
  348. .Where(x => !Rows.Any(y => y.ModuleCode == x.ModuleCode && y.TenantId == x.TenantId && y.FactoryId == x.FactoryId && y.Status == ModuleRebuildStatus.Running))
  349. .OrderBy(x => x.Id).FirstOrDefault();
  350. if (row == null) return Task.FromResult<AdoModuleDashboardRebuildJob>(null);
  351. row.Status = ModuleRebuildStatus.Running;
  352. row.StartedAt = DateTime.Now;
  353. row.HeartbeatAt = DateTime.Now;
  354. return Task.FromResult(row);
  355. }
  356. }
  357. public Task UpdateAsync(AdoModuleDashboardRebuildJob row, CancellationToken ct = default) => Task.CompletedTask;
  358. public Task UpdateProgressAsync(long jobId, string currentStage, int stageIndex, int progressPercent, string message, DateTime now, CancellationToken ct = default)
  359. {
  360. lock (_gate)
  361. {
  362. var row = Rows.FirstOrDefault(x => x.Id == jobId);
  363. if (row == null) return Task.CompletedTask;
  364. row.CurrentStage = currentStage;
  365. row.StageIndex = stageIndex;
  366. row.ProgressPercent = progressPercent;
  367. row.ProgressMessage = message;
  368. row.LastProgressAt = now;
  369. row.HeartbeatAt = now;
  370. row.UpdateTime = now;
  371. ProgressHistory.Add((jobId, progressPercent, currentStage));
  372. return Task.CompletedTask;
  373. }
  374. }
  375. public Task UpdateStageResultAsync(long jobId, string completedStage, int rows, int progressPercent, string nextMessage, string detailJson, DateTime now, CancellationToken ct = default) =>
  376. UpdateProgressAsync(jobId, completedStage, 0, progressPercent, nextMessage, now, ct);
  377. public Task TouchHeartbeatAsync(long jobId, DateTime now, CancellationToken ct = default)
  378. {
  379. lock (_gate)
  380. {
  381. var row = Rows.FirstOrDefault(x => x.Id == jobId);
  382. if (row != null) row.HeartbeatAt = now;
  383. return Task.CompletedTask;
  384. }
  385. }
  386. public Task FailStaleRunningAsync(TimeSpan staleAfter, CancellationToken ct = default)
  387. {
  388. lock (_gate)
  389. {
  390. var cutoff = DateTime.Now - staleAfter;
  391. foreach (var row in Rows.Where(x => x.Status == ModuleRebuildStatus.Running && (x.HeartbeatAt == null || x.HeartbeatAt < cutoff)))
  392. {
  393. row.Status = ModuleRebuildStatus.Failed;
  394. row.CurrentStage = ModuleRebuildStages.Failed;
  395. row.FinishedAt = DateTime.Now;
  396. row.LastProgressAt = DateTime.Now;
  397. row.UpdateTime = DateTime.Now;
  398. row.ErrorMessage = "服务中断,任务未正常结束";
  399. }
  400. return Task.CompletedTask;
  401. }
  402. }
  403. }
  404. }