S8RealOrderArchiveService.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using SqlSugar;
  4. namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
  5. /// <summary>
  6. /// S8「订单执行档案」<b>真实订单</b> Read Model。
  7. ///
  8. /// <para><b>数据链</b>:
  9. /// <c>mdp_std_so</c>(GROUP BY tenant_id + order_id)→ 订单 Header
  10. /// → LEFT JOIN <c>dwd_order_review_kpi</c> → ORDER_REVIEW 阶段事实
  11. /// → LEFT JOIN <c>dwd_product_design_kpi</c> → PRODUCT_DESIGN 阶段事实
  12. /// → 固定五阶段骨架 → 前端档案页。</para>
  13. ///
  14. /// <para><b>PRINCIPLE 1 · 租户隔离</b>:租户只来自 <see cref="AidopTenantScope.ResolveOrThrow"/>
  15. /// 解析的登录上下文;所有查询与 JOIN 均带 <c>tenant_id</c>,不接受前端传入 tenantId,
  16. /// 无默认租户、无 Factory 代偿。</para>
  17. ///
  18. /// <para><b>PRINCIPLE 2 · 只走数据中台</b>:本服务只读 <c>mdp_std_so</c> 与
  19. /// <c>dwd_order_review_kpi</c>,不触碰 <c>crm_seorder</c> / <c>crm_seorderentry</c> /
  20. /// <c>CustMaster</c> / <c>aidop_action_run_log</c>。</para>
  21. ///
  22. /// <para><b>与 SEED 严格隔离</b>:本服务<b>完全不读</b> <c>ado_s8_order_flow_*</c>。
  23. /// 真实订单的 Stage 2~5 一律返回空字段,绝不从 SEED 拼回来;SEED 基线由
  24. /// <see cref="S8OrderFlowService"/> 原样保留,供 fixture / 回归使用。</para>
  25. /// </summary>
  26. public class S8RealOrderArchiveService : ITransient
  27. {
  28. /// <summary>本 Read Model 的数据来源标识,用于前端区分 REAL / SEED,禁止两路混用。</summary>
  29. public const string RealDataSource = "MDP_STD_SO";
  30. private readonly ISqlSugarClient _db;
  31. private readonly UserManager _userManager;
  32. private readonly S8Stage3ProcurementReadService _stage3;
  33. public S8RealOrderArchiveService(
  34. ISqlSugarClient db,
  35. UserManager userManager,
  36. S8Stage3ProcurementReadService stage3)
  37. {
  38. _db = db;
  39. _userManager = userManager;
  40. _stage3 = stage3;
  41. }
  42. /// <summary>
  43. /// 订单 Header 投影。
  44. ///
  45. /// <para><b>聚合函数只是 SQL 投影手段,不是业务规则</b> —— <c>mdp_std_so</c> 是订单行粒度,
  46. /// 而这几列已实证在同一 <c>order_id</c> 的所有行内取值完全一致(实测差异计数为 0),
  47. /// 因此 <c>MAX()</c> 只是「从若干相同值里取一个」,不隐含「取最大」的业务含义。</para>
  48. ///
  49. /// <para><b>行级字段禁止升格为 Header</b>:<c>progress</c> / <c>factory_id</c> /
  50. /// <c>item_name</c> / <c>item_spec</c> 逐行不同,故不在本投影内。</para>
  51. /// </summary>
  52. private const string HeaderProjection = """
  53. SELECT `tenant_id`,
  54. `order_id`,
  55. MAX(`order_no`) AS `order_no`,
  56. MAX(`customer_id`) AS `customer_id`,
  57. MAX(`customer_no`) AS `customer_no`,
  58. MAX(`customer_name`) AS `customer_name`,
  59. MAX(`order_date`) AS `order_date`,
  60. MAX(`order_type`) AS `order_type`,
  61. MAX(`order_status`) AS `order_status`,
  62. MAX(`flow_state`) AS `flow_state`
  63. FROM `mdp_std_so`
  64. WHERE `tenant_id` = @TenantId
  65. AND `order_id` IS NOT NULL
  66. AND `order_id` <> 0
  67. AND `deleted_flag` = 0
  68. GROUP BY `tenant_id`, `order_id`
  69. """;
  70. /// <summary>
  71. /// 真实订单列表。一张销售订单一行。
  72. /// <para>ORDER_REVIEW 事实用 <b>LEFT JOIN</b> 挂上 —— 有订单但尚未评审的订单
  73. /// 必须照常出现在档案里,不能被 INNER JOIN 过滤掉。</para>
  74. /// </summary>
  75. public async Task<List<AdoS8OrderFlowOrderListItemDto>> GetRealOrdersAsync(AdoS8OrderFlowOrderQueryDto query)
  76. {
  77. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  78. var where = "1=1";
  79. var ps = new List<SugarParameter> { new("@TenantId", tenantId) };
  80. // 订单号 / 客户 模糊检索(order_no 仅作展示与搜索,不参与身份判定)
  81. if (!string.IsNullOrWhiteSpace(query.Keyword))
  82. {
  83. where += " AND (h.`order_no` LIKE @Kw OR h.`customer_name` LIKE @Kw OR h.`customer_no` LIKE @Kw)";
  84. ps.Add(new SugarParameter("@Kw", $"%{query.Keyword.Trim()}%"));
  85. }
  86. if (!string.IsNullOrWhiteSpace(query.CustomerCode))
  87. {
  88. where += " AND h.`customer_no` = @CustNo";
  89. ps.Add(new SugarParameter("@CustNo", query.CustomerCode.Trim()));
  90. }
  91. var rows = await _db.Ado.SqlQueryAsync<RealOrderRow>($"""
  92. SELECT h.`order_id` AS SalesOrderId,
  93. h.`order_no` AS OrderNo,
  94. h.`customer_id` AS CustomerId,
  95. h.`customer_no` AS CustomerNo,
  96. h.`customer_name` AS CustomerName,
  97. h.`order_date` AS OrderDate,
  98. h.`order_type` AS OrderType,
  99. h.`order_status` AS OrderStatus,
  100. h.`flow_state` AS FlowState,
  101. k.`review_start_time` AS ReviewStartTime,
  102. k.`review_end_time` AS ReviewEndTime,
  103. k.`actual_hours` AS ActualHours,
  104. k.`calculation_status` AS CalculationStatus,
  105. p.`product_design_start_time` AS DesignStartTime,
  106. p.`product_design_end_time` AS DesignEndTime,
  107. p.`actual_hours` AS DesignActualHours,
  108. p.`calculation_status` AS DesignCalculationStatus
  109. FROM ({HeaderProjection}) h
  110. LEFT JOIN `dwd_order_review_kpi` k
  111. ON k.`tenant_id` = h.`tenant_id`
  112. AND k.`sales_order_id` = h.`order_id`
  113. LEFT JOIN `dwd_product_design_kpi` p
  114. ON p.`tenant_id` = h.`tenant_id`
  115. AND p.`sales_order_id` = h.`order_id`
  116. WHERE {where}
  117. ORDER BY h.`order_date` DESC, h.`order_id` DESC
  118. """, ps);
  119. return (rows ?? []).Select(MapRealOrder).ToList();
  120. }
  121. /// <summary>
  122. /// 单张真实订单详情(含固定五阶段骨架)。
  123. /// <para><b>定位 Authority = <c>tenant_id + salesOrderId</c></b>,不接受订单号定位。</para>
  124. /// </summary>
  125. public async Task<AdoS8OrderFlowOrderDetailDto?> GetRealOrderAsync(long salesOrderId)
  126. {
  127. if (salesOrderId <= 0) return null;
  128. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  129. var rows = await _db.Ado.SqlQueryAsync<RealOrderRow>($"""
  130. SELECT h.`order_id` AS SalesOrderId,
  131. h.`order_no` AS OrderNo,
  132. h.`customer_id` AS CustomerId,
  133. h.`customer_no` AS CustomerNo,
  134. h.`customer_name` AS CustomerName,
  135. h.`order_date` AS OrderDate,
  136. h.`order_type` AS OrderType,
  137. h.`order_status` AS OrderStatus,
  138. h.`flow_state` AS FlowState,
  139. k.`review_start_time` AS ReviewStartTime,
  140. k.`review_end_time` AS ReviewEndTime,
  141. k.`actual_hours` AS ActualHours,
  142. k.`calculation_status` AS CalculationStatus,
  143. p.`product_design_start_time` AS DesignStartTime,
  144. p.`product_design_end_time` AS DesignEndTime,
  145. p.`actual_hours` AS DesignActualHours,
  146. p.`calculation_status` AS DesignCalculationStatus
  147. FROM ({HeaderProjection}) h
  148. LEFT JOIN `dwd_order_review_kpi` k
  149. ON k.`tenant_id` = h.`tenant_id`
  150. AND k.`sales_order_id` = h.`order_id`
  151. LEFT JOIN `dwd_product_design_kpi` p
  152. ON p.`tenant_id` = h.`tenant_id`
  153. AND p.`sales_order_id` = h.`order_id`
  154. WHERE h.`order_id` = @SalesOrderId
  155. LIMIT 1
  156. """, new List<SugarParameter>
  157. {
  158. new("@TenantId", tenantId),
  159. new("@SalesOrderId", salesOrderId)
  160. });
  161. var row = rows?.FirstOrDefault();
  162. if (row == null) return null;
  163. var item = MapRealOrder(row);
  164. var detail = new AdoS8OrderFlowOrderDetailDto
  165. {
  166. SalesOrderId = item.SalesOrderId,
  167. OrderNo = item.OrderNo,
  168. CustomerId = item.CustomerId,
  169. OrderDate = item.OrderDate,
  170. OrderType = item.OrderType,
  171. OrderStatus = item.OrderStatus,
  172. FlowState = item.FlowState,
  173. OrderCode = item.OrderCode,
  174. CustomerCode = item.CustomerCode,
  175. CustomerName = item.CustomerName,
  176. WorkflowStatus = item.WorkflowStatus,
  177. CurrentOrderFlowCode = item.CurrentOrderFlowCode,
  178. CurrentOrderFlowName = item.CurrentOrderFlowName,
  179. CurrentStatus = item.CurrentStatus,
  180. DataSource = item.DataSource,
  181. Lifecycle = BuildFiveStageSkeleton(row, await LoadStage3SummaryAsync(salesOrderId)),
  182. };
  183. return detail;
  184. }
  185. /// <summary>
  186. /// 取材料采购阶段的摘要,供五阶段矩阵使用。
  187. ///
  188. /// <para><b>复用同一个 Authority</b>:直接调 <see cref="S8Stage3ProcurementReadService"/>,
  189. /// 不另写第二份业务 SQL,也不从 SEED / 贴源层 / 源库重算。矩阵与详情面板因此不可能给出
  190. /// 互相矛盾的结论 —— 它们本来就是同一次计算的两种投影。</para>
  191. ///
  192. /// <para><b>只在单订单详情里调用</b>。订单列表(<c>GetRealOrdersAsync</c>)刻意不调,
  193. /// 否则 N 张订单会变成 N 次采购归因查询。</para>
  194. ///
  195. /// <para>取数失败时返回 <c>null</c>,由调用方标成 <c>UNKNOWN</c> ——
  196. /// 技术故障绝不能被渲染成「未到达」这种业务结论。</para>
  197. /// </summary>
  198. private async Task<AdoS8Stage3ProcurementSummaryDto?> LoadStage3SummaryAsync(long salesOrderId)
  199. {
  200. try
  201. {
  202. var stage3 = await _stage3.GetStage3Async(salesOrderId);
  203. return stage3?.Summary;
  204. }
  205. catch (Exception)
  206. {
  207. return null;
  208. }
  209. }
  210. /// <summary>
  211. /// 材料采购阶段的数据态。
  212. ///
  213. /// <para><b>永远不会返回「已完成」</b>:采购摘要的口径是 ATTRIBUTED_ONLY —— 只统计能可靠
  214. /// 归属到该订单的采购行,不是该订单的完整采购需求集合。即便已归属的行全部完成,
  215. /// 也不能推出「材料采购阶段已完成」,那要等完整需求集合的 Authority 建立之后才谈得上。</para>
  216. ///
  217. /// <para>摘要为 null 表示取数失败(不是没有数据),故落 <c>UNKNOWN</c> 而非 <c>NO_DATA</c>。</para>
  218. /// </summary>
  219. public static string ResolveStage3DataState(AdoS8Stage3ProcurementSummaryDto? stage3)
  220. {
  221. if (stage3 == null) return AdoS8StageDataState.Unknown;
  222. return stage3.AttributedPoLineCount > 0
  223. ? AdoS8StageDataState.InProgress
  224. : AdoS8StageDataState.NoData;
  225. }
  226. // ══════════════════════════════════════════════════════════════════════
  227. // 映射
  228. // ══════════════════════════════════════════════════════════════════════
  229. /// <summary>
  230. /// 行 → 列表项。
  231. /// <para><b>没有真实 Authority 的字段一律留空/留 null</b>:ProductName / ProductLine /
  232. /// Region / Priority / CustomerType / TargetCycleDays / ActualCycleDays / 各类偏差。
  233. /// 绝不用 SEED、PPT 常量、随机默认值或按客户名推断来填 —— 真实订单配假 KPI 比没有更危险。</para>
  234. /// </summary>
  235. private static AdoS8OrderFlowOrderListItemDto MapRealOrder(RealOrderRow r) => new()
  236. {
  237. SalesOrderId = r.SalesOrderId,
  238. OrderNo = r.OrderNo,
  239. CustomerId = r.CustomerId,
  240. OrderDate = r.OrderDate,
  241. OrderType = r.OrderType,
  242. OrderStatus = r.OrderStatus,
  243. FlowState = r.FlowState,
  244. // OrderCode 在既有契约里承担「展示码」角色,REAL 下等于真实订单号;身份看 SalesOrderId。
  245. OrderCode = r.OrderNo ?? string.Empty,
  246. CustomerCode = r.CustomerNo ?? string.Empty,
  247. CustomerName = r.CustomerName ?? string.Empty,
  248. // 本批只有 ORDER_REVIEW 有事实,当前阶段恒定指向它;不推断"订单走到哪一步"。
  249. CurrentOrderFlowCode = OrderFlowConstants.ORDER_REVIEW_PLAN_CALC,
  250. CurrentOrderFlowName = OrderFlowConstants.ResolveName(OrderFlowConstants.ORDER_REVIEW_PLAN_CALC),
  251. CurrentStatus = string.Empty,
  252. // 订单状态用真实 Header Authority 映射,不猜。
  253. WorkflowStatus = string.Equals(r.OrderStatus, "CLOSED", StringComparison.OrdinalIgnoreCase)
  254. ? "completed"
  255. : "in_progress",
  256. DataSource = RealDataSource,
  257. // ↓ 以下无真实 Authority,显式留空 —— 前端渲染 "--"
  258. ProductName = string.Empty,
  259. ProductLine = string.Empty,
  260. CustomerType = string.Empty,
  261. Region = string.Empty,
  262. Priority = string.Empty,
  263. ScenarioCode = string.Empty,
  264. ExceptionStatus = string.Empty,
  265. ReleaseAt = null,
  266. TargetCycleDays = null,
  267. ActualCycleDays = null,
  268. CurrentCycleDays = null,
  269. NodeVarianceDays = null,
  270. CumulativeVarianceDays = null,
  271. ResponseMinutes = null,
  272. ProcessingMinutes = null,
  273. TotalLossMinutes = null,
  274. };
  275. /// <summary>
  276. /// 固定五阶段骨架。
  277. /// <para><b>五阶段是 S8 的业务设计,不由「有没有数据」决定</b> —— 因此 Stage 2~5
  278. /// 必须存在但字段为空;本批只有 ORDER_REVIEW 有真实事实。</para>
  279. /// </summary>
  280. public static List<AdoS8OrderFlowStageDto> BuildFiveStageSkeleton(
  281. RealOrderRow r,
  282. AdoS8Stage3ProcurementSummaryDto? stage3)
  283. {
  284. var list = new List<AdoS8OrderFlowStageDto>();
  285. for (var i = 0; i < OrderFlowConstants.All.Count; i++)
  286. {
  287. var code = OrderFlowConstants.All[i];
  288. var isReview = code == OrderFlowConstants.ORDER_REVIEW_PLAN_CALC;
  289. var isDesign = code == OrderFlowConstants.PRODUCT_DESIGN;
  290. var isPurchase = code == OrderFlowConstants.MATERIAL_PURCHASE;
  291. list.Add(new AdoS8OrderFlowStageDto
  292. {
  293. OrderFlowCode = code,
  294. OrderFlowName = OrderFlowConstants.ResolveName(code),
  295. SortNo = i + 1,
  296. OwnerDept = null,
  297. // 目前只有 ORDER_REVIEW 与 PRODUCT_DESIGN 有真实事实;
  298. // Stage 3~5(材料采购 / 本体生产 / 总装发货)一律 null —— 即便这些域
  299. // 已经有真实业务数据(工单 / MRP / 采购 / 排产),本批也不顺手点亮。
  300. ActualStartAt = isReview ? r.ReviewStartTime : isDesign ? r.DesignStartTime : null,
  301. ActualEndAt = isReview ? r.ReviewEndTime : isDesign ? r.DesignEndTime : null,
  302. ActualHours = isReview ? r.ActualHours : isDesign ? r.DesignActualHours : null,
  303. CalculationStatus = isReview ? r.CalculationStatus : isDesign ? r.DesignCalculationStatus : null,
  304. // 目标/计划/偏差全部属 TargetHours 域,本批无 Authority → null
  305. PlannedDays = null,
  306. ActualDays = null,
  307. TargetAt = null,
  308. NodeVarianceDays = null,
  309. CumulativeVarianceDays = null,
  310. // 状态色本批不评级(红黄绿属另一批次);统一 pending,由前端按时间细分「进行中 / 未到达」
  311. Status = "pending",
  312. Substeps = [],
  313. // 材料采购没有时间 Authority,但有真实的「已归属 / 已完成」计数。
  314. // 用数据态如实表达,绝不为了让矩阵好看而伪造 ActualStartAt / ActualEndAt。
  315. DataState = isPurchase ? ResolveStage3DataState(stage3) : null,
  316. DataScope = isPurchase && stage3 != null ? AdoS8Stage3Const.ScopeAttributedOnly : null,
  317. KnownItemCount = isPurchase ? stage3?.AttributedPoLineCount : null,
  318. CompletedItemCount = isPurchase ? stage3?.ProcurementCompletedCount : null,
  319. });
  320. }
  321. return list;
  322. }
  323. /// <summary>SQL 投影行,字段名与 SELECT 别名一一对应。</summary>
  324. public sealed class RealOrderRow
  325. {
  326. public long SalesOrderId { get; set; }
  327. public string? OrderNo { get; set; }
  328. public long? CustomerId { get; set; }
  329. public string? CustomerNo { get; set; }
  330. public string? CustomerName { get; set; }
  331. public DateTime? OrderDate { get; set; }
  332. public int? OrderType { get; set; }
  333. public string? OrderStatus { get; set; }
  334. public string? FlowState { get; set; }
  335. public DateTime? ReviewStartTime { get; set; }
  336. public DateTime? ReviewEndTime { get; set; }
  337. public decimal? ActualHours { get; set; }
  338. public string? CalculationStatus { get; set; }
  339. public DateTime? DesignStartTime { get; set; }
  340. public DateTime? DesignEndTime { get; set; }
  341. public decimal? DesignActualHours { get; set; }
  342. public string? DesignCalculationStatus { get; set; }
  343. }
  344. }