|
|
@@ -0,0 +1,203 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8.OrderFlow;
|
|
|
+
|
|
|
+using A = S8RealOrderArchiveService;
|
|
|
+using D = AdoS8StageDataState;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// 五阶段汇总的 Stage-3 数据态契约。
|
|
|
+///
|
|
|
+/// <para>背景:五阶段矩阵此前把材料采购一律画成「未到达」—— 因为该阶段没有时间 Authority,
|
|
|
+/// 而矩阵只会按「有没有时间」推断。同一张订单的 Stage-3 明细却实实在在列着 8 行在途采购,
|
|
|
+/// 两个页面因此给出互相矛盾的结论。</para>
|
|
|
+///
|
|
|
+/// <para>修法是让后端在<b>同一个五阶段 DTO</b> 里如实下发数据态,而不是让前端另开一个请求
|
|
|
+/// 自己改矩阵 —— 那样会出现第二个汇总 Authority,两边迟早再次分叉。本文件守住三件事:</para>
|
|
|
+///
|
|
|
+/// <list type="number">
|
|
|
+/// <item>永远不产生 COMPLETED —— 口径是 ATTRIBUTED_ONLY 子集,推不出阶段完成;</item>
|
|
|
+/// <item>取数失败是 UNKNOWN,不是 NO_DATA,更不是「未到达」;</item>
|
|
|
+/// <item>只有材料采购被点亮,其余四个阶段一个字段都不能多填。</item>
|
|
|
+/// </list>
|
|
|
+/// </summary>
|
|
|
+public class S8FiveStageSummaryAuthorityTests
|
|
|
+{
|
|
|
+ private static string Source(params string[] parts)
|
|
|
+ {
|
|
|
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
|
+ while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
|
|
|
+ dir = dir.Parent;
|
|
|
+ Assert.NotNull(dir);
|
|
|
+ var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
|
|
|
+ Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
|
|
|
+ return File.ReadAllText(full);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string Archive() => Source("Service", "S8", "OrderFlow", "S8RealOrderArchiveService.cs");
|
|
|
+
|
|
|
+ /// <summary>去掉 XML 文档注释后的代码体 —— 文档需要点名被禁的东西来说明边界,那是说明不是违规。</summary>
|
|
|
+ private static string ArchiveBody()
|
|
|
+ => string.Join('\n', Archive().Split('\n')
|
|
|
+ .Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
|
|
|
+
|
|
|
+ private static AdoS8Stage3ProcurementSummaryDto Summary(int attributed, int completed)
|
|
|
+ => new() { AttributedPoLineCount = attributed, ProcurementCompletedCount = completed };
|
|
|
+
|
|
|
+ private static A.RealOrderRow Row() => new() { SalesOrderId = 1, OrderNo = "SO-TEST-0001" };
|
|
|
+
|
|
|
+ private static AdoS8OrderFlowStageDto Stage(
|
|
|
+ AdoS8Stage3ProcurementSummaryDto? summary, string code)
|
|
|
+ => A.BuildFiveStageSkeleton(Row(), summary).Single(s => s.OrderFlowCode == code);
|
|
|
+
|
|
|
+ private static AdoS8OrderFlowStageDto Purchase(AdoS8Stage3ProcurementSummaryDto? summary)
|
|
|
+ => Stage(summary, OrderFlowConstants.MATERIAL_PURCHASE);
|
|
|
+
|
|
|
+ // ── ① 数据态映射(§二十九 1-4)────────────────────────────────────────────────
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 有已归属采购行是IN_PROGRESS()
|
|
|
+ => Assert.Equal(D.InProgress, A.ResolveStage3DataState(Summary(8, 1)));
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 已归属为零是NO_DATA()
|
|
|
+ => Assert.Equal(D.NoData, A.ResolveStage3DataState(Summary(0, 0)));
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 取数失败是UNKNOWN而不是NO_DATA()
|
|
|
+ {
|
|
|
+ // summary == null 表示 Stage-3 读模型抛异常,不是「查到了零行」。
|
|
|
+ // 把技术故障折叠进 NO_DATA,会让一次数据库超时被读成「这张订单没有采购」。
|
|
|
+ Assert.Equal(D.Unknown, A.ResolveStage3DataState(null));
|
|
|
+ Assert.NotEqual(D.NoData, A.ResolveStage3DataState(null));
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 已归属行全部完成也不得产生COMPLETED()
|
|
|
+ {
|
|
|
+ // 8 行已归属、8 行已完成 —— 看上去「采购做完了」,但口径是 ATTRIBUTED_ONLY:
|
|
|
+ // 这 8 行只是能可靠归属到该订单的采购行,不是该订单的完整采购需求集合。
|
|
|
+ var state = A.ResolveStage3DataState(Summary(8, 8));
|
|
|
+ Assert.Equal(D.InProgress, state);
|
|
|
+ Assert.NotEqual("COMPLETED", state);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 数据态取值集合刻意不含COMPLETED()
|
|
|
+ {
|
|
|
+ var all = new[] { D.InProgress, D.NoData, D.Unknown };
|
|
|
+ Assert.DoesNotContain("COMPLETED", all);
|
|
|
+ // 常量类里也不许偷偷加一个 —— 加了就说明有人绕过了上面那条业务约束。
|
|
|
+ Assert.DoesNotContain("COMPLETED", typeof(AdoS8StageDataState)
|
|
|
+ .GetFields().Select(f => f.GetValue(null) as string ?? string.Empty));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── ② 五阶段装配(§二十九 5-8)────────────────────────────────────────────────
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 材料采购阶段带上已归属与已完成计数()
|
|
|
+ {
|
|
|
+ var stage = Purchase(Summary(8, 1));
|
|
|
+ Assert.Equal(D.InProgress, stage.DataState);
|
|
|
+ Assert.Equal(AdoS8Stage3Const.ScopeAttributedOnly, stage.DataScope);
|
|
|
+ Assert.Equal(8, stage.KnownItemCount);
|
|
|
+ Assert.Equal(1, stage.CompletedItemCount);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 其余四个阶段的数据态字段一律为空()
|
|
|
+ {
|
|
|
+ // 只有材料采购建立了 Authority。顺手把设计/生产/发货也点亮,等于凭空发明结论。
|
|
|
+ var stages = A.BuildFiveStageSkeleton(Row(), Summary(8, 1))
|
|
|
+ .Where(s => s.OrderFlowCode != OrderFlowConstants.MATERIAL_PURCHASE);
|
|
|
+ foreach (var s in stages)
|
|
|
+ {
|
|
|
+ Assert.Null(s.DataState);
|
|
|
+ Assert.Null(s.DataScope);
|
|
|
+ Assert.Null(s.KnownItemCount);
|
|
|
+ Assert.Null(s.CompletedItemCount);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 取数失败时不得下发计数()
|
|
|
+ {
|
|
|
+ // UNKNOWN 意味着「不知道」。此时给出 0 会被前端当成「查到了零行」照常渲染。
|
|
|
+ var stage = Purchase(null);
|
|
|
+ Assert.Equal(D.Unknown, stage.DataState);
|
|
|
+ Assert.Null(stage.KnownItemCount);
|
|
|
+ Assert.Null(stage.CompletedItemCount);
|
|
|
+ Assert.Null(stage.DataScope);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 数据态不得伪造时间与评级()
|
|
|
+ {
|
|
|
+ // 本批的全部价值就在于「不伪造时间也能如实表达」。一旦有人用 observed_at 之类
|
|
|
+ // 回填 ActualStartAt 让矩阵变绿,Stage-3 就会带着一个查无实据的时间进入 KPI。
|
|
|
+ var stage = Purchase(Summary(8, 1));
|
|
|
+ Assert.Null(stage.ActualStartAt);
|
|
|
+ Assert.Null(stage.ActualEndAt);
|
|
|
+ Assert.Null(stage.ActualHours);
|
|
|
+ Assert.Equal("pending", stage.Status);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 五阶段始终齐备且顺序不因数据态改变()
|
|
|
+ {
|
|
|
+ // 五阶段是业务设计,不由「有没有数据」决定 —— NO_DATA 不能让这一列消失。
|
|
|
+ foreach (var summary in new AdoS8Stage3ProcurementSummaryDto?[] { null, Summary(0, 0), Summary(8, 1) })
|
|
|
+ {
|
|
|
+ var stages = A.BuildFiveStageSkeleton(Row(), summary);
|
|
|
+ Assert.Equal(OrderFlowConstants.All, stages.Select(s => s.OrderFlowCode).ToList());
|
|
|
+ Assert.Equal(Enumerable.Range(1, OrderFlowConstants.All.Count), stages.Select(s => s.SortNo));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ── ③ 单一 Authority(§三十)──────────────────────────────────────────────────
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void Stage3取数失败不得让整张订单详情失败()
|
|
|
+ {
|
|
|
+ // 采购是详情的附加信息,不是订单存在的前提。让它把 200 变成 500,
|
|
|
+ // 等于用一条辅助链路的抖动阻断主链路。
|
|
|
+ var body = ArchiveBody();
|
|
|
+ var i = body.IndexOf("LoadStage3SummaryAsync", StringComparison.Ordinal);
|
|
|
+ Assert.True(i >= 0, "未找到 LoadStage3SummaryAsync");
|
|
|
+ var tail = body[i..];
|
|
|
+ Assert.Contains("catch", tail, StringComparison.Ordinal);
|
|
|
+ Assert.Contains("return null", tail, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 五阶段汇总只消费Stage3读模型不另起查询()
|
|
|
+ {
|
|
|
+ // 汇总必须复用 Stage-3 读模型,否则同一页的「矩阵」与「明细」会由两套 SQL 产出,
|
|
|
+ // 迟早给出不一致的数字 —— 那正是本批要消灭的 SUMMARY_DETAIL_SPLIT。
|
|
|
+ var body = ArchiveBody();
|
|
|
+ Assert.Contains("_stage3.GetStage3Async", body, StringComparison.Ordinal);
|
|
|
+ foreach (var forbidden in new[]
|
|
|
+ {
|
|
|
+ "mdp_std_purchase_order_completion",
|
|
|
+ "mdp_std_purchase_order",
|
|
|
+ "mdp_std_iqc",
|
|
|
+ })
|
|
|
+ Assert.DoesNotContain(forbidden, body, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void 订单列表不得触发采购归因查询()
|
|
|
+ {
|
|
|
+ // 列表一次返回 N 张订单;在那里调 Stage-3 会变成 N 次归因查询。
|
|
|
+ var body = ArchiveBody();
|
|
|
+ var i = body.IndexOf("GetRealOrdersAsync", StringComparison.Ordinal);
|
|
|
+ Assert.True(i >= 0, "未找到 GetRealOrdersAsync");
|
|
|
+ var j = body.IndexOf("GetRealOrderAsync", StringComparison.Ordinal);
|
|
|
+ Assert.True(j >= 0, "未找到 GetRealOrderAsync");
|
|
|
+ // 列表方法体(到下一个方法之前)不得出现 Stage-3 调用
|
|
|
+ var listBody = body[i..Math.Max(i, j > i ? j : body.Length)];
|
|
|
+ Assert.DoesNotContain("LoadStage3SummaryAsync", listBody, StringComparison.Ordinal);
|
|
|
+ }
|
|
|
+}
|