AidopKanbanController.cs 80 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Entity;
  3. using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Admin.NET.Plugin.AiDOP.Manufacturing;
  6. using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  7. using Admin.NET.Plugin.AiDOP.Order;
  8. using Admin.NET.Plugin.AiDOP.ProcurementExecution;
  9. using Admin.NET.Plugin.AiDOP.Production;
  10. using Admin.NET.Plugin.AiDOP.Supply;
  11. using Admin.NET.Plugin.AiDOP.Service.S8;
  12. using Admin.NET.Plugin.AiDOP.SmartOps;
  13. using Microsoft.Extensions.Logging;
  14. using SqlSugar;
  15. namespace Admin.NET.Plugin.AiDOP.Controllers;
  16. [ApiController]
  17. [Route("api/[controller]")]
  18. [NonUnify]
  19. public partial class AidopKanbanController : ControllerBase
  20. {
  21. private readonly ISqlSugarClient _db;
  22. private readonly S1MdpSyncTransformService _s1MdpSyncTransformService;
  23. private readonly S2MdpSyncTransformService _s2MdpSyncTransformService;
  24. private readonly S3MdpSyncTransformService _s3MdpSyncTransformService;
  25. private readonly MaterialWarehouse.IqcInspectionMdpSyncService _iqcInspectionMdpSyncService;
  26. private readonly PurchaseOrderCompletionMdpSyncService _poCompletionMdpSyncService;
  27. private readonly S4MdpSyncTransformService _s4MdpSyncTransformService;
  28. private readonly S5MdpSyncTransformService _s5MdpSyncTransformService;
  29. private readonly S6MdpSyncTransformService _s6MdpSyncTransformService;
  30. private readonly S7MdpSyncTransformService _s7MdpSyncTransformService;
  31. private readonly SmartOpsKpiAtomicQueryService _atomicQuery;
  32. private readonly OutsourceIssueMdpSyncService _outsourceIssueMdpSyncService;
  33. private readonly PurchaseReceiptMdpSyncService _purchaseReceiptMdpSyncService;
  34. private readonly ProductionReturnMdpSyncService _productionReturnMdpSyncService;
  35. private readonly ProductionIssueMdpSyncService _productionIssueMdpSyncService;
  36. private readonly ProductionReceiptMdpSyncService _productionReceiptMdpSyncService;
  37. private readonly IpqcInspectionMdpSyncService _ipqcInspectionMdpSyncService;
  38. private readonly ReportWorkMdpSyncService _reportWorkMdpSyncService;
  39. private readonly SmartOpsTrustedScopeResolver _trustedScope;
  40. private readonly SmartDiagnosisEvidenceService _evidence;
  41. private readonly S8KpiSnapshotService _s8KpiSnapshot;
  42. private readonly ILogger<AidopKanbanController> _logger;
  43. public AidopKanbanController(
  44. ISqlSugarClient db,
  45. S1MdpSyncTransformService s1MdpSyncTransformService,
  46. S2MdpSyncTransformService s2MdpSyncTransformService,
  47. S3MdpSyncTransformService s3MdpSyncTransformService,
  48. MaterialWarehouse.IqcInspectionMdpSyncService iqcInspectionMdpSyncService,
  49. PurchaseOrderCompletionMdpSyncService poCompletionMdpSyncService,
  50. S4MdpSyncTransformService s4MdpSyncTransformService,
  51. S5MdpSyncTransformService s5MdpSyncTransformService,
  52. S6MdpSyncTransformService s6MdpSyncTransformService,
  53. S7MdpSyncTransformService s7MdpSyncTransformService,
  54. SmartOpsKpiAtomicQueryService atomicQuery,
  55. OutsourceIssueMdpSyncService outsourceIssueMdpSyncService,
  56. PurchaseReceiptMdpSyncService purchaseReceiptMdpSyncService,
  57. ProductionReturnMdpSyncService productionReturnMdpSyncService,
  58. ProductionIssueMdpSyncService productionIssueMdpSyncService,
  59. ProductionReceiptMdpSyncService productionReceiptMdpSyncService,
  60. IpqcInspectionMdpSyncService ipqcInspectionMdpSyncService,
  61. ReportWorkMdpSyncService reportWorkMdpSyncService,
  62. SmartOpsTrustedScopeResolver trustedScope,
  63. SmartDiagnosisEvidenceService evidence,
  64. S8KpiSnapshotService s8KpiSnapshot,
  65. ILogger<AidopKanbanController> logger)
  66. {
  67. _db = db;
  68. _s1MdpSyncTransformService = s1MdpSyncTransformService;
  69. _s2MdpSyncTransformService = s2MdpSyncTransformService;
  70. _s3MdpSyncTransformService = s3MdpSyncTransformService;
  71. _iqcInspectionMdpSyncService = iqcInspectionMdpSyncService;
  72. _poCompletionMdpSyncService = poCompletionMdpSyncService;
  73. _s4MdpSyncTransformService = s4MdpSyncTransformService;
  74. _s5MdpSyncTransformService = s5MdpSyncTransformService;
  75. _s6MdpSyncTransformService = s6MdpSyncTransformService;
  76. _s7MdpSyncTransformService = s7MdpSyncTransformService;
  77. _atomicQuery = atomicQuery;
  78. _outsourceIssueMdpSyncService = outsourceIssueMdpSyncService;
  79. _purchaseReceiptMdpSyncService = purchaseReceiptMdpSyncService;
  80. _productionReturnMdpSyncService = productionReturnMdpSyncService;
  81. _productionIssueMdpSyncService = productionIssueMdpSyncService;
  82. _productionReceiptMdpSyncService = productionReceiptMdpSyncService;
  83. _ipqcInspectionMdpSyncService = ipqcInspectionMdpSyncService;
  84. _reportWorkMdpSyncService = reportWorkMdpSyncService;
  85. _trustedScope = trustedScope;
  86. _evidence = evidence;
  87. _s8KpiSnapshot = s8KpiSnapshot;
  88. _logger = logger;
  89. }
  90. /// <summary>
  91. /// 已弃用。前端零调用点,九宫格实际走 <c>home-grid/{moduleCode}</c>(<see cref="GetHomeGridGeneric"/>)。
  92. /// 本端点用全局 <c>MAX(biz_date)</c> 卡所有模块,没有该日数据的模块会被整段筛掉,不可复用、不可接回。
  93. /// </summary>
  94. [Obsolete("前端零调用点。九宫格走 home-grid/{moduleCode}。全局 MAX(biz_date) 会筛掉其它模块,不可复用。")]
  95. [HttpGet("home-l1")]
  96. public async Task<IActionResult> GetHomeL1([FromQuery] long factoryId = 1)
  97. {
  98. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  99. const string sql = """
  100. SELECT v.module_code AS ModuleCode, v.metric_code AS MetricCode,
  101. v.metric_value AS MetricValue, v.target_value AS TargetValue,
  102. v.status_color AS StatusColor, v.trend_flag AS TrendFlag,
  103. k.Direction, k.YellowThreshold, k.RedThreshold
  104. FROM ado_s9_kpi_value_l1_day v
  105. LEFT JOIN ado_smart_ops_kpi_master k
  106. ON k.MetricCode = v.metric_code COLLATE utf8mb4_general_ci AND k.TenantId = v.tenant_id
  107. WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.is_deleted=0
  108. AND v.biz_date = (SELECT MAX(biz_date) FROM ado_s9_kpi_value_l1_day
  109. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0)
  110. ORDER BY v.module_code
  111. """;
  112. var raw = await _db.Ado.SqlQueryAsync<HomeL1RawDto>(sql, new { tenantId, factoryId });
  113. var rows = raw.Select(r => new HomeL1Dto
  114. {
  115. ModuleCode = r.ModuleCode,
  116. MetricCode = r.MetricCode,
  117. MetricValue = r.MetricValue,
  118. TargetValue = r.TargetValue,
  119. TrendFlag = r.TrendFlag,
  120. StatusColor = AidopS4KpiMerge.AchievementLevel(
  121. r.MetricValue, r.TargetValue,
  122. r.Direction ?? "higher_is_better",
  123. r.YellowThreshold, r.RedThreshold)
  124. }).ToList();
  125. return Ok(rows);
  126. }
  127. [HttpGet("s8-alerts")]
  128. public async Task<IActionResult> GetS8Alerts(
  129. [FromQuery] long factoryId = 1,
  130. [FromQuery] string? dateStart = null,
  131. [FromQuery] string? dateEnd = null,
  132. [FromQuery] string? product = null,
  133. [FromQuery] string? orderNo = null,
  134. [FromQuery] string? productionLine = null)
  135. {
  136. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  137. var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
  138. dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
  139. var dimSql = BuildS8AlertTextFilterSql();
  140. var sql = $"""
  141. SELECT DATE_FORMAT(alert_time, '%H:%i') AS Time,
  142. IFNULL(SUBSTRING_INDEX(metric_code, '_', 1), 'S8') AS Module,
  143. IFNULL(message, '异常告警') AS Message,
  144. IFNULL(level_code, 'medium') AS LevelCode
  145. FROM ado_s8_alert_record
  146. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
  147. AND (@ds IS NULL OR alert_time >= @ds)
  148. AND (@de IS NULL OR alert_time < DATE_ADD(@de, INTERVAL 1 DAY))
  149. {dimSql}
  150. ORDER BY alert_time DESC
  151. LIMIT 6
  152. """;
  153. var rows = await _db.Ado.SqlQueryAsync<S8AlertDto>(sql, new
  154. {
  155. tenantId,
  156. factoryId,
  157. ds = filter.DateStart,
  158. de = filter.DateEnd,
  159. product = filter.Product,
  160. orderNo = filter.OrderNo,
  161. productionLine = filter.ProductionLine
  162. });
  163. return Ok(rows.Select(x => new
  164. {
  165. time = x.Time,
  166. module = x.Module,
  167. message = x.Message,
  168. level = x.LevelCode,
  169. levelLabel = x.LevelCode switch
  170. {
  171. "critical" => "严重",
  172. "high" => "高",
  173. "medium" => "中",
  174. _ => "一般"
  175. }
  176. }));
  177. }
  178. /// <summary>
  179. /// S8-DASHBOARD-P2-SEMANTICS-1(P2-B):S8 看板预警计数。
  180. ///
  181. /// <para><b>为什么要单开一个端点</b>:<c>s8-alerts</c> 是「最近 6 条」列表,带 <c>LIMIT 6</c>
  182. /// 且 SQL 里<b>没有任何今日谓词</b>。前端拿它的 <c>length</c> 当「今日预警数」,于是
  183. /// ① 真实今日 0 条、表里存着几个月前的旧行时,卡片照样显示非 0;
  184. /// ② 今日真有 7 条以上时,卡片永远封顶在 6。两个方向都错。</para>
  185. ///
  186. /// <para><b>窗口口径</b>:未传日期 ⇒ <c>[今日 00:00, 明日 00:00)</c>(服务器自然日,
  187. /// <c>isToday=true</c>);传了日期 ⇒ <c>[dateStart, dateEnd + 1 天)</c>,<c>isToday=false</c>,
  188. /// 由前端把标题从「今日预警数」切成「区间预警数」。一律半开区间,与 <c>s8-alerts</c> 同构。</para>
  189. ///
  190. /// <para>维度过滤复用 <see cref="BuildS8AlertTextFilterSql"/>,不另起口径。</para>
  191. /// </summary>
  192. [HttpGet("s8-alert-summary")]
  193. public async Task<IActionResult> GetS8AlertSummary(
  194. [FromQuery] long factoryId = 1,
  195. [FromQuery] string? dateStart = null,
  196. [FromQuery] string? dateEnd = null,
  197. [FromQuery] string? product = null,
  198. [FromQuery] string? orderNo = null,
  199. [FromQuery] string? productionLine = null)
  200. {
  201. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  202. var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
  203. dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
  204. var isToday = !filter.DateStart.HasValue && !filter.DateEnd.HasValue;
  205. var windowStart = (filter.DateStart ?? filter.DateEnd ?? DateTime.Today).Date;
  206. var windowEnd = (filter.DateEnd ?? filter.DateStart ?? DateTime.Today).Date.AddDays(1);
  207. if (windowEnd <= windowStart) windowEnd = windowStart.AddDays(1);
  208. var dimSql = BuildS8AlertTextFilterSql();
  209. var sql = $"""
  210. SELECT COUNT(*) AS Total,
  211. IFNULL(SUM(CASE WHEN level_code = 'critical' THEN 1 ELSE 0 END), 0) AS Critical
  212. FROM ado_s8_alert_record
  213. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
  214. AND alert_time >= @ws
  215. AND alert_time < @we
  216. {dimSql}
  217. """;
  218. var rows = await _db.Ado.SqlQueryAsync<S8AlertSummaryRow>(sql, new
  219. {
  220. tenantId,
  221. factoryId,
  222. ws = windowStart,
  223. we = windowEnd,
  224. product = filter.Product,
  225. orderNo = filter.OrderNo,
  226. productionLine = filter.ProductionLine
  227. });
  228. var row = rows.FirstOrDefault();
  229. return Ok(new
  230. {
  231. windowStart = windowStart.ToString("yyyy-MM-dd"),
  232. windowEnd = windowEnd.AddDays(-1).ToString("yyyy-MM-dd"),
  233. isToday,
  234. total = row?.Total ?? 0,
  235. critical = row?.Critical ?? 0
  236. });
  237. }
  238. private sealed class S8AlertSummaryRow
  239. {
  240. public int Total { get; set; }
  241. public int Critical { get; set; }
  242. }
  243. [HttpGet("s8-home-trend")]
  244. public async Task<IActionResult> GetS8HomeTrend(
  245. [FromQuery] long factoryId = 1,
  246. [FromQuery] string? dateStart = null,
  247. [FromQuery] string? dateEnd = null,
  248. [FromQuery] string? product = null,
  249. [FromQuery] string? orderNo = null,
  250. [FromQuery] string? productionLine = null)
  251. {
  252. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  253. var filter = SmartOps.SmartOpsDashboardFilter.FromQuery(
  254. dateStart, dateEnd, product, orderNo, productionLine, null, null, null, null, null, null, null, null);
  255. var end = (filter.DateEnd ?? DateTime.Today).Date;
  256. var start = (filter.DateStart ?? end.AddDays(-6)).Date;
  257. if ((end - start).TotalDays > 13) start = end.AddDays(-6);
  258. var dimSql = BuildS8AlertTextFilterSql();
  259. var sql = $"""
  260. SELECT DATE(alert_time) AS BizDate, COUNT(*) AS Cnt
  261. FROM ado_s8_alert_record
  262. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
  263. AND alert_time >= @ds AND alert_time < DATE_ADD(@de, INTERVAL 1 DAY)
  264. {dimSql}
  265. GROUP BY DATE(alert_time)
  266. ORDER BY BizDate
  267. """;
  268. var rows = await _db.Ado.SqlQueryAsync<S8HomeTrendRow>(sql, new
  269. {
  270. tenantId,
  271. factoryId,
  272. ds = start,
  273. de = end,
  274. product = filter.Product,
  275. orderNo = filter.OrderNo,
  276. productionLine = filter.ProductionLine
  277. });
  278. var byDate = rows.ToDictionary(x => x.BizDate.Date, x => x.Cnt);
  279. var points = new List<object>();
  280. for (var day = start; day <= end; day = day.AddDays(1))
  281. points.Add(new { date = day.ToString("MM/dd"), count = byDate.GetValueOrDefault(day) });
  282. return Ok(new
  283. {
  284. title = start.Month == end.Month && start.Year == end.Year
  285. ? $"{start.Month}月异常趋势"
  286. : $"{start:MM/dd}~{end:MM/dd} 异常趋势",
  287. points
  288. });
  289. }
  290. private sealed class S8HomeTrendRow
  291. {
  292. public DateTime BizDate { get; set; }
  293. public int Cnt { get; set; }
  294. }
  295. /// <summary>S8 报警表无独立产品/订单/产线列,按报文与编码模糊匹配基础查询。</summary>
  296. private static string BuildS8AlertTextFilterSql() => """
  297. AND (@product IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @product, '%')
  298. OR IFNULL(metric_code,'') LIKE CONCAT('%', @product, '%')
  299. OR IFNULL(alert_code,'') LIKE CONCAT('%', @product, '%'))
  300. AND (@orderNo IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @orderNo, '%')
  301. OR IFNULL(metric_code,'') LIKE CONCAT('%', @orderNo, '%')
  302. OR IFNULL(alert_code,'') LIKE CONCAT('%', @orderNo, '%'))
  303. AND (@productionLine IS NULL OR IFNULL(message,'') LIKE CONCAT('%', @productionLine, '%')
  304. OR IFNULL(metric_code,'') LIKE CONCAT('%', @productionLine, '%')
  305. OR IFNULL(alert_code,'') LIKE CONCAT('%', @productionLine, '%'))
  306. """;
  307. [HttpGet("module-detail")]
  308. public async Task<IActionResult> GetModuleDetail([FromQuery] string moduleCode = "S1", [FromQuery] long factoryId = 1)
  309. {
  310. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  311. moduleCode = string.IsNullOrWhiteSpace(moduleCode) ? "S1" : moduleCode.ToUpperInvariant();
  312. const string l2Sql = """
  313. SELECT v.module_code AS ModuleCode, v.metric_code AS MetricCode,
  314. COALESCE(NULLIF(k.MetricName,''), v.metric_code) AS MetricName,
  315. v.metric_value AS MetricValue, v.target_value AS TargetValue,
  316. v.status_color AS StatusColor, v.trend_flag AS TrendFlag, v.biz_date AS StatDate
  317. FROM ado_s9_kpi_value_l2_day v
  318. LEFT JOIN ado_smart_ops_kpi_master k ON k.TenantId=v.tenant_id AND k.MetricCode=v.metric_code AND k.ModuleCode=v.module_code
  319. WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.is_deleted=0 AND v.module_code=@moduleCode
  320. AND v.biz_date=(SELECT MAX(biz_date) FROM ado_s9_kpi_value_l2_day
  321. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0)
  322. ORDER BY v.metric_code
  323. LIMIT 30
  324. """;
  325. const string l3Sql = """
  326. SELECT v.module_code AS ModuleCode, v.metric_code AS MetricCode,
  327. COALESCE(NULLIF(k.MetricName,''), v.metric_code) AS MetricName,
  328. v.metric_value AS MetricValue, v.target_value AS TargetValue,
  329. v.status_color AS StatusColor, v.trend_flag AS TrendFlag, v.biz_date AS StatDate,
  330. p.MetricCode AS ParentMetricCode
  331. FROM ado_s9_kpi_value_l3_day v
  332. LEFT JOIN ado_smart_ops_kpi_master k ON k.TenantId=v.tenant_id AND k.MetricCode=v.metric_code AND k.ModuleCode=v.module_code
  333. LEFT JOIN ado_smart_ops_kpi_master p ON p.Id=k.ParentId
  334. WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.is_deleted=0 AND v.module_code=@moduleCode
  335. AND v.biz_date=(SELECT MAX(biz_date) FROM ado_s9_kpi_value_l3_day
  336. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0)
  337. ORDER BY v.metric_code
  338. LIMIT 60
  339. """;
  340. const string alertSql = """
  341. SELECT DATE_FORMAT(alert_time, '%H:%i:%s') AS Time,
  342. IFNULL(level_code, 'medium') AS LevelCode,
  343. IFNULL(message, '异常告警') AS Message
  344. FROM ado_s8_alert_record
  345. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
  346. AND (UPPER(IFNULL(module_code,''))=@moduleCode OR UPPER(IFNULL(SUBSTRING_INDEX(metric_code, '_', 1), ''))=@moduleCode)
  347. ORDER BY alert_time DESC
  348. LIMIT 20
  349. """;
  350. const string l2FallbackSql = """
  351. SELECT '' AS ModuleCode, metric_code AS MetricCode, metric_code AS MetricName, metric_value AS MetricValue,
  352. target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag, NULL AS StatDate
  353. FROM ado_s9_kpi_value_l2_day
  354. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
  355. ORDER BY id DESC
  356. LIMIT 30
  357. """;
  358. const string l3FallbackSql = """
  359. SELECT '' AS ModuleCode, metric_code AS MetricCode, metric_code AS MetricName, metric_value AS MetricValue,
  360. target_value AS TargetValue, status_color AS StatusColor, trend_flag AS TrendFlag, NULL AS StatDate
  361. FROM ado_s9_kpi_value_l3_day
  362. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
  363. ORDER BY id DESC
  364. LIMIT 60
  365. """;
  366. var l2 = new List<KpiDetailDto>();
  367. var l3 = new List<KpiDetailDto>();
  368. var alerts = new List<S8AlertDto>();
  369. try { l2 = await _db.Ado.SqlQueryAsync<KpiDetailDto>(l2Sql, new { moduleCode, tenantId, factoryId }); }
  370. catch { l2 = await _db.Ado.SqlQueryAsync<KpiDetailDto>(l2FallbackSql, new { tenantId, factoryId }); }
  371. try { l3 = await _db.Ado.SqlQueryAsync<KpiDetailDto>(l3Sql, new { moduleCode, tenantId, factoryId }); }
  372. catch { l3 = await _db.Ado.SqlQueryAsync<KpiDetailDto>(l3FallbackSql, new { tenantId, factoryId }); }
  373. try { alerts = await _db.Ado.SqlQueryAsync<S8AlertDto>(alertSql, new { moduleCode, tenantId, factoryId }); }
  374. catch { alerts = new List<S8AlertDto>(); }
  375. var schedules = new List<S2ScheduleDto>();
  376. var s2Alerts = new List<S8AlertDto>();
  377. var decomposition = new List<S2DecompositionDto>();
  378. var trend = new List<S2TrendDto>();
  379. var distribution = new List<S2DistributionDto>();
  380. var branchKpis = new List<S3BranchKpiGroupDto>();
  381. S2SyncStatusDto? syncStatus = null;
  382. if (moduleCode == "S2")
  383. {
  384. syncStatus = await GetS2SyncStatusAsync();
  385. s2Alerts = await GetS2DerivedAlertsAsync(tenantId, factoryId);
  386. if (s2Alerts.Count > 0)
  387. alerts = s2Alerts;
  388. decomposition = await GetS2DecompositionAsync(tenantId, factoryId, l2);
  389. trend = await GetS2TrendAsync(tenantId, factoryId);
  390. distribution = await GetS2DistributionAsync(tenantId, factoryId);
  391. try
  392. {
  393. schedules = await _db.Ado.SqlQueryAsync<S2ScheduleDto>(
  394. """
  395. SELECT work_order AS OrderNo,
  396. COALESCE(NULLIF(item_name, ''), item_code, work_order) AS Product,
  397. COALESCE(NULLIF(sales_order_no, ''), work_order) AS Customer,
  398. prod_line AS ProductionLine,
  399. qty_ordered AS Quantity,
  400. due_date AS DeliveryDate,
  401. first_start_time AS StartTime,
  402. schedule_cycle_days AS CycleDays,
  403. CASE WHEN schedule_satisfaction_flag = 1 THEN 100
  404. WHEN due_date IS NULL OR last_plan_date IS NULL THEN 70
  405. ELSE 65 END AS Satisfaction,
  406. CASE WHEN schedule_satisfaction_flag = 1 THEN '已锁定'
  407. WHEN last_plan_date IS NULL THEN '待排程'
  408. ELSE '资源异常' END AS Status
  409. FROM dwd_order_schedule_trans
  410. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  411. AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
  412. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  413. ORDER BY calc_time DESC, id DESC LIMIT 1)
  414. ORDER BY urgent_flag DESC, due_date IS NULL, due_date, id DESC
  415. LIMIT 30
  416. """,
  417. new { tenantId, factoryId });
  418. }
  419. catch
  420. {
  421. schedules = new List<S2ScheduleDto>();
  422. }
  423. }
  424. if (moduleCode == "S3")
  425. {
  426. syncStatus = await GetMdpSyncStatusAsync("S3_MDP_SYNC_TRANSFORM");
  427. var s3Alerts = await GetS3DerivedAlertsAsync(tenantId, factoryId);
  428. if (s3Alerts.Count > 0)
  429. alerts = s3Alerts;
  430. decomposition = await GetS3DecompositionAsync(tenantId, factoryId, l2);
  431. branchKpis = await GetS3BranchKpisAsync(tenantId, factoryId, l2);
  432. trend = await GetS3TrendAsync(tenantId, factoryId);
  433. distribution = await GetS3DistributionAsync(tenantId, factoryId);
  434. }
  435. if (moduleCode == "S4")
  436. {
  437. syncStatus = await GetMdpSyncStatusAsync("S4_MDP_SYNC_TRANSFORM");
  438. var s4Alerts = await GetS4DerivedAlertsAsync(tenantId, factoryId);
  439. if (s4Alerts.Count > 0)
  440. alerts = s4Alerts;
  441. trend = await GetS4TrendAsync(tenantId, factoryId);
  442. distribution = await GetS4DistributionAsync(tenantId, factoryId);
  443. }
  444. var drillGroups = BuildKpiDrillGroups(moduleCode, l2, l3);
  445. if (moduleCode == "S2")
  446. decomposition = EnrichS2DecompositionWithL3(decomposition, l3);
  447. return Ok(new
  448. {
  449. moduleCode,
  450. l2,
  451. l3,
  452. drillGroups,
  453. syncStatus,
  454. decomposition,
  455. branchKpis,
  456. trend,
  457. distribution,
  458. schedules,
  459. alerts = alerts.Select(x => new
  460. {
  461. time = x.Time,
  462. message = x.Message,
  463. level = x.LevelCode
  464. })
  465. });
  466. }
  467. private static List<KpiDrillGroupDto> BuildKpiDrillGroups(string moduleCode, List<KpiDetailDto> l2, List<KpiDetailDto> l3)
  468. {
  469. if (l3.Count == 0) return new List<KpiDrillGroupDto>();
  470. var parentTitles = l2.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  471. .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
  472. .ToDictionary(g => g.Key, g => g.First().MetricName ?? g.Key, StringComparer.OrdinalIgnoreCase);
  473. return l3.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  474. .GroupBy(u => string.IsNullOrWhiteSpace(u.ParentMetricCode) ? "_root" : u.ParentMetricCode!, StringComparer.OrdinalIgnoreCase)
  475. .Select(g =>
  476. {
  477. var parentCode = g.Key == "_root" ? null : g.Key;
  478. var title = parentCode != null && parentTitles.TryGetValue(parentCode, out var parentName)
  479. ? parentName
  480. : parentCode ?? $"{moduleCode} L3";
  481. return new KpiDrillGroupDto
  482. {
  483. ParentMetricCode = parentCode,
  484. Title = title,
  485. Items = g.Select(item => new KpiDrillItemDto
  486. {
  487. MetricCode = item.MetricCode,
  488. MetricName = item.MetricName ?? item.MetricCode,
  489. MetricValue = item.MetricValue,
  490. TargetValue = item.TargetValue,
  491. StatusColor = item.StatusColor,
  492. TrendFlag = item.TrendFlag,
  493. StatDate = item.StatDate?.ToString("yyyy-MM-dd")
  494. }).ToList()
  495. };
  496. })
  497. .OrderBy(x => x.ParentMetricCode)
  498. .ToList();
  499. }
  500. private static List<S2DecompositionDto> EnrichS2DecompositionWithL3(List<S2DecompositionDto> decomposition, List<KpiDetailDto> l3)
  501. {
  502. if (l3.Count == 0) return decomposition;
  503. var latestL3 = l3.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  504. .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
  505. .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  506. foreach (var section in decomposition)
  507. {
  508. if (section.Title == "工序排程")
  509. {
  510. section.Metrics = new List<string>
  511. {
  512. $"1. 周期:{FormatMetric(latestL3, "S2_L3_001", "天")}",
  513. $"2. 满足率:{FormatMetric(latestL3, "S2_L3_002", "%")}",
  514. $"3. 人效:{FormatMetric(latestL3, "S2_L3_003", "道/人")}"
  515. };
  516. }
  517. else if (section.Title == "资源排程")
  518. {
  519. section.Metrics = new List<string>
  520. {
  521. $"1. 设备周期:{FormatMetric(latestL3, "S2_L3_004", "天")}",
  522. $"2. 设备满足率:{FormatMetric(latestL3, "S2_L3_005", "%")}",
  523. $"3. 设备人效:{FormatMetric(latestL3, "S2_L3_006", "台/人")}"
  524. };
  525. }
  526. }
  527. return decomposition;
  528. }
  529. [HttpPost("s1-mdp/refresh")]
  530. public async Task<IActionResult> RefreshS1Mdp(CancellationToken cancellationToken)
  531. {
  532. var user = App.GetRequiredService<UserManager>();
  533. if (user.TenantId <= 0)
  534. return Unauthorized(new { ok = false, message = "请先登录" });
  535. var tenantId = AidopTenantScope.ResolveOrThrow(user);
  536. var rebuild = App.GetRequiredService<Admin.NET.Plugin.AiDOP.DataPlatform.S1Refresh.S1DashboardRebuildService>();
  537. var (status, body) = await rebuild.EnqueueAsync(tenantId, 1, user.UserId, "MANUAL", cancellationToken);
  538. return StatusCode(status, body);
  539. }
  540. [HttpPost("s2-mdp/refresh")]
  541. public async Task<IActionResult> RefreshS2Mdp(CancellationToken cancellationToken)
  542. {
  543. var user = App.GetRequiredService<UserManager>();
  544. if (user.TenantId <= 0)
  545. return Unauthorized(new { ok = false, message = "请先登录" });
  546. var tenantId = AidopTenantScope.ResolveOrThrow(user);
  547. var rebuild = App.GetRequiredService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.ModuleRebuildService>();
  548. var (status, body) = await rebuild.EnqueueAsync("S2", tenantId, 1, user.UserId, "MANUAL", cancellationToken);
  549. return StatusCode(status, body);
  550. }
  551. [HttpPost("s3-mdp/refresh")]
  552. public async Task<IActionResult> RefreshS3Mdp(CancellationToken cancellationToken)
  553. {
  554. var user = App.GetRequiredService<UserManager>();
  555. if (user.TenantId <= 0)
  556. return Unauthorized(new { ok = false, message = "请先登录" });
  557. var tenantId = AidopTenantScope.ResolveOrThrow(user);
  558. var rebuild = App.GetRequiredService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.ModuleRebuildService>();
  559. var (status, body) = await rebuild.EnqueueAsync("S3", tenantId, 1, user.UserId, "MANUAL", cancellationToken);
  560. return StatusCode(status, body);
  561. }
  562. [HttpPost("s4-mdp/refresh")]
  563. public async Task<IActionResult> RefreshS4Mdp(CancellationToken cancellationToken)
  564. {
  565. var user = App.GetRequiredService<UserManager>();
  566. if (user.TenantId <= 0)
  567. return Unauthorized(new { ok = false, message = "请先登录" });
  568. var tenantId = AidopTenantScope.ResolveOrThrow(user);
  569. var rebuild = App.GetRequiredService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.ModuleRebuildService>();
  570. var (status, body) = await rebuild.EnqueueAsync("S4", tenantId, 1, user.UserId, "MANUAL", cancellationToken);
  571. return StatusCode(status, body);
  572. }
  573. [HttpPost("s1-mdp/inbound")]
  574. public async Task<IActionResult> InboundS1Mdp(
  575. [FromQuery] bool fullRefresh = false,
  576. CancellationToken cancellationToken = default)
  577. {
  578. var user = App.GetRequiredService<UserManager>();
  579. var tenantId = AidopTenantScope.ResolveOrThrow(user);
  580. var result = await _s1MdpSyncTransformService.RunInboundAsync(
  581. Admin.NET.Plugin.AiDOP.DataPlatform.S1Refresh.S1MdpRunScope.Create(tenantId, 1), null, fullRefresh, cancellationToken);
  582. return Ok(new { ok = true, result.BatchId, result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows });
  583. }
  584. [HttpPost("s2-mdp/inbound")]
  585. public async Task<IActionResult> InboundS2Mdp(
  586. [FromQuery] bool fullRefresh = false,
  587. CancellationToken cancellationToken = default)
  588. {
  589. var user = App.GetRequiredService<UserManager>();
  590. var resolvedTenantId = AidopTenantScope.ResolveOrThrow(user);
  591. var result = await _s2MdpSyncTransformService.RunInboundAsync(
  592. null, resolvedTenantId, fullRefresh, cancellationToken);
  593. return Ok(new { ok = true, result.BatchId, result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows });
  594. }
  595. [HttpPost("s3-mdp/inbound")]
  596. public async Task<IActionResult> InboundS3Mdp(
  597. [FromQuery] bool fullRefresh = false,
  598. CancellationToken cancellationToken = default)
  599. {
  600. var user = App.GetRequiredService<UserManager>();
  601. var resolvedTenantId = AidopTenantScope.ResolveOrThrow(user);
  602. var result = await _s3MdpSyncTransformService.RunInboundAsync(
  603. null, resolvedTenantId, fullRefresh, cancellationToken);
  604. return Ok(new { ok = true, result.BatchId, result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows });
  605. }
  606. [HttpPost("s4-mdp/inbound")]
  607. public async Task<IActionResult> InboundS4Mdp(
  608. [FromQuery] bool fullRefresh = false,
  609. CancellationToken cancellationToken = default)
  610. {
  611. var user = App.GetRequiredService<UserManager>();
  612. var resolvedTenantId = AidopTenantScope.ResolveOrThrow(user);
  613. var result = await _s4MdpSyncTransformService.RunInboundAsync(
  614. null, resolvedTenantId, fullRefresh, cancellationToken);
  615. return Ok(new { ok = true, result.BatchId, result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows });
  616. }
  617. /// <summary>
  618. /// S5 物料仓储 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
  619. /// 路径 A:方老师 v5.4 KPI #16/#17/#18/#19 原 SQL 直发 T8。
  620. /// 边界:只读 SELECT;仅 refresh 内触发;禁止看板实时查 T8;禁止高频调度。
  621. /// </summary>
  622. [HttpPost("s5-mdp/refresh")]
  623. public async Task<IActionResult> RefreshS5Mdp(CancellationToken cancellationToken) =>
  624. await EnqueueModuleRebuildAsync("S5", cancellationToken);
  625. [HttpPost("s5-mdp/inbound")]
  626. public async Task<IActionResult> InboundS5Mdp(
  627. [FromQuery] long factoryId = 1,
  628. CancellationToken cancellationToken = default)
  629. {
  630. var tenantId = AidopTenantScope.ResolveOrThrow(App.GetRequiredService<UserManager>());
  631. var option = S5MdpRefreshOption.Default();
  632. option.TargetTenantId = tenantId;
  633. option.TargetFactoryId = factoryId;
  634. var result = await _s5MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL", option);
  635. return Ok(new { ok = true, result.BatchId, result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows });
  636. }
  637. /// <summary>
  638. /// S5 委外发料单 数据中台只读链路手动刷新(DOP 内部:NbrMaster/NbrDetail Type='CA' → mdp_stg → mdp_std)。
  639. /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp 表;不碰 T8;CA 为 0 行时成功、处理数 0。
  640. /// </summary>
  641. [HttpPost("s5-outsource-issue-mdp/refresh")]
  642. public async Task<IActionResult> RefreshS5OutsourceIssueMdp(CancellationToken cancellationToken)
  643. {
  644. var result = await _outsourceIssueMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
  645. return Ok(new
  646. {
  647. ok = true,
  648. result.BatchId,
  649. result.HeadStageRows,
  650. result.DetailStageRows,
  651. result.HeadStandardRows,
  652. result.DetailStandardRows
  653. });
  654. }
  655. /// <summary>
  656. /// S6 过程检验单(IPQC)数据中台只读链路手动刷新(DOP 内部:归口源 qms_gcjyd → mdp_stg_ipqc_inspection → mdp_std_ipqc_inspection)。
  657. /// 独立于 s6-mdp/refresh 的 KPI 管线;只读源、只写 mdp 表;不碰 T8;源为 0 行时成功、处理数 0。
  658. /// </summary>
  659. [HttpPost("s6-ipqc-inspection-mdp/refresh")]
  660. public async Task<IActionResult> RefreshS6IpqcInspectionMdp(CancellationToken cancellationToken)
  661. {
  662. var result = await _ipqcInspectionMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
  663. return Ok(new
  664. {
  665. ok = true,
  666. result.BatchId,
  667. result.HeadStageRows,
  668. result.HeadStandardRows
  669. });
  670. }
  671. /// <summary>
  672. /// S6 IPQC 双模式入站:MdpDbPullExecutor → mdp_stg_ipqc_pull,再跑标准层转换。
  673. /// </summary>
  674. [HttpPost("s6-ipqc-inspection-mdp/inbound")]
  675. public async Task<IActionResult> InboundS6IpqcInspectionMdp(
  676. [FromQuery] long? tenantId,
  677. [FromQuery] bool fullRefresh = false,
  678. CancellationToken cancellationToken = default)
  679. {
  680. var result = await _ipqcInspectionMdpSyncService.RunInboundAsync(
  681. tenantId ?? 0,
  682. fullRefresh,
  683. cancellationToken);
  684. return Ok(new
  685. {
  686. ok = true,
  687. result.PullBatchId,
  688. result.RowsPulled,
  689. result.RowsWrittenStg,
  690. result.NewCursor,
  691. result.TransformBatchId,
  692. result.HeadStageRows,
  693. result.HeadStandardRows,
  694. result.DetailStageRows,
  695. result.DetailStandardRows
  696. });
  697. }
  698. /// <summary>S6 报工双模式入站:S6_REPORT / S6_REPORT_API → mdp_stg_s6_report → mdp_std_s6_report。</summary>
  699. [HttpPost("s6-report-mdp/inbound")]
  700. public async Task<IActionResult> InboundS6ReportMdp(
  701. [FromQuery] long? tenantId,
  702. [FromQuery] bool fullRefresh = false,
  703. [FromQuery] string? entityCode = null,
  704. CancellationToken cancellationToken = default)
  705. {
  706. var result = await _reportWorkMdpSyncService.RunInboundAsync(
  707. tenantId ?? 0,
  708. fullRefresh,
  709. entityCode,
  710. cancellationToken);
  711. return Ok(new
  712. {
  713. ok = true,
  714. result.PullBatchId,
  715. result.RowsPulled,
  716. result.RowsWrittenStg,
  717. result.TransformBatchId,
  718. result.StandardRows
  719. });
  720. }
  721. /// <summary>
  722. /// S5 采购收货单 数据中台只读链路手动刷新(DOP 内部方案 1:PurOrdRctDetail/Master RctType='rc' → mdp_std_purchase_receipt)。
  723. /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp_std_purchase_receipt;不读/不改 S3 mdp_stg_receipt、S4 ado_s4_receipt;不碰 T8;rc 为 0 行时成功、处理数 0。
  724. /// </summary>
  725. [HttpPost("s5-purchase-receipt-mdp/refresh")]
  726. public async Task<IActionResult> RefreshS5PurchaseReceiptMdp(CancellationToken cancellationToken)
  727. {
  728. var result = await _purchaseReceiptMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
  729. return Ok(new
  730. {
  731. ok = true,
  732. batchId = result.BatchId,
  733. stdRows = result.StdRows
  734. });
  735. }
  736. /// <summary>
  737. /// S5 来料检验(IQC)标准事实层投影手动刷新。
  738. /// qms_qcp_inspbill(+报检申请/分录+审批流实例)→ mdp_std_iqc_inspection。
  739. /// Grain = 一张检验单;只读业务表、只写 mdp_std_iqc_inspection;不做订单级完成聚合;不投 PO 行号。
  740. /// tenantId 省略或 0 表示全租户。
  741. /// </summary>
  742. [HttpPost("s5-iqc-inspection-mdp/refresh")]
  743. public async Task<IActionResult> RefreshS5IqcInspectionMdp(
  744. [FromQuery] long? tenantId,
  745. CancellationToken cancellationToken = default)
  746. {
  747. var result = await _iqcInspectionMdpSyncService.RunFullAsync(
  748. tenantId ?? 0, "MANUAL", cancellationToken);
  749. return Ok(new
  750. {
  751. ok = true,
  752. batchId = result.BatchId,
  753. stdRows = result.StdRows
  754. });
  755. }
  756. /// <summary>
  757. /// S8 Stage-3 采购完成态全量对账手动入口。
  758. /// Source B(WMS/MES 的 PurOrdDetail)→ mdp_std_purchase_order_completion。
  759. /// Grain = 一条采购行;只读源侧、只写 completion 事实表;
  760. /// 绝不覆盖 mdp_std_purchase_order.status,也不用数量重新推导完成。
  761. /// tenantId 省略或 0 表示全租户;指定时只写、只淘汰该租户。
  762. /// </summary>
  763. [HttpPost("s3-po-completion-mdp/refresh")]
  764. public async Task<IActionResult> RefreshS3PoCompletionMdp(
  765. [FromQuery] long? tenantId,
  766. CancellationToken cancellationToken = default)
  767. {
  768. var r = await _poCompletionMdpSyncService.RunFullAsync(
  769. tenantId ?? 0, "MANUAL", cancellationToken);
  770. return Ok(new
  771. {
  772. ok = true,
  773. status = "SUCCESS",
  774. batchId = r.BatchId,
  775. sourceCode = r.SourceCode,
  776. sourceRows = r.SourceRows,
  777. matchedRows = r.MatchedRows,
  778. writtenRows = r.WrittenRows,
  779. skippedRows = r.SkippedRows,
  780. tenantUnresolvedRows = r.TenantUnresolvedRows,
  781. unmatchedRows = r.UnmatchedRows,
  782. completedRows = r.CompletedRows,
  783. notCompletedRows = r.NotCompletedRows,
  784. unknownStatusRows = r.UnknownStatusRows,
  785. staleRemoved = r.StaleRemoved
  786. });
  787. }
  788. /// <summary>
  789. /// S5 采购收货双模式入站:MdpDbPullExecutor → mdp_stg_purchase_receipt,再跑标准层转换。
  790. /// 独立作业模式仍可用 s5-purchase-receipt-mdp/refresh(直读源表建 std)。
  791. /// </summary>
  792. [HttpPost("s5-purchase-receipt-mdp/inbound")]
  793. public async Task<IActionResult> InboundS5PurchaseReceiptMdp(
  794. [FromQuery] long? tenantId,
  795. [FromQuery] bool fullRefresh = false,
  796. CancellationToken cancellationToken = default)
  797. {
  798. var result = await _purchaseReceiptMdpSyncService.RunInboundAsync(
  799. tenantId ?? 0,
  800. fullRefresh,
  801. cancellationToken);
  802. return Ok(new
  803. {
  804. ok = true,
  805. result.PullBatchId,
  806. result.RowsPulled,
  807. result.RowsWrittenStg,
  808. result.NewCursor,
  809. result.PullMessage,
  810. result.TransformBatchId,
  811. result.StdRows
  812. });
  813. }
  814. /// <summary>
  815. /// S5 生产退料单 数据中台只读链路手动刷新(DOP 内部 head-detail:NbrMaster/NbrDetail Type='WOD' → mdp_std_production_return(_detail))。
  816. /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp_std 表;不建 stg/dwd;不碰 S2 写路径、S3/S4、T8;WOD 为 0 行时成功、处理数 0。
  817. /// </summary>
  818. [HttpPost("s5-production-return-mdp/refresh")]
  819. public async Task<IActionResult> RefreshS5ProductionReturnMdp(CancellationToken cancellationToken)
  820. {
  821. var result = await _productionReturnMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
  822. return Ok(new
  823. {
  824. ok = true,
  825. batchId = result.BatchId,
  826. headRows = result.HeadRows,
  827. detailRows = result.DetailRows
  828. });
  829. }
  830. /// <summary>
  831. /// S5 生产领料单 数据中台只读链路手动刷新(DOP 内部头级:NbrMaster Type='SM' → mdp_std_production_issue)。
  832. /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp_std 表;不建 stg/detail/dwd;不碰 WorkOrderPickBillService 写路径、S3/S4、T8;SM 为 0 行时成功、处理数 0。
  833. /// </summary>
  834. [HttpPost("s5-production-issue-mdp/refresh")]
  835. public async Task<IActionResult> RefreshS5ProductionIssueMdp(CancellationToken cancellationToken)
  836. {
  837. var result = await _productionIssueMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
  838. return Ok(new
  839. {
  840. ok = true,
  841. batchId = result.BatchId,
  842. headRows = result.HeadRows
  843. });
  844. }
  845. /// <summary>
  846. /// WP9 S5:生产领料列表切源到 165(DOPDEMORQ_SQLSERVER)并 FULL Replace 标准层;本库历史 SM 打 AIDOP_LEGACY。
  847. /// 破坏性重建 mdp_std_production_issue,运维确认后调用。
  848. /// </summary>
  849. [HttpPost("s5-production-issue-mdp/source-switch")]
  850. public async Task<IActionResult> SwitchS5ProductionIssueSource(
  851. [FromQuery] long tenantId = 0,
  852. CancellationToken cancellationToken = default)
  853. {
  854. var result = await _productionIssueMdpSyncService.ActivateSqlServerSourceAndSwitchAsync(tenantId, cancellationToken);
  855. return Ok(new
  856. {
  857. ok = true,
  858. source = "DOPDEMORQ_SQLSERVER",
  859. entity = "S5_PRODUCTION_ISSUE_MASTER_SQLSERVER",
  860. pullBatchId = result.PullBatchId,
  861. rowsPulled = result.RowsPulled,
  862. rowsWrittenStg = result.RowsWrittenStg,
  863. transformBatchId = result.TransformBatchId,
  864. stdRows = result.StdRows,
  865. message = "已切源并全量重建生产领料标准层;本库历史 SM 已标记 AIDOP_LEGACY"
  866. });
  867. }
  868. /// <summary>
  869. /// S5 生产入库单 数据中台只读链路手动刷新(DOP 内部扁平:NbrMaster/NbrDetail Type='WOI' → mdp_std_production_receipt)。
  870. /// 独立于 s5-mdp/refresh 的 KPI 管线;只读源、只写 mdp_std 表;不建 stg/dwd;不碰 S2 写路径、S7、S3/S4、T8;WOI 为 0 行时成功、处理数 0。
  871. /// </summary>
  872. [HttpPost("s5-production-receipt-mdp/refresh")]
  873. public async Task<IActionResult> RefreshS5ProductionReceiptMdp(CancellationToken cancellationToken)
  874. {
  875. var result = await _productionReceiptMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
  876. return Ok(new
  877. {
  878. ok = true,
  879. batchId = result.BatchId,
  880. rows = result.Rows
  881. });
  882. }
  883. /// <summary>
  884. /// S7 成品入库双模式入站:NbrMaster → mdp_stg_production_receipt,再跑 WOI→mdp_std_production_receipt。
  885. /// </summary>
  886. [HttpPost("s7-production-receipt-mdp/inbound")]
  887. public async Task<IActionResult> InboundS7ProductionReceiptMdp(
  888. [FromQuery] long? tenantId,
  889. [FromQuery] bool fullRefresh = false,
  890. CancellationToken cancellationToken = default)
  891. {
  892. var result = await _productionReceiptMdpSyncService.RunInboundAsync(
  893. tenantId ?? 0,
  894. fullRefresh,
  895. cancellationToken);
  896. return Ok(new
  897. {
  898. ok = true,
  899. result.PullBatchId,
  900. result.RowsPulled,
  901. result.RowsWrittenStg,
  902. result.NewCursor,
  903. result.TransformBatchId,
  904. result.StdRows
  905. });
  906. }
  907. /// <summary>
  908. /// S6 生产执行 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
  909. /// 路径 A:方老师 v5.4 KPI #22/#23 原 SQL 直发 T8。
  910. /// </summary>
  911. [HttpPost("s6-mdp/refresh")]
  912. public async Task<IActionResult> RefreshS6Mdp(CancellationToken cancellationToken) =>
  913. await EnqueueModuleRebuildAsync("S6", cancellationToken);
  914. [HttpPost("s6-mdp/inbound")]
  915. public async Task<IActionResult> InboundS6Mdp(
  916. [FromQuery] long factoryId = 1,
  917. CancellationToken cancellationToken = default)
  918. {
  919. var tenantId = AidopTenantScope.ResolveOrThrow(App.GetRequiredService<UserManager>());
  920. var option = S6MdpRefreshOption.Default();
  921. option.TargetTenantId = tenantId;
  922. option.TargetFactoryId = factoryId;
  923. var result = await _s6MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL", option);
  924. return Ok(new { ok = true, result.BatchId, result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows });
  925. }
  926. /// <summary>
  927. /// S7 成品仓储 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
  928. /// 路径 A:方老师 v5.4 KPI #25/#26/#27 原 SQL 直发 T8。
  929. /// </summary>
  930. [HttpPost("s7-mdp/refresh")]
  931. public async Task<IActionResult> RefreshS7Mdp(CancellationToken cancellationToken) =>
  932. await EnqueueModuleRebuildAsync("S7", cancellationToken);
  933. [HttpPost("s7-mdp/inbound")]
  934. public async Task<IActionResult> InboundS7Mdp(
  935. [FromQuery] long factoryId = 1,
  936. CancellationToken cancellationToken = default)
  937. {
  938. var tenantId = AidopTenantScope.ResolveOrThrow(App.GetRequiredService<UserManager>());
  939. var option = S7MdpRefreshOption.Default();
  940. option.TargetTenantId = tenantId;
  941. option.TargetFactoryId = factoryId;
  942. var result = await _s7MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL", option);
  943. return Ok(new { ok = true, result.BatchId, result.StageRows, result.StandardRows, result.DwdRows, result.KpiRows });
  944. }
  945. [HttpPost("mdp/refresh/{moduleCode}")]
  946. public async Task<IActionResult> RefreshModuleMdp(string moduleCode, CancellationToken cancellationToken)
  947. {
  948. var mc = string.IsNullOrWhiteSpace(moduleCode) ? string.Empty : moduleCode.Trim().ToUpperInvariant();
  949. return mc switch
  950. {
  951. "S1" => await RefreshS1Mdp(cancellationToken),
  952. "S2" => await RefreshS2Mdp(cancellationToken),
  953. "S3" => await RefreshS3Mdp(cancellationToken),
  954. "S4" => await RefreshS4Mdp(cancellationToken),
  955. "S5" or "S6" or "S7" => await EnqueueModuleRebuildAsync(mc, cancellationToken),
  956. _ => Ok(new { ok = true, moduleCode = mc, message = "当前模块暂无 MDP 刷新任务,仅刷新动态看板数据。" })
  957. };
  958. }
  959. private async Task<IActionResult> EnqueueModuleRebuildAsync(string moduleCode, CancellationToken cancellationToken)
  960. {
  961. var user = App.GetRequiredService<UserManager>();
  962. if (user.TenantId <= 0)
  963. return Unauthorized(new { ok = false, message = "请先登录" });
  964. var tenantId = AidopTenantScope.ResolveOrThrow(user);
  965. var rebuild = App.GetRequiredService<Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild.ModuleRebuildService>();
  966. var (status, body) = await rebuild.EnqueueAsync(moduleCode, tenantId, 1, user.UserId, "MANUAL", cancellationToken);
  967. return StatusCode(status, body);
  968. }
  969. private async Task<S2SyncStatusDto?> GetS2SyncStatusAsync()
  970. {
  971. return await GetMdpSyncStatusAsync("S2_MDP_SYNC_TRANSFORM");
  972. }
  973. private async Task<S2SyncStatusDto?> GetMdpSyncStatusAsync(string jobCode)
  974. {
  975. try
  976. {
  977. return await _db.Ado.SqlQuerySingleAsync<S2SyncStatusDto>(
  978. """
  979. SELECT batch_id AS BatchId, status AS Status, stage_rows AS StageRows,
  980. standard_rows AS StandardRows, dwd_rows AS DwdRows,
  981. start_time AS StartTime, end_time AS EndTime, error_message AS ErrorMessage
  982. FROM mdp_transform_run_log
  983. WHERE job_code=@JobCode
  984. ORDER BY start_time DESC, id DESC
  985. LIMIT 1
  986. """,
  987. new SugarParameter("@JobCode", jobCode));
  988. }
  989. catch
  990. {
  991. return null;
  992. }
  993. }
  994. private async Task<List<S2DecompositionDto>> GetS2DecompositionAsync(long tenantId, long factoryId, List<KpiDetailDto> l2)
  995. {
  996. var latestL1 = new Dictionary<string, KpiDetailDto>(StringComparer.OrdinalIgnoreCase);
  997. try
  998. {
  999. var rows = await _db.Ado.SqlQueryAsync<KpiDetailDto>(
  1000. """
  1001. SELECT v.module_code AS ModuleCode, v.metric_code AS MetricCode, k.MetricName AS MetricName,
  1002. v.metric_value AS MetricValue, v.target_value AS TargetValue,
  1003. v.status_color AS StatusColor, v.trend_flag AS TrendFlag, v.biz_date AS StatDate
  1004. FROM ado_s9_kpi_value_l1_day v
  1005. LEFT JOIN ado_smart_ops_kpi_master k ON k.TenantId=v.tenant_id AND k.MetricCode=v.metric_code
  1006. WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.module_code='S2' AND v.is_deleted=0
  1007. AND v.biz_date=(SELECT MAX(biz_date) FROM ado_s9_kpi_value_l1_day
  1008. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code='S2' AND is_deleted=0)
  1009. """,
  1010. new { tenantId, factoryId });
  1011. latestL1 = rows.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  1012. .ToDictionary(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase);
  1013. }
  1014. catch
  1015. {
  1016. latestL1 = new Dictionary<string, KpiDetailDto>(StringComparer.OrdinalIgnoreCase);
  1017. }
  1018. var latestL2 = l2.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  1019. .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
  1020. .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  1021. var operationSummary = await GetS2OperationSummaryAsync(tenantId, factoryId);
  1022. var resourceSummary = await GetS2ResourceSummaryAsync(tenantId, factoryId);
  1023. return new List<S2DecompositionDto>
  1024. {
  1025. new()
  1026. {
  1027. Title = "订单排程",
  1028. Active = true,
  1029. Metrics = new List<string>
  1030. {
  1031. $"1. 周期:{FormatMetric(latestL1, "S2_L1_001", "天")}",
  1032. $"2. 满足率:{FormatMetric(latestL1, "S2_L1_002", "%")}",
  1033. $"3. 在制库存:{FormatMetric(latestL1, "S2_L1_004", "天")}",
  1034. $"4. 人效:{FormatMetric(latestL1, "S2_L1_003", "单/人")}"
  1035. }
  1036. },
  1037. new()
  1038. {
  1039. Title = "工单排程",
  1040. Metrics = new List<string>
  1041. {
  1042. $"1. 周期:{FormatMetric(latestL2, "S2_L2_001", "天")}",
  1043. $"2. 满足率:{FormatMetric(latestL2, "S2_L2_002", "%")}",
  1044. $"3. 人效:{FormatMetric(latestL2, "S2_L2_003", "单/人")}"
  1045. }
  1046. },
  1047. new()
  1048. {
  1049. Title = "工序排程",
  1050. Metrics = new List<string>
  1051. {
  1052. $"1. 工序数:{operationSummary.OperationCount}",
  1053. $"2. 完成量:{Math.Round(operationSummary.CompletedQty ?? 0, 2)}",
  1054. $"3. 排程量:{Math.Round(operationSummary.ScheduledQty ?? 0, 2)}"
  1055. }
  1056. },
  1057. new()
  1058. {
  1059. Title = "资源排程",
  1060. Metrics = new List<string>
  1061. {
  1062. $"1. 人员数:{Math.Round(resourceSummary.PersonCount ?? 0, 2)}",
  1063. $"2. 异常数:{resourceSummary.RiskCount}",
  1064. $"3. 产线数:{resourceSummary.LineCount}"
  1065. }
  1066. }
  1067. };
  1068. }
  1069. private async Task<S2OperationSummaryDto> GetS2OperationSummaryAsync(long tenantId, long factoryId)
  1070. {
  1071. try
  1072. {
  1073. return await _db.Ado.SqlQuerySingleAsync<S2OperationSummaryDto>(
  1074. """
  1075. SELECT SUM(operation_count) AS OperationCount,
  1076. SUM(completed_op_qty) AS CompletedQty,
  1077. SUM(scheduled_qty) AS ScheduledQty
  1078. FROM dwd_order_schedule_trans
  1079. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1080. AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
  1081. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1082. ORDER BY calc_time DESC, id DESC LIMIT 1)
  1083. """,
  1084. new { tenantId, factoryId }) ?? new S2OperationSummaryDto();
  1085. }
  1086. catch
  1087. {
  1088. return new S2OperationSummaryDto();
  1089. }
  1090. }
  1091. private async Task<S2ResourceSummaryDto> GetS2ResourceSummaryAsync(long tenantId, long factoryId)
  1092. {
  1093. try
  1094. {
  1095. return await _db.Ado.SqlQuerySingleAsync<S2ResourceSummaryDto>(
  1096. """
  1097. SELECT SUM(resource_person_count) AS PersonCount,
  1098. SUM(CASE WHEN schedule_satisfaction_flag=0 THEN 1 ELSE 0 END) AS RiskCount,
  1099. COUNT(DISTINCT COALESCE(NULLIF(prod_line,''), NULLIF(site_code,''), '未分配')) AS LineCount
  1100. FROM dwd_order_schedule_trans
  1101. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1102. AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
  1103. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1104. ORDER BY calc_time DESC, id DESC LIMIT 1)
  1105. """,
  1106. new { tenantId, factoryId }) ?? new S2ResourceSummaryDto();
  1107. }
  1108. catch
  1109. {
  1110. return new S2ResourceSummaryDto();
  1111. }
  1112. }
  1113. private async Task<List<S2TrendDto>> GetS2TrendAsync(long tenantId, long factoryId)
  1114. {
  1115. try
  1116. {
  1117. return await _db.Ado.SqlQueryAsync<S2TrendDto>(
  1118. """
  1119. SELECT DATE_FORMAT(d.biz_date, '%m-%d') AS DateLabel,
  1120. MAX(CASE WHEN d.metric_code='S2_L1_001' THEN d.metric_value END) AS CycleDays,
  1121. MAX(CASE WHEN d.metric_code='S2_L1_002' THEN d.metric_value END) AS SatisfactionPct
  1122. FROM ado_s9_kpi_value_l1_day d
  1123. WHERE d.tenant_id=@tenantId AND d.factory_id=@factoryId AND d.module_code='S2' AND d.is_deleted=0
  1124. AND d.metric_code IN ('S2_L1_001','S2_L1_002')
  1125. GROUP BY d.biz_date
  1126. ORDER BY d.biz_date DESC
  1127. LIMIT 7
  1128. """,
  1129. new { tenantId, factoryId });
  1130. }
  1131. catch
  1132. {
  1133. return new List<S2TrendDto>();
  1134. }
  1135. }
  1136. private async Task<List<S2DistributionDto>> GetS2DistributionAsync(long tenantId, long factoryId)
  1137. {
  1138. try
  1139. {
  1140. return await _db.Ado.SqlQueryAsync<S2DistributionDto>(
  1141. """
  1142. SELECT COALESCE(NULLIF(prod_line,''), NULLIF(site_code,''), '未分配') AS Name,
  1143. ROUND(100 * SUM(schedule_satisfaction_flag) / NULLIF(COUNT(1), 0), 2) AS SatisfactionPct,
  1144. COUNT(1) AS TotalCount,
  1145. SUM(CASE WHEN schedule_satisfaction_flag=0 THEN 1 ELSE 0 END) AS RiskCount
  1146. FROM dwd_order_schedule_trans
  1147. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1148. AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
  1149. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1150. ORDER BY calc_time DESC, id DESC LIMIT 1)
  1151. GROUP BY COALESCE(NULLIF(prod_line,''), NULLIF(site_code,''), '未分配')
  1152. ORDER BY SatisfactionPct, TotalCount DESC
  1153. LIMIT 8
  1154. """,
  1155. new { tenantId, factoryId });
  1156. }
  1157. catch
  1158. {
  1159. return new List<S2DistributionDto>();
  1160. }
  1161. }
  1162. private async Task<List<S8AlertDto>> GetS2DerivedAlertsAsync(long tenantId, long factoryId)
  1163. {
  1164. try
  1165. {
  1166. return await _db.Ado.SqlQueryAsync<S8AlertDto>(
  1167. """
  1168. SELECT DATE_FORMAT(calc_time, '%H:%i:%s') AS Time,
  1169. CASE
  1170. WHEN last_plan_date IS NULL THEN 'high'
  1171. WHEN due_date IS NOT NULL AND last_plan_date > due_date THEN 'critical'
  1172. WHEN IFNULL(resource_person_count, 0) <= 0 THEN 'medium'
  1173. ELSE 'info'
  1174. END AS LevelCode,
  1175. CONCAT('工单 ', work_order, ' ',
  1176. CASE
  1177. WHEN last_plan_date IS NULL THEN '尚未形成排程'
  1178. WHEN due_date IS NOT NULL AND last_plan_date > due_date THEN '排程晚于交期'
  1179. WHEN IFNULL(resource_person_count, 0) <= 0 THEN '缺少资源人员配置'
  1180. ELSE '排程状态正常'
  1181. END) AS Message
  1182. FROM dwd_order_schedule_trans
  1183. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1184. AND calc_batch_id=(SELECT calc_batch_id FROM dwd_order_schedule_trans
  1185. WHERE tenant_id=@tenantId AND factory_id=@factoryId
  1186. ORDER BY calc_time DESC, id DESC LIMIT 1)
  1187. AND (last_plan_date IS NULL OR (due_date IS NOT NULL AND last_plan_date > due_date) OR IFNULL(resource_person_count, 0) <= 0)
  1188. ORDER BY FIELD(LevelCode, 'critical', 'high', 'medium', 'info'), due_date IS NULL, due_date
  1189. LIMIT 20
  1190. """,
  1191. new { tenantId, factoryId });
  1192. }
  1193. catch
  1194. {
  1195. return new List<S8AlertDto>();
  1196. }
  1197. }
  1198. private async Task<List<S2DecompositionDto>> GetS3DecompositionAsync(long tenantId, long factoryId, List<KpiDetailDto> l2)
  1199. {
  1200. var l1 = await GetLatestModuleL1Async("S3", tenantId, factoryId);
  1201. var latestL2 = l2.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  1202. .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
  1203. .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  1204. var readiness = await GetS3ReadinessSummaryAsync(tenantId, factoryId);
  1205. var delivery = await GetS3DeliverySummaryAsync(tenantId, factoryId);
  1206. return new List<S2DecompositionDto>
  1207. {
  1208. new()
  1209. {
  1210. Title = "物料需求计划",
  1211. Active = true,
  1212. Metrics = new List<string>
  1213. {
  1214. $"1. 周期:{FormatMetric(l1, "S3_L1_001", "天")}",
  1215. $"2. 满足率:{Math.Round(readiness.ReadyRatePct ?? GetMetricValue(l1, "S3_L1_002") ?? 0, 2)}%",
  1216. $"3. 覆盖SKU:{readiness.ComponentCount}",
  1217. $"4. 缺口数量:{Math.Round(readiness.ShortageQty ?? 0, 2)}"
  1218. }
  1219. },
  1220. new()
  1221. {
  1222. Title = "物料交货计划",
  1223. Metrics = new List<string>
  1224. {
  1225. $"1. 周期:{FormatMetric(latestL2, "S3_L2_004", "天")}",
  1226. $"2. 满足率:{FormatMetric(latestL2, "S3_L2_005", "%")}",
  1227. $"3. 风险供应商:{delivery.RiskSupplierCount}",
  1228. $"4. 待交数量:{Math.Round(delivery.RemainingQty ?? 0, 2)}"
  1229. }
  1230. }
  1231. };
  1232. }
  1233. private async Task<List<S3BranchKpiGroupDto>> GetS3BranchKpisAsync(long tenantId, long factoryId, List<KpiDetailDto> l2)
  1234. {
  1235. var l1 = await GetLatestModuleL1Async("S3", tenantId, factoryId);
  1236. var latestL2 = l2.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  1237. .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
  1238. .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  1239. var readiness = await GetS3ReadinessSummaryAsync(tenantId, factoryId);
  1240. var delivery = await GetS3DeliverySummaryAsync(tenantId, factoryId);
  1241. var demandCycle = GetMetricValue(l1, "S3_L1_001");
  1242. var demandReadyRate = readiness.ReadyRatePct ?? GetMetricValue(l1, "S3_L1_002");
  1243. var deliveryCycle = GetMetricValue(latestL2, "S3_L2_004");
  1244. var deliveryRate = GetMetricValue(latestL2, "S3_L2_005");
  1245. return new List<S3BranchKpiGroupDto>
  1246. {
  1247. new()
  1248. {
  1249. Branch = "MRP",
  1250. Title = "物料需求计划",
  1251. Items = new List<S3BranchKpiDto>
  1252. {
  1253. BuildBranchKpi("需求计划周期", demandCycle, "d", InverseBar(demandCycle, 15m)),
  1254. BuildBranchKpi("需求计划满足率", demandReadyRate, "%", PercentBar(demandReadyRate)),
  1255. BuildBranchKpi("计划覆盖SKU", readiness.ComponentCount, "SKU", PercentBar(readiness.ComponentCount, Math.Max(readiness.ComponentCount, 1)))
  1256. }
  1257. },
  1258. new()
  1259. {
  1260. Branch = "MDP",
  1261. Title = "物料交货计划",
  1262. Items = new List<S3BranchKpiDto>
  1263. {
  1264. BuildBranchKpi("交货计划周期", deliveryCycle, "d", InverseBar(deliveryCycle, 10.5m)),
  1265. BuildBranchKpi("交货计划满足率", deliveryRate, "%", PercentBar(deliveryRate)),
  1266. BuildBranchKpi("风险供应商", delivery.RiskSupplierCount, "家", InverseBar(delivery.RiskSupplierCount, Math.Max(delivery.SupplierCount, 1)))
  1267. }
  1268. }
  1269. };
  1270. }
  1271. private async Task<List<S2TrendDto>> GetS3TrendAsync(long tenantId, long factoryId)
  1272. {
  1273. try
  1274. {
  1275. return await _db.Ado.SqlQueryAsync<S2TrendDto>(
  1276. """
  1277. SELECT DATE_FORMAT(d.biz_date, '%m-%d') AS DateLabel,
  1278. MAX(CASE WHEN d.metric_code='S3_L1_001' THEN d.metric_value END) AS CycleDays,
  1279. MAX(CASE WHEN d.metric_code='S3_L1_002' THEN d.metric_value END) AS SatisfactionPct
  1280. FROM ado_s9_kpi_value_l1_day d
  1281. WHERE d.tenant_id=@tenantId AND d.factory_id=@factoryId AND d.module_code='S3' AND d.is_deleted=0
  1282. AND d.metric_code IN ('S3_L1_001','S3_L1_002')
  1283. GROUP BY d.biz_date
  1284. ORDER BY d.biz_date DESC
  1285. LIMIT 7
  1286. """,
  1287. new { tenantId, factoryId });
  1288. }
  1289. catch
  1290. {
  1291. return new List<S2TrendDto>();
  1292. }
  1293. }
  1294. private async Task<List<S2DistributionDto>> GetS3DistributionAsync(long tenantId, long factoryId)
  1295. {
  1296. try
  1297. {
  1298. return await _db.Ado.SqlQueryAsync<S2DistributionDto>(
  1299. """
  1300. SELECT COALESCE(NULLIF(supplier_name,''), NULLIF(supplier_code,''), '未分配') AS Name,
  1301. ROUND(100 * SUM(CASE WHEN IFNULL(schedule_qty,0) >= IFNULL(order_qty,0) AND IFNULL(order_qty,0) > 0 THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 2) AS SatisfactionPct,
  1302. COUNT(1) AS TotalCount,
  1303. SUM(CASE WHEN risk_level IN ('HIGH','MEDIUM') THEN 1 ELSE 0 END) AS RiskCount
  1304. FROM dwd_supplier_delivery
  1305. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId
  1306. AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_delivery
  1307. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId)
  1308. GROUP BY COALESCE(NULLIF(supplier_name,''), NULLIF(supplier_code,''), '未分配')
  1309. ORDER BY RiskCount DESC, SatisfactionPct, TotalCount DESC
  1310. LIMIT 8
  1311. """,
  1312. new { tenantId, factoryId });
  1313. }
  1314. catch
  1315. {
  1316. return new List<S2DistributionDto>();
  1317. }
  1318. }
  1319. private async Task<List<S8AlertDto>> GetS3DerivedAlertsAsync(long tenantId, long factoryId)
  1320. {
  1321. try
  1322. {
  1323. return await _db.Ado.SqlQueryAsync<S8AlertDto>(
  1324. """
  1325. SELECT DATE_FORMAT(calc_time, '%H:%i:%s') AS Time,
  1326. CASE WHEN risk_level='HIGH' THEN 'critical'
  1327. WHEN risk_level='MEDIUM' THEN 'warning'
  1328. ELSE 'info' END AS LevelCode,
  1329. CONCAT('供应商 ', COALESCE(NULLIF(supplier_name,''), supplier_code, '未分配'),
  1330. ' 物料 ', COALESCE(NULLIF(item_code,''), '未指定'),
  1331. ':', COALESCE(NULLIF(risk_reason,''), risk_type, '供应风险')) AS Message
  1332. FROM dwd_supplier_risk
  1333. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId
  1334. AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_risk
  1335. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId)
  1336. ORDER BY FIELD(risk_level, 'HIGH', 'MEDIUM', 'LOW'), risk_count DESC, calc_time DESC
  1337. LIMIT 20
  1338. """,
  1339. new { tenantId, factoryId });
  1340. }
  1341. catch
  1342. {
  1343. return new List<S8AlertDto>();
  1344. }
  1345. }
  1346. private async Task<List<S2TrendDto>> GetS4TrendAsync(long tenantId, long factoryId)
  1347. {
  1348. try
  1349. {
  1350. return await _db.Ado.SqlQueryAsync<S2TrendDto>(
  1351. """
  1352. SELECT DATE_FORMAT(d.biz_date, '%m-%d') AS DateLabel,
  1353. MAX(CASE WHEN d.metric_code='S4_L1_001' THEN d.metric_value END) AS CycleDays,
  1354. MAX(CASE WHEN d.metric_code='S4_L1_002' THEN d.metric_value END) AS SatisfactionPct
  1355. FROM ado_s9_kpi_value_l1_day d
  1356. WHERE d.tenant_id=@tenantId AND d.factory_id=@factoryId AND d.module_code='S4' AND d.is_deleted=0
  1357. AND d.metric_code IN ('S4_L1_001','S4_L1_002')
  1358. GROUP BY d.biz_date
  1359. ORDER BY d.biz_date DESC
  1360. LIMIT 7
  1361. """,
  1362. new { tenantId, factoryId });
  1363. }
  1364. catch
  1365. {
  1366. return new List<S2TrendDto>();
  1367. }
  1368. }
  1369. private async Task<List<S2DistributionDto>> GetS4DistributionAsync(long tenantId, long factoryId)
  1370. {
  1371. try
  1372. {
  1373. return await _db.Ado.SqlQueryAsync<S2DistributionDto>(
  1374. """
  1375. SELECT COALESCE(NULLIF(supplier_name,''), NULLIF(supplier_code,''), '未分配') AS Name,
  1376. ROUND(100 * SUM(CASE WHEN IFNULL(receipt_qty,0) >= IFNULL(order_qty,0) AND IFNULL(order_qty,0) > 0 THEN 1
  1377. WHEN delivery_status='COMPLETED' THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 2) AS SatisfactionPct,
  1378. COUNT(1) AS TotalCount,
  1379. SUM(CASE WHEN risk_level IN ('HIGH','MEDIUM') THEN 1 ELSE 0 END) AS RiskCount
  1380. FROM dwd_supplier_delivery
  1381. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId
  1382. AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_delivery
  1383. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId)
  1384. GROUP BY COALESCE(NULLIF(supplier_name,''), NULLIF(supplier_code,''), '未分配')
  1385. ORDER BY RiskCount DESC, SatisfactionPct, TotalCount DESC
  1386. LIMIT 8
  1387. """,
  1388. new { tenantId, factoryId });
  1389. }
  1390. catch
  1391. {
  1392. return new List<S2DistributionDto>();
  1393. }
  1394. }
  1395. private async Task<List<S8AlertDto>> GetS4DerivedAlertsAsync(long tenantId, long factoryId)
  1396. {
  1397. try
  1398. {
  1399. var fromDwd = await _db.Ado.SqlQueryAsync<S8AlertDto>(
  1400. """
  1401. SELECT DATE_FORMAT(calc_time, '%H:%i:%s') AS Time,
  1402. CASE WHEN risk_level='HIGH' THEN 'critical'
  1403. WHEN risk_level='MEDIUM' THEN 'warning'
  1404. ELSE 'info' END AS LevelCode,
  1405. CONCAT('采购订单 ', COALESCE(po_no, '—'), ' 供应商 ', COALESCE(NULLIF(supplier_name,''), supplier_code, '未分配'),
  1406. ':', COALESCE(delivery_status, 'OPEN'), ' 剩余 ', CAST(IFNULL(remaining_qty,0) AS CHAR)) AS Message
  1407. FROM dwd_supplier_delivery
  1408. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId
  1409. AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_delivery
  1410. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId)
  1411. AND risk_level IN ('HIGH','MEDIUM')
  1412. ORDER BY FIELD(risk_level, 'HIGH', 'MEDIUM'), remaining_qty DESC
  1413. LIMIT 15
  1414. """,
  1415. new { tenantId, factoryId });
  1416. if (fromDwd.Count > 0)
  1417. return fromDwd;
  1418. }
  1419. catch
  1420. {
  1421. // fall through to S8 alerts
  1422. }
  1423. try
  1424. {
  1425. return await _db.Ado.SqlQueryAsync<S8AlertDto>(
  1426. """
  1427. SELECT DATE_FORMAT(alert_time, '%H:%i:%s') AS Time,
  1428. IFNULL(level_code, 'medium') AS LevelCode,
  1429. IFNULL(message, '采购执行异常') AS Message
  1430. FROM ado_s8_alert_record
  1431. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND is_deleted=0
  1432. AND (UPPER(IFNULL(module_code,''))='S4' OR metric_code LIKE 'S4%')
  1433. ORDER BY alert_time DESC
  1434. LIMIT 20
  1435. """,
  1436. new { tenantId, factoryId });
  1437. }
  1438. catch
  1439. {
  1440. return new List<S8AlertDto>();
  1441. }
  1442. }
  1443. private async Task<Dictionary<string, KpiDetailDto>> GetLatestModuleL1Async(string moduleCode, long tenantId, long factoryId)
  1444. {
  1445. try
  1446. {
  1447. var rows = await _db.Ado.SqlQueryAsync<KpiDetailDto>(
  1448. """
  1449. SELECT v.module_code AS ModuleCode, v.metric_code AS MetricCode, k.MetricName AS MetricName,
  1450. v.metric_value AS MetricValue, v.target_value AS TargetValue,
  1451. v.status_color AS StatusColor, v.trend_flag AS TrendFlag, v.biz_date AS StatDate
  1452. FROM ado_s9_kpi_value_l1_day v
  1453. LEFT JOIN ado_smart_ops_kpi_master k ON k.TenantId=v.tenant_id AND k.MetricCode=v.metric_code
  1454. WHERE v.tenant_id=@tenantId AND v.factory_id=@factoryId AND v.module_code=@moduleCode AND v.is_deleted=0
  1455. AND v.biz_date=(SELECT MAX(biz_date) FROM ado_s9_kpi_value_l1_day
  1456. WHERE tenant_id=@tenantId AND factory_id=@factoryId AND module_code=@moduleCode AND is_deleted=0)
  1457. """,
  1458. new { moduleCode, tenantId, factoryId });
  1459. return rows.Where(u => !string.IsNullOrWhiteSpace(u.MetricCode))
  1460. .GroupBy(u => u.MetricCode!, StringComparer.OrdinalIgnoreCase)
  1461. .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
  1462. }
  1463. catch
  1464. {
  1465. return new Dictionary<string, KpiDetailDto>(StringComparer.OrdinalIgnoreCase);
  1466. }
  1467. }
  1468. private async Task<S3ReadinessSummaryDto> GetS3ReadinessSummaryAsync(long tenantId, long factoryId)
  1469. {
  1470. try
  1471. {
  1472. return await _db.Ado.SqlQuerySingleAsync<S3ReadinessSummaryDto>(
  1473. """
  1474. SELECT COUNT(DISTINCT component_item_code) AS ComponentCount,
  1475. ROUND(100 * SUM(CASE WHEN IFNULL(shortage_qty,0) <= 0 OR UPPER(IFNULL(ready_status,'')) IN ('READY','SUFFICIENT','OK') THEN 1 ELSE 0 END) / NULLIF(COUNT(1), 0), 2) AS ReadyRatePct,
  1476. SUM(IFNULL(shortage_qty, 0)) AS ShortageQty
  1477. FROM dwd_material_readiness
  1478. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId
  1479. AND stat_date=(SELECT MAX(stat_date) FROM dwd_material_readiness
  1480. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId)
  1481. """,
  1482. new { tenantId, factoryId }) ?? new S3ReadinessSummaryDto();
  1483. }
  1484. catch
  1485. {
  1486. return new S3ReadinessSummaryDto();
  1487. }
  1488. }
  1489. private async Task<S3DeliverySummaryDto> GetS3DeliverySummaryAsync(long tenantId, long factoryId)
  1490. {
  1491. try
  1492. {
  1493. return await _db.Ado.SqlQuerySingleAsync<S3DeliverySummaryDto>(
  1494. """
  1495. SELECT COUNT(DISTINCT supplier_code) AS SupplierCount,
  1496. COUNT(DISTINCT CASE WHEN risk_level IN ('HIGH','MEDIUM') THEN supplier_code END) AS RiskSupplierCount,
  1497. SUM(IFNULL(remaining_qty, 0)) AS RemainingQty
  1498. FROM dwd_supplier_delivery
  1499. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId
  1500. AND stat_date=(SELECT MAX(stat_date) FROM dwd_supplier_delivery
  1501. WHERE tenant_id=@tenantId AND COALESCE(NULLIF(factory_id,0),1)=@factoryId)
  1502. """,
  1503. new { tenantId, factoryId }) ?? new S3DeliverySummaryDto();
  1504. }
  1505. catch
  1506. {
  1507. return new S3DeliverySummaryDto();
  1508. }
  1509. }
  1510. private static string FormatMetric(Dictionary<string, KpiDetailDto> rows, string metricCode, string unit)
  1511. {
  1512. if (!rows.TryGetValue(metricCode, out var row) || row.MetricValue == null)
  1513. return $"--{unit}";
  1514. return $"{Math.Round(row.MetricValue.Value, 2)}{unit}";
  1515. }
  1516. private static decimal? GetMetricValue(Dictionary<string, KpiDetailDto> rows, string metricCode)
  1517. {
  1518. return rows.TryGetValue(metricCode, out var row) ? row.MetricValue : null;
  1519. }
  1520. private static S3BranchKpiDto BuildBranchKpi(string label, decimal? value, string unit, decimal barPct)
  1521. {
  1522. return new S3BranchKpiDto
  1523. {
  1524. Label = label,
  1525. Value = value,
  1526. Unit = unit,
  1527. BarPct = barPct,
  1528. Status = barPct >= 90 ? "success" : barPct >= 70 ? "warning" : "danger"
  1529. };
  1530. }
  1531. private static decimal PercentBar(decimal? value, decimal max = 100m)
  1532. {
  1533. if (value == null || max <= 0)
  1534. return 0;
  1535. return Math.Round(Math.Max(0, Math.Min(100, value.Value / max * 100)), 2);
  1536. }
  1537. private static decimal InverseBar(decimal? value, decimal target)
  1538. {
  1539. if (value == null || value <= 0 || target <= 0)
  1540. return 0;
  1541. return Math.Round(Math.Max(0, Math.Min(100, target / value.Value * 100)), 2);
  1542. }
  1543. /// <summary>
  1544. /// 智慧运营看板基础查询下拉:产品、订单号、产线(兼容旧接口;优先 S0 主数据)。
  1545. /// </summary>
  1546. [HttpGet("smart-ops-filter-options")]
  1547. public async Task<IActionResult> GetSmartOpsFilterOptions([FromQuery] long factoryId = 1)
  1548. {
  1549. _ = factoryId;
  1550. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  1551. const int cap = 200;
  1552. var productItems = await SearchProductsAsync(tenantId, null, cap);
  1553. var orderItems = await SearchOrderNosAsync(tenantId, null, cap);
  1554. var lineItems = await SearchProductionLinesAsync(tenantId, null, cap);
  1555. return Ok(new
  1556. {
  1557. products = productItems.Select(x => x.Value).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToList(),
  1558. orderNos = orderItems.Select(x => x.Value).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToList(),
  1559. productionLines = lineItems.Select(x => x.Value).Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToList()
  1560. });
  1561. }
  1562. private sealed class SmartOpsOptionRow
  1563. {
  1564. public string? Value { get; set; }
  1565. }
  1566. private sealed class HomeL1Dto
  1567. {
  1568. public string? ModuleCode { get; set; }
  1569. public string? MetricCode { get; set; }
  1570. public decimal? MetricValue { get; set; }
  1571. public decimal? TargetValue { get; set; }
  1572. public string? StatusColor { get; set; }
  1573. public string? TrendFlag { get; set; }
  1574. }
  1575. private sealed class HomeL1RawDto
  1576. {
  1577. public string? ModuleCode { get; set; }
  1578. public string? MetricCode { get; set; }
  1579. public decimal? MetricValue { get; set; }
  1580. public decimal? TargetValue { get; set; }
  1581. public string? StatusColor { get; set; }
  1582. public string? TrendFlag { get; set; }
  1583. public string? Direction { get; set; }
  1584. public decimal? YellowThreshold { get; set; }
  1585. public decimal? RedThreshold { get; set; }
  1586. }
  1587. private sealed class S8AlertDto
  1588. {
  1589. public string? Time { get; set; }
  1590. public string? Module { get; set; }
  1591. public string? Message { get; set; }
  1592. public string? LevelCode { get; set; }
  1593. }
  1594. private sealed class KpiDetailDto
  1595. {
  1596. public string? ModuleCode { get; set; }
  1597. public string? MetricCode { get; set; }
  1598. public string? MetricName { get; set; }
  1599. public decimal? MetricValue { get; set; }
  1600. public decimal? TargetValue { get; set; }
  1601. public string? StatusColor { get; set; }
  1602. public string? TrendFlag { get; set; }
  1603. public DateTime? StatDate { get; set; }
  1604. public string? ParentMetricCode { get; set; }
  1605. }
  1606. private sealed class KpiDrillGroupDto
  1607. {
  1608. public string? ParentMetricCode { get; set; }
  1609. public string Title { get; set; } = string.Empty;
  1610. public List<KpiDrillItemDto> Items { get; set; } = new();
  1611. }
  1612. private sealed class KpiDrillItemDto
  1613. {
  1614. public string? MetricCode { get; set; }
  1615. public string? MetricName { get; set; }
  1616. public decimal? MetricValue { get; set; }
  1617. public decimal? TargetValue { get; set; }
  1618. public string? StatusColor { get; set; }
  1619. public string? TrendFlag { get; set; }
  1620. public string? StatDate { get; set; }
  1621. }
  1622. private sealed class S2ScheduleDto
  1623. {
  1624. public string? OrderNo { get; set; }
  1625. public string? Customer { get; set; }
  1626. public string? Product { get; set; }
  1627. public string? ProductionLine { get; set; }
  1628. public decimal? Quantity { get; set; }
  1629. public DateTime? DeliveryDate { get; set; }
  1630. public DateTime? StartTime { get; set; }
  1631. public decimal? CycleDays { get; set; }
  1632. public decimal? Satisfaction { get; set; }
  1633. public string? Status { get; set; }
  1634. }
  1635. private sealed class S2SyncStatusDto
  1636. {
  1637. public string? BatchId { get; set; }
  1638. public string? Status { get; set; }
  1639. public int? StageRows { get; set; }
  1640. public int? StandardRows { get; set; }
  1641. public int? DwdRows { get; set; }
  1642. public DateTime? StartTime { get; set; }
  1643. public DateTime? EndTime { get; set; }
  1644. public string? ErrorMessage { get; set; }
  1645. }
  1646. private sealed class S2DecompositionDto
  1647. {
  1648. public string Title { get; set; } = string.Empty;
  1649. public bool Active { get; set; }
  1650. public List<string> Metrics { get; set; } = new();
  1651. }
  1652. private sealed class S2TrendDto
  1653. {
  1654. public string? DateLabel { get; set; }
  1655. public decimal? CycleDays { get; set; }
  1656. public decimal? SatisfactionPct { get; set; }
  1657. }
  1658. private sealed class S2DistributionDto
  1659. {
  1660. public string? Name { get; set; }
  1661. public decimal? SatisfactionPct { get; set; }
  1662. public int TotalCount { get; set; }
  1663. public int RiskCount { get; set; }
  1664. }
  1665. private sealed class S2OperationSummaryDto
  1666. {
  1667. public int OperationCount { get; set; }
  1668. public decimal? CompletedQty { get; set; }
  1669. public decimal? ScheduledQty { get; set; }
  1670. }
  1671. private sealed class S2ResourceSummaryDto
  1672. {
  1673. public decimal? PersonCount { get; set; }
  1674. public int RiskCount { get; set; }
  1675. public int LineCount { get; set; }
  1676. }
  1677. private sealed class S3BranchKpiGroupDto
  1678. {
  1679. public string Branch { get; set; } = string.Empty;
  1680. public string Title { get; set; } = string.Empty;
  1681. public List<S3BranchKpiDto> Items { get; set; } = new();
  1682. }
  1683. private sealed class S3BranchKpiDto
  1684. {
  1685. public string Label { get; set; } = string.Empty;
  1686. public decimal? Value { get; set; }
  1687. public string Unit { get; set; } = string.Empty;
  1688. public decimal BarPct { get; set; }
  1689. public string Status { get; set; } = "info";
  1690. }
  1691. private sealed class S3ReadinessSummaryDto
  1692. {
  1693. public int ComponentCount { get; set; }
  1694. public decimal? ReadyRatePct { get; set; }
  1695. public decimal? ShortageQty { get; set; }
  1696. }
  1697. private sealed class S3DeliverySummaryDto
  1698. {
  1699. public int SupplierCount { get; set; }
  1700. public int RiskSupplierCount { get; set; }
  1701. public decimal? RemainingQty { get; set; }
  1702. }
  1703. }