S6MdpSyncTransformService.cs 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. using Admin.NET.Core.Service;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Microsoft.Extensions.Logging;
  4. using System.Text.Json;
  5. namespace Admin.NET.Plugin.AiDOP.Manufacturing;
  6. /// <summary>
  7. /// S6 生产执行 — KPI 计算与刷新转换服务。
  8. /// 双模式:读本地标准层 mdp_std_t8_kc_*(由 T8BaseInboundMdpSyncService 从 T8 贴源→标准),不再直连 T8。
  9. /// 计算逻辑沿用方老师 v5.4 KPI J 列口径(等价改写为 MySQL 读 std)。
  10. /// 口径归位(S6-L1-KPI-CONTRACT-RESOLUTION-1,方案 B):L1=订单级,L2=工单级。
  11. /// L1:订单制造周期/满足率/人效写 l1_day;L2:工单制造满足率/人效写 l2_day。
  12. /// 最终聚合经 KpiCalcDispatcher(LEGACY_CODE/CONFIG_SQL),数据准备(dwd)不变。
  13. /// </summary>
  14. public class S6MdpSyncTransformService : ITransient
  15. {
  16. private readonly ISqlSugarClient _db;
  17. private readonly SysNoticeService _sysNoticeService;
  18. private readonly ILogger<S6MdpSyncTransformService> _logger;
  19. private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
  20. private readonly SmartOps.KpiDimensionRunService _dimensionRun;
  21. private readonly SmartOps.IKpiTargetResolver _kpiTargetResolver;
  22. private const string L2ValueTable = "ado_s9_kpi_value_l2_day";
  23. private const string JobCode = "S6_MDP_SYNC_TRANSFORM";
  24. private const string JobName = "S6 生产执行 MDP 同步与转换";
  25. private const string ModuleCode = "S6";
  26. // FAILURE-NOTIFICATION-1:超级管理员 superAdmin.NET(AccountType=999)
  27. private const long NoticeReceiverUserId = 1300000000101L;
  28. private const string NoticeReceiverUserName = "超级管理员";
  29. public S6MdpSyncTransformService(
  30. ISqlSugarClient db,
  31. SysNoticeService sysNoticeService,
  32. ILogger<S6MdpSyncTransformService> logger,
  33. SmartOps.KpiCalcDispatcher kpiCalcDispatcher,
  34. SmartOps.KpiDimensionRunService dimensionRun,
  35. SmartOps.IKpiTargetResolver kpiTargetResolver)
  36. {
  37. _db = db;
  38. _sysNoticeService = sysNoticeService;
  39. _logger = logger;
  40. _kpiCalcDispatcher = kpiCalcDispatcher;
  41. _dimensionRun = dimensionRun;
  42. _kpiTargetResolver = kpiTargetResolver;
  43. }
  44. public async Task<S6MdpSyncTransformResult> RunFullAsync(
  45. CancellationToken cancellationToken = default,
  46. string triggerType = "AUTO",
  47. S6MdpRefreshOption? option = null)
  48. {
  49. cancellationToken.ThrowIfCancellationRequested();
  50. option ??= S6MdpRefreshOption.Default();
  51. NormalizeOption(option);
  52. var now = DateTime.Now;
  53. var batchId = $"S6_MDP_FULL_{now:yyyyMMddHHmmss}";
  54. var normalizedTrigger = NormalizeTriggerType(triggerType);
  55. var runLogId = await InsertTransformRunLogAsync(batchId, now, normalizedTrigger, option);
  56. var result = new S6MdpSyncTransformResult
  57. {
  58. BatchId = batchId,
  59. RunLogId = runLogId,
  60. TriggerType = normalizedTrigger,
  61. SourceZtid = option.SourceZtid,
  62. TargetTenantId = option.TargetTenantId,
  63. TargetFactoryId = option.TargetFactoryId,
  64. BizDate = option.BizDate,
  65. BizMonth = option.BizMonth,
  66. MonthlyPeriodStart = option.MonthlyPeriodStart,
  67. MonthlyPeriodEnd = option.MonthlyPeriodEnd
  68. };
  69. try
  70. {
  71. result.StageRows = 0;
  72. result.StandardRows = 0;
  73. // 先准备工单级 DWD/L2;订单满足率随后复用同批 dwd_t8_work_order_mfg_fulfillment。
  74. var sub21 = await BuildS6L2001WorkOrderMfgCycleAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  75. result.MergeSub("S6_L2_001", sub21);
  76. var sub22 = await BuildS6L2002WorkOrderMfgFulfillmentAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  77. result.MergeSub("S6_L2_002", sub22);
  78. var sub23 = await BuildS6L2003WorkOrderMfgEfficiencyAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  79. result.MergeSub("S6_L2_003", sub23);
  80. var sub11 = await BuildS6L1001OrderMfgCycleAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  81. result.MergeSub("S6_L1_001", sub11);
  82. var sub12 = await BuildS6L1002OrderMfgFulfillmentAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  83. result.MergeSub("S6_L1_002", sub12);
  84. var sub13 = await BuildS6L1003OrderMfgEfficiencyAsync(batchId, now, option, normalizedTrigger, cancellationToken);
  85. result.MergeSub("S6_L1_003", sub13);
  86. await MarkTransformRunSuccessAsync(runLogId, now, result);
  87. return result;
  88. }
  89. catch (Exception ex)
  90. {
  91. await MarkTransformRunFailedAsync(runLogId, now, ex.Message, batchId);
  92. throw;
  93. }
  94. }
  95. // ─────────────────────────────────────────────────────────────────────────
  96. /// <summary>工单制造周期 = 每个完成工单最晚完工时间 - 最早投产时间,再取工单平均。归位 L2:写 S6_L2_001。</summary>
  97. private async Task<KpiBuildSubResult> BuildS6L2001WorkOrderMfgCycleAsync(
  98. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  99. {
  100. const string sql = @"
  101. select rwnoid, datediff(max(completion_time), min(start_time)) as cycle_days
  102. from (
  103. select a.noid, b.rwnoid, b.code, b.addtime as start_time,
  104. case when b.gdyn=1 and b.gdtime is not null then b.gdtime else max(d.completion_time) end as completion_time,
  105. case when b.gdyn=1 or sum(ifnull(d.slzx,0))>=b.sl then 1 else 0 end as completed
  106. from mdp_std_t8_kc_dd_head a
  107. inner join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  108. left join (
  109. select l.lynoid, l.code, l.slzx, ifnull(l.gdtime,h.shtime) as completion_time
  110. from mdp_std_t8_kc_tz_head h
  111. inner join mdp_std_t8_kc_tz_list l on h.tenant_id=l.tenant_id and h.src_id=l.idid
  112. where h.tenant_id=@tenantId and l.tenant_id=@tenantId and h.ztid=@ztid
  113. and h.lbs='生产入库' and h.hzyn=0 and h.zfyn=0 and h.shyn=1
  114. ) d on b.rwnoid=d.lynoid and b.code=d.code
  115. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  116. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and b.rwnoid is not null
  117. group by a.noid, b.rwnoid, b.code, b.sl, b.addtime, b.gdyn, b.gdtime
  118. ) n
  119. group by rwnoid
  120. having min(completed)=1 and min(start_time) is not null and max(completion_time) is not null";
  121. var rows = await _db.Ado.SqlQueryAsync<S6WorkOrderCycleRow>(sql, new[]
  122. {
  123. new SugarParameter("@tenantId", option.TargetTenantId),
  124. new SugarParameter("@ztid", option.SourceZtid)
  125. });
  126. var cycles = rows.Where(r => r.cycle_days.HasValue && r.cycle_days.Value >= 0)
  127. .Select(r => (decimal)r.cycle_days!.Value).ToList();
  128. decimal? value = cycles.Count > 0 ? Math.Round(cycles.Average(), 4) : null;
  129. var denom = cycles.Count > 0 ? "OK" : "NO_COMPLETED_WORK_ORDER";
  130. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  131. "S6_L2_001", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  132. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  133. batchId, triggerType, value, denom, ct);
  134. var sub = new KpiBuildSubResult
  135. {
  136. T8Rows = rows.Count,
  137. KpiRows = dispatch.ShouldUpsert
  138. ? await UpsertKpiValueAsync("S6_L2_001", option.BizDate, dispatch.MetricValue, now, option, L2ValueTable)
  139. : 0,
  140. DenominatorStatus = dispatch.DenominatorStatus
  141. };
  142. await RunDimensionSafelyAsync("S6_L2_001", option.BizDate, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  143. return sub;
  144. }
  145. /// <summary>工单制造满足率 = 计划完工时间内累计报工 / 工单计划生产数量。归位 L2:写 S6_L2_002。</summary>
  146. private async Task<KpiBuildSubResult> BuildS6L2002WorkOrderMfgFulfillmentAsync(
  147. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  148. {
  149. var sub = new KpiBuildSubResult();
  150. // 双模式:读本地标准层 mdp_std_t8_*(源 identity Id→src_id),语义等价于原直发 T8 SQL。
  151. const string sql = @"
  152. select a.noid as noid, b.rwnoid as rwnoid, b.code as code, b.sl as sl, sum(d.slzx) as slzx
  153. from mdp_std_t8_kc_dd_head a
  154. left join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  155. left join (
  156. select b.lynoid as lynoid, b.code as code,
  157. date(a.shtime) as shtime, b.slzx as slzx
  158. from mdp_std_t8_kc_tz_head a
  159. inner join mdp_std_t8_kc_tz_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  160. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  161. and a.lbs='生产入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
  162. ) d on b.rwnoid=d.lynoid and b.code=d.code
  163. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  164. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and d.shtime<=b.jhdate
  165. group by a.noid, b.rwnoid, b.code, b.sl";
  166. var rows = await _db.Ado.SqlQueryAsync<S6MfgFulfillmentRow>(sql, new[]
  167. {
  168. new SugarParameter("@tenantId", option.TargetTenantId),
  169. new SugarParameter("@ztid", option.SourceZtid)
  170. });
  171. sub.T8Rows = rows.Count;
  172. var dwdAffected = 0;
  173. var rateList = new List<decimal>();
  174. foreach (var r in rows)
  175. {
  176. ct.ThrowIfCancellationRequested();
  177. if (string.IsNullOrEmpty(r.noid)) continue;
  178. decimal? rate = (r.sl.HasValue && r.sl.Value > 0m && r.slzx.HasValue)
  179. ? Math.Round(Math.Clamp(r.slzx.Value / r.sl.Value, 0m, 1m), 4)
  180. : null;
  181. if (rate.HasValue) rateList.Add(rate.Value);
  182. dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
  183. INSERT INTO dwd_t8_work_order_mfg_fulfillment
  184. (tenant_id, factory_id, biz_date, source_ztid, order_no, task_no, item_code,
  185. plan_qty, done_qty_in_window, fulfillment_rate, batch_id, create_time)
  186. VALUES
  187. (@tenantId, @factoryId, @bizDate, @ztid, @orderNo, @taskNo, @itemCode,
  188. @planQty, @doneQty, @rate, @batchId, @now)
  189. ON DUPLICATE KEY UPDATE
  190. plan_qty=VALUES(plan_qty), done_qty_in_window=VALUES(done_qty_in_window),
  191. fulfillment_rate=VALUES(fulfillment_rate),
  192. batch_id=VALUES(batch_id), update_time=@now",
  193. new SugarParameter("@tenantId", option.TargetTenantId),
  194. new SugarParameter("@factoryId", option.TargetFactoryId),
  195. new SugarParameter("@bizDate", option.BizDate),
  196. new SugarParameter("@ztid", option.SourceZtid),
  197. new SugarParameter("@orderNo", r.noid),
  198. new SugarParameter("@taskNo", r.rwnoid ?? ""),
  199. new SugarParameter("@itemCode", r.code ?? ""),
  200. new SugarParameter("@planQty", r.sl),
  201. new SugarParameter("@doneQty", r.slzx),
  202. new SugarParameter("@rate", rate),
  203. new SugarParameter("@batchId", batchId),
  204. new SugarParameter("@now", now));
  205. }
  206. sub.DwdRows = dwdAffected;
  207. // 数据准备(dwd)已完成。最终聚合交分发器(日 KPI:period 复用 BizDate,SQL 按 biz_date 圈选)。
  208. decimal? legacyValue = rateList.Count > 0
  209. ? Math.Round(rateList.Average() * 100m, 4) // 百分号
  210. : null;
  211. var legacyDenom = rateList.Count > 0 ? "OK" : "NO_VALID_WORK_ORDER";
  212. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  213. "S6_L2_002", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  214. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  215. batchId, triggerType, legacyValue, legacyDenom, ct);
  216. sub.KpiRows = dispatch.ShouldUpsert
  217. ? await UpsertKpiValueAsync("S6_L2_002", option.BizDate, dispatch.MetricValue, now, option, L2ValueTable)
  218. : 0;
  219. sub.DenominatorStatus = dispatch.DenominatorStatus;
  220. // 调度:SUMMARY 成功/NO_DATA 后触发对应 DIMENSION 跑批(共享 BatchId;SUMMARY FAILED 不触发)。
  221. if (dispatch.ShouldUpsert)
  222. {
  223. try
  224. {
  225. await _dimensionRun.RunDimensionAsync(
  226. "S6_L2_002", ModuleCode, option.TargetTenantId, option.BizDate, batchId, triggerType, ct);
  227. }
  228. catch (Exception ex)
  229. {
  230. _logger.LogWarning(ex, "S6_L2_002 维度跑批异常(不影响汇总链路)");
  231. }
  232. }
  233. return sub;
  234. }
  235. /// <summary>工单制造人效 = 完成制造工单数(lbs=生产入库 AND (slzx>=sl OR gdyn=1)) / count(gw=生产)。归位 L2:写 S6_L2_003。</summary>
  236. private async Task<KpiBuildSubResult> BuildS6L2003WorkOrderMfgEfficiencyAsync(
  237. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  238. {
  239. var sub = new KpiBuildSubResult();
  240. const string sqlNumer = @"
  241. select count(*) as ddnum
  242. from mdp_std_t8_kc_tz_head a
  243. inner join mdp_std_t8_kc_tz_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  244. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  245. and a.lbs='生产入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
  246. and a.date0 between @startDate and @endDate
  247. and b.slzx>0 and (b.slzx>=b.sl or b.gdyn=1)";
  248. const string sqlDenom = @"
  249. select count(*) as penum
  250. from mdp_std_t8_sys_pelist
  251. where tenant_id=@tenantId and ztid=@ztid and zzzt='在职' and gw='生产'";
  252. var pNumer = new[]
  253. {
  254. new SugarParameter("@tenantId", option.TargetTenantId),
  255. new SugarParameter("@ztid", option.SourceZtid),
  256. new SugarParameter("@startDate", option.MonthlyPeriodStart),
  257. new SugarParameter("@endDate", option.MonthlyPeriodEnd)
  258. };
  259. var pDenom = new[]
  260. {
  261. new SugarParameter("@tenantId", option.TargetTenantId),
  262. new SugarParameter("@ztid", option.SourceZtid)
  263. };
  264. var numerRows = await _db.Ado.SqlQueryAsync<S6CountRow>(sqlNumer, pNumer);
  265. var denomRows = await _db.Ado.SqlQueryAsync<S6PeNumRow>(sqlDenom, pDenom);
  266. sub.T8Rows = numerRows.Count + denomRows.Count;
  267. int? doneCount = numerRows.FirstOrDefault()?.ddnum;
  268. int? headcount = denomRows.FirstOrDefault()?.penum;
  269. decimal? efficiency = null;
  270. string denomStatus;
  271. if (!headcount.HasValue || headcount.Value <= 0)
  272. denomStatus = "NO_HEADCOUNT";
  273. else if (!doneCount.HasValue)
  274. denomStatus = "NO_NUMERATOR";
  275. else
  276. {
  277. efficiency = Math.Round((decimal)doneCount.Value / headcount.Value, 4);
  278. denomStatus = "OK";
  279. }
  280. sub.DenominatorStatus = denomStatus;
  281. var dwdAffected = await _db.Ado.ExecuteCommandAsync(@"
  282. INSERT INTO dwd_t8_work_order_mfg_efficiency
  283. (tenant_id, factory_id, biz_month, source_ztid, period_start, period_end,
  284. done_count, production_headcount, efficiency, denominator_status, batch_id, create_time)
  285. VALUES
  286. (@tenantId, @factoryId, @bizMonth, @ztid, @periodStart, @periodEnd,
  287. @doneCount, @headcount, @efficiency, @denomStatus, @batchId, @now)
  288. ON DUPLICATE KEY UPDATE
  289. period_start=VALUES(period_start), period_end=VALUES(period_end),
  290. done_count=VALUES(done_count), production_headcount=VALUES(production_headcount),
  291. efficiency=VALUES(efficiency), denominator_status=VALUES(denominator_status),
  292. batch_id=VALUES(batch_id), update_time=@now",
  293. new SugarParameter("@tenantId", option.TargetTenantId),
  294. new SugarParameter("@factoryId", option.TargetFactoryId),
  295. new SugarParameter("@bizMonth", option.BizMonth),
  296. new SugarParameter("@ztid", option.SourceZtid),
  297. new SugarParameter("@periodStart", option.MonthlyPeriodStart),
  298. new SugarParameter("@periodEnd", option.MonthlyPeriodEnd),
  299. new SugarParameter("@doneCount", doneCount),
  300. new SugarParameter("@headcount", headcount),
  301. new SugarParameter("@efficiency", efficiency),
  302. new SugarParameter("@denomStatus", denomStatus),
  303. new SugarParameter("@batchId", batchId),
  304. new SugarParameter("@now", now));
  305. sub.DwdRows = dwdAffected;
  306. // 月度 KPI 最终聚合交分发器;bizDate=月末、period=当月窗口;legacyValue=efficiency、legacyDenom 保留 NO_HEADCOUNT/NO_NUMERATOR。
  307. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  308. "S6_L2_003", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  309. option.MonthlyPeriodEnd, option.MonthlyPeriodStart, option.MonthlyPeriodEnd, option.SourceZtid,
  310. batchId, triggerType, efficiency, denomStatus, ct);
  311. sub.KpiRows = dispatch.ShouldUpsert
  312. ? await UpsertKpiValueAsync("S6_L2_003", option.MonthlyPeriodEnd, dispatch.MetricValue, now, option, L2ValueTable)
  313. : 0;
  314. sub.DenominatorStatus = dispatch.DenominatorStatus;
  315. // 调度:SUMMARY 成功/NO_DATA 后触发人效月度 DIMENSION 跑批(月度:@biz_date=月末派生 biz_month)。
  316. if (dispatch.ShouldUpsert)
  317. {
  318. try
  319. {
  320. await _dimensionRun.RunDimensionAsync(
  321. "S6_L2_003", ModuleCode, option.TargetTenantId, option.MonthlyPeriodEnd, batchId, triggerType, ct);
  322. }
  323. catch (Exception ex)
  324. {
  325. _logger.LogWarning(ex, "S6_L2_003 维度跑批异常(不影响汇总链路)");
  326. }
  327. }
  328. return sub;
  329. }
  330. /// <summary>订单制造周期 = 每个完成订单最晚完工时间 - 最早投产时间,再取订单平均。</summary>
  331. private async Task<KpiBuildSubResult> BuildS6L1001OrderMfgCycleAsync(
  332. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  333. {
  334. const string sql = @"
  335. select noid, datediff(max(completion_time), min(start_time)) as cycle_days
  336. from (
  337. select a.noid, b.rwnoid, b.code, b.addtime as start_time,
  338. case when b.gdyn=1 and b.gdtime is not null then b.gdtime else max(d.completion_time) end as completion_time,
  339. case when b.gdyn=1 or sum(ifnull(d.slzx,0))>=b.sl then 1 else 0 end as completed
  340. from mdp_std_t8_kc_dd_head a
  341. inner join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  342. left join (
  343. select l.lynoid, l.code, l.slzx, ifnull(l.gdtime,h.shtime) as completion_time
  344. from mdp_std_t8_kc_tz_head h
  345. inner join mdp_std_t8_kc_tz_list l on h.tenant_id=l.tenant_id and h.src_id=l.idid
  346. where h.tenant_id=@tenantId and l.tenant_id=@tenantId and h.ztid=@ztid
  347. and h.lbs='生产入库' and h.hzyn=0 and h.zfyn=0 and h.shyn=1
  348. ) d on b.rwnoid=d.lynoid and b.code=d.code
  349. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  350. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and a.noid is not null
  351. group by a.noid, b.rwnoid, b.code, b.sl, b.addtime, b.gdyn, b.gdtime
  352. ) n
  353. group by noid
  354. having min(completed)=1 and min(start_time) is not null and max(completion_time) is not null";
  355. var rows = await _db.Ado.SqlQueryAsync<S6OrderCycleRow>(sql, new[]
  356. {
  357. new SugarParameter("@tenantId", option.TargetTenantId),
  358. new SugarParameter("@ztid", option.SourceZtid)
  359. });
  360. var cycles = rows.Where(r => r.cycle_days.HasValue && r.cycle_days.Value >= 0)
  361. .Select(r => (decimal)r.cycle_days!.Value).ToList();
  362. decimal? value = cycles.Count > 0 ? Math.Round(cycles.Average(), 4) : null;
  363. var denom = cycles.Count > 0 ? "OK" : "NO_COMPLETED_ORDER";
  364. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  365. "S6_L1_001", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  366. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  367. batchId, triggerType, value, denom, ct);
  368. var sub = new KpiBuildSubResult
  369. {
  370. T8Rows = rows.Count,
  371. KpiRows = dispatch.ShouldUpsert
  372. ? await UpsertKpiValueAsync("S6_L1_001", option.BizDate, dispatch.MetricValue, now, option)
  373. : 0,
  374. DenominatorStatus = dispatch.DenominatorStatus
  375. };
  376. await RunDimensionSafelyAsync("S6_L1_001", option.BizDate, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  377. return sub;
  378. }
  379. /// <summary>订单制造满足率 = AVG(clamp(SUM(交期内完成量)/SUM(计划量), 0..1)) × 100。</summary>
  380. private async Task<KpiBuildSubResult> BuildS6L1002OrderMfgFulfillmentAsync(
  381. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  382. {
  383. const string sql = @"
  384. select order_no, sum(plan_qty) as plan_qty, sum(done_qty_in_window) as done_qty_in_window
  385. from dwd_t8_work_order_mfg_fulfillment
  386. where tenant_id=@tenantId and factory_id=@factoryId and biz_date=@bizDate and source_ztid=@ztid
  387. group by order_no";
  388. var rows = await _db.Ado.SqlQueryAsync<S6OrderFulfillmentRow>(sql,
  389. new SugarParameter("@tenantId", option.TargetTenantId),
  390. new SugarParameter("@factoryId", option.TargetFactoryId),
  391. new SugarParameter("@bizDate", option.BizDate),
  392. new SugarParameter("@ztid", option.SourceZtid));
  393. decimal? value = CalculateOrderFulfillmentPercent(
  394. rows.Select(r => (r.plan_qty, r.done_qty_in_window)));
  395. var denom = value.HasValue ? "OK" : "NO_VALID_ORDER";
  396. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  397. "S6_L1_002", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  398. option.BizDate, option.BizDate, option.BizDate, option.SourceZtid,
  399. batchId, triggerType, value, denom, ct);
  400. var sub = new KpiBuildSubResult
  401. {
  402. T8Rows = rows.Count,
  403. KpiRows = dispatch.ShouldUpsert
  404. ? await UpsertKpiValueAsync("S6_L1_002", option.BizDate, dispatch.MetricValue, now, option)
  405. : 0,
  406. DenominatorStatus = dispatch.DenominatorStatus
  407. };
  408. await RunDimensionSafelyAsync("S6_L1_002", option.BizDate, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  409. return sub;
  410. }
  411. internal static decimal? CalculateOrderFulfillmentPercent(
  412. IEnumerable<(decimal? PlanQty, decimal? DoneQty)> orders)
  413. {
  414. var rates = orders
  415. .Where(r => r.PlanQty.HasValue && r.PlanQty.Value > 0m && r.DoneQty.HasValue)
  416. .Select(r => Math.Clamp(r.DoneQty!.Value / r.PlanQty!.Value, 0m, 1m))
  417. .ToList();
  418. return rates.Count > 0 ? Math.Round(rates.Average() * 100m, 4) : null;
  419. }
  420. /// <summary>订单制造人效 = 统计期内完成制造的订单数 / 在职生产人数。</summary>
  421. private async Task<KpiBuildSubResult> BuildS6L1003OrderMfgEfficiencyAsync(
  422. string batchId, DateTime now, S6MdpRefreshOption option, string triggerType, CancellationToken ct)
  423. {
  424. const string sqlNumer = @"
  425. select count(*) as ddnum
  426. from (
  427. select noid
  428. from (
  429. select a.noid, b.rwnoid, b.code,
  430. case when b.gdyn=1 and b.gdtime is not null then b.gdtime else max(d.completion_time) end as completion_time,
  431. case when b.gdyn=1 or sum(ifnull(d.slzx,0))>=b.sl then 1 else 0 end as completed
  432. from mdp_std_t8_kc_dd_head a
  433. inner join mdp_std_t8_kc_dd_list b on a.tenant_id=b.tenant_id and a.src_id=b.idid
  434. left join (
  435. select l.lynoid, l.code, l.slzx, ifnull(l.gdtime,h.shtime) as completion_time
  436. from mdp_std_t8_kc_tz_head h
  437. inner join mdp_std_t8_kc_tz_list l on h.tenant_id=l.tenant_id and h.src_id=l.idid
  438. where h.tenant_id=@tenantId and l.tenant_id=@tenantId and h.ztid=@ztid
  439. and h.lbs='生产入库' and h.hzyn=0 and h.zfyn=0 and h.shyn=1
  440. ) d on b.rwnoid=d.lynoid and b.code=d.code
  441. where a.tenant_id=@tenantId and b.tenant_id=@tenantId and a.ztid=@ztid
  442. and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and a.noid is not null
  443. group by a.noid, b.rwnoid, b.code, b.sl, b.gdyn, b.gdtime
  444. ) order_lines
  445. group by noid
  446. having min(completed)=1 and max(completion_time) between @startDate and @endDate
  447. ) orders_done";
  448. const string sqlDenom = @"
  449. select count(*) as penum
  450. from mdp_std_t8_sys_pelist
  451. where tenant_id=@tenantId and ztid=@ztid and zzzt='在职' and gw='生产'";
  452. var numerRows = await _db.Ado.SqlQueryAsync<S6CountRow>(sqlNumer,
  453. new SugarParameter("@tenantId", option.TargetTenantId),
  454. new SugarParameter("@ztid", option.SourceZtid),
  455. new SugarParameter("@startDate", option.MonthlyPeriodStart),
  456. new SugarParameter("@endDate", option.MonthlyPeriodEnd));
  457. var denomRows = await _db.Ado.SqlQueryAsync<S6PeNumRow>(sqlDenom,
  458. new SugarParameter("@tenantId", option.TargetTenantId),
  459. new SugarParameter("@ztid", option.SourceZtid));
  460. int? doneCount = numerRows.FirstOrDefault()?.ddnum;
  461. int? headcount = denomRows.FirstOrDefault()?.penum;
  462. decimal? value = null;
  463. string denom;
  464. if (!headcount.HasValue || headcount.Value <= 0)
  465. denom = "NO_HEADCOUNT";
  466. else if (!doneCount.HasValue)
  467. denom = "NO_NUMERATOR";
  468. else
  469. {
  470. value = Math.Round((decimal)doneCount.Value / headcount.Value, 4);
  471. denom = "OK";
  472. }
  473. var dispatch = await _kpiCalcDispatcher.DispatchAsync(
  474. "S6_L1_003", ModuleCode, option.TargetTenantId, option.TargetFactoryId,
  475. option.MonthlyPeriodEnd, option.MonthlyPeriodStart, option.MonthlyPeriodEnd, option.SourceZtid,
  476. batchId, triggerType, value, denom, ct);
  477. var sub = new KpiBuildSubResult
  478. {
  479. T8Rows = numerRows.Count + denomRows.Count,
  480. KpiRows = dispatch.ShouldUpsert
  481. ? await UpsertKpiValueAsync("S6_L1_003", option.MonthlyPeriodEnd, dispatch.MetricValue, now, option)
  482. : 0,
  483. DenominatorStatus = dispatch.DenominatorStatus
  484. };
  485. await RunDimensionSafelyAsync("S6_L1_003", option.MonthlyPeriodEnd, batchId, triggerType, option, dispatch.ShouldUpsert, ct);
  486. return sub;
  487. }
  488. private async Task RunDimensionSafelyAsync(
  489. string metricCode, DateTime bizDate, string batchId, string triggerType,
  490. S6MdpRefreshOption option, bool shouldRun, CancellationToken ct)
  491. {
  492. if (!shouldRun) return;
  493. try
  494. {
  495. await _dimensionRun.RunDimensionAsync(
  496. metricCode, ModuleCode, option.TargetTenantId, bizDate, batchId, triggerType, ct);
  497. }
  498. catch (Exception ex)
  499. {
  500. _logger.LogWarning(ex, "{MetricCode} 维度跑批异常(不影响汇总链路)", metricCode);
  501. }
  502. }
  503. // ─────────────────────────────────────────────────────────────────────────
  504. private async Task<int> UpsertKpiValueAsync(string metricCode, DateTime bizDate, decimal? metricValue, DateTime now, S6MdpRefreshOption option, string valueTable = "ado_s9_kpi_value_l1_day")
  505. {
  506. // 沿用 S3 UpsertS3KpiValueAsync 范式:先查现存行 → UPDATE;不存在 → SELECT MAX(id)+1 显式生成 id 后 INSERT。
  507. // 值表 id 为手工分配主键(无 AUTO_INCREMENT),必须显式 set;metric_value 允许 NULL(分母缺失不得伪装真实 0)。
  508. // valueTable:L1 KPI 写 ado_s9_kpi_value_l1_day;L2(本服务工单指标 S6_L2_002/003)写 ado_s9_kpi_value_l2_day。
  509. // 表名为受控常量(非用户输入),可安全内插;各表 id 序列独立,MAX(id) 取目标表。
  510. // FIX-2:截断时分秒(月度 KPI 入参可能为 YYYY-MM-DD 23:59:59),保证 SELECT WHERE biz_date=@BizDate 与 DB date 列匹配。
  511. bizDate = bizDate.Date;
  512. var snap = await _kpiTargetResolver.ResolveAsync(option.TargetTenantId, option.TargetFactoryId, metricCode, ModuleCode, bizDate);
  513. var existingId = await _db.Ado.GetLongAsync(
  514. $"SELECT IFNULL((SELECT id FROM {valueTable} WHERE tenant_id=@TenantId AND factory_id=@FactoryId " +
  515. "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " +
  516. "ORDER BY id LIMIT 1), 0)",
  517. new List<SugarParameter>
  518. {
  519. new("@TenantId", option.TargetTenantId),
  520. new("@FactoryId", option.TargetFactoryId),
  521. new("@ModuleCode", ModuleCode),
  522. new("@MetricCode", metricCode),
  523. new("@BizDate", bizDate)
  524. });
  525. if (existingId > 0)
  526. {
  527. return await _db.Ado.ExecuteCommandAsync(
  528. $"UPDATE {valueTable} SET metric_value=@MetricValue, target_value=@TargetValue, " +
  529. "target_config_id=@TargetConfigId, target_source=@TargetSource, target_resolved_at=@TargetResolvedAt, " +
  530. "calc_time=@Now, update_time=@Now, is_deleted=0, is_active=1 WHERE id=@Id",
  531. new SugarParameter("@MetricValue", metricValue),
  532. new SugarParameter("@TargetValue", SmartOps.KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  533. new SugarParameter("@TargetConfigId", SmartOps.KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  534. new SugarParameter("@TargetSource", SmartOps.KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  535. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt),
  536. new SugarParameter("@Now", now),
  537. new SugarParameter("@Id", existingId));
  538. }
  539. var nextId = await _db.Ado.GetLongAsync(
  540. $"SELECT COALESCE(MAX(id), 0) + 1 FROM {valueTable}");
  541. return await _db.Ado.ExecuteCommandAsync($@"
  542. INSERT INTO {valueTable}
  543. (id, tenant_id, org_id, company_id, factory_id, status, biz_date,
  544. create_time, update_time, is_deleted, is_active,
  545. module_code, metric_code, metric_value, target_value, calc_time,
  546. target_config_id, target_source, target_resolved_at)
  547. VALUES
  548. (@Id, @TenantId, NULL, NULL, @FactoryId, NULL, @BizDate,
  549. @Now, @Now, 0, 1,
  550. @ModuleCode, @MetricCode, @MetricValue, @TargetValue, @Now,
  551. @TargetConfigId, @TargetSource, @TargetResolvedAt)",
  552. new SugarParameter("@Id", nextId),
  553. new SugarParameter("@TenantId", option.TargetTenantId),
  554. new SugarParameter("@FactoryId", option.TargetFactoryId),
  555. new SugarParameter("@BizDate", bizDate),
  556. new SugarParameter("@Now", now),
  557. new SugarParameter("@ModuleCode", ModuleCode),
  558. new SugarParameter("@MetricCode", metricCode),
  559. new SugarParameter("@MetricValue", metricValue),
  560. new SugarParameter("@TargetValue", SmartOps.KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  561. new SugarParameter("@TargetConfigId", SmartOps.KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  562. new SugarParameter("@TargetSource", SmartOps.KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  563. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt));
  564. }
  565. private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType, S6MdpRefreshOption option)
  566. {
  567. await _db.Ado.ExecuteCommandAsync(@"
  568. INSERT INTO mdp_transform_run_log
  569. (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time, stage_rows, standard_rows, dwd_rows, create_time, update_time)
  570. VALUES
  571. (@TenantId, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime, 0, 0, 0, @StartTime, @StartTime)",
  572. new SugarParameter("@TenantId", option.TargetTenantId),
  573. new SugarParameter("@JobCode", JobCode),
  574. new SugarParameter("@JobName", JobName),
  575. new SugarParameter("@TriggerType", triggerType),
  576. new SugarParameter("@BatchId", batchId),
  577. new SugarParameter("@StartTime", startedAt));
  578. return await _db.Ado.GetLongAsync(
  579. "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
  580. new List<SugarParameter> { new("@BatchId", batchId) });
  581. }
  582. private async Task MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S6MdpSyncTransformResult result)
  583. {
  584. var finishedAt = DateTime.Now;
  585. await _db.Ado.ExecuteCommandAsync(@"
  586. UPDATE mdp_transform_run_log
  587. SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
  588. stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=@DwdRows,
  589. summary_json=@SummaryJson, update_time=CURRENT_TIMESTAMP
  590. WHERE id=@Id",
  591. new SugarParameter("@EndTime", finishedAt),
  592. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  593. new SugarParameter("@StageRows", result.StageRows),
  594. new SugarParameter("@StandardRows", result.StandardRows),
  595. new SugarParameter("@DwdRows", result.DwdRows),
  596. new SugarParameter("@SummaryJson", JsonSerializer.Serialize(new
  597. {
  598. batchId = result.BatchId,
  599. sourceZtid = result.SourceZtid,
  600. bizDate = result.BizDate.ToString("yyyy-MM-dd"),
  601. bizMonth = result.BizMonth,
  602. dwdRows = result.DwdRows,
  603. kpiRows = result.KpiRows,
  604. perKpiDwdRows = result.PerKpiDwdRows,
  605. perKpiKpiRows = result.PerKpiKpiRows,
  606. denominatorStatus = result.KpiDenominatorStatus
  607. })),
  608. new SugarParameter("@Id", runLogId));
  609. }
  610. private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message, string batchId)
  611. {
  612. bool runLogUpdated = false;
  613. try
  614. {
  615. var finishedAt = DateTime.Now;
  616. await _db.Ado.ExecuteCommandAsync(@"
  617. UPDATE mdp_transform_run_log
  618. SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
  619. error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
  620. WHERE id=@Id",
  621. new SugarParameter("@EndTime", finishedAt),
  622. new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
  623. new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
  624. new SugarParameter("@Id", runLogId));
  625. runLogUpdated = true;
  626. }
  627. catch (Exception ex)
  628. {
  629. Console.Error.WriteLine($"[S6MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
  630. }
  631. // FAILURE-NOTIFICATION-1:写库 FAILED 成功后发通知给超级管理员;通知失败不影响主流程
  632. if (!runLogUpdated) return;
  633. try
  634. {
  635. await _sysNoticeService.AddNotice(new AddNoticeInput
  636. {
  637. Title = "S6 生产执行 T8 KPI 跑批失败",
  638. Content = $"模块:S6 生产执行\n批次ID:{batchId}\n失败时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n错误信息:{Truncate(message, 1000)}\n\n请查看 mdp_transform_run_log 获取完整错误与重试记录。",
  639. Type = NoticeTypeEnum.NOTICE,
  640. PublicTime = DateTime.Now,
  641. Status = NoticeStatusEnum.PUBLIC,
  642. PublicUserId = NoticeReceiverUserId,
  643. PublicUserName = NoticeReceiverUserName
  644. });
  645. }
  646. catch (Exception notifyEx)
  647. {
  648. _logger.LogError(notifyEx, "[S6MdpSyncTransform] SysNotice 发送失败 (runLogId={RunLogId}, batchId={BatchId})", runLogId, batchId);
  649. }
  650. }
  651. private static string NormalizeTriggerType(string s) =>
  652. string.IsNullOrWhiteSpace(s) ? "AUTO" : s.Trim().ToUpperInvariant();
  653. private static void NormalizeOption(S6MdpRefreshOption option)
  654. {
  655. var d = S6MdpRefreshOption.Default();
  656. if (option.TargetFactoryId <= 0) option.TargetFactoryId = d.TargetFactoryId;
  657. if (string.IsNullOrWhiteSpace(option.SourceZtid)) option.SourceZtid = d.SourceZtid;
  658. option.TargetTenantId = AidopSourceTenantMap.ResolveTenantId(option.SourceZtid, option.TargetTenantId);
  659. if (option.BizDate == default) option.BizDate = d.BizDate;
  660. if (string.IsNullOrWhiteSpace(option.BizMonth)) option.BizMonth = d.BizMonth;
  661. if (option.MonthlyPeriodStart == default) option.MonthlyPeriodStart = d.MonthlyPeriodStart;
  662. if (option.MonthlyPeriodEnd == default) option.MonthlyPeriodEnd = d.MonthlyPeriodEnd;
  663. }
  664. private static string Truncate(string s, int max) =>
  665. string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
  666. }
  667. // DTO ────────────────────────────────────────────────────────────────────────
  668. public sealed class S6MdpRefreshOption
  669. {
  670. public string SourceZtid { get; set; } = "pbxfxp";
  671. /// <summary>KPI/DWD 落库目标租户;≤0 时由 <see cref="AidopSourceTenantMap"/> 按 SourceZtid 解析。</summary>
  672. public long TargetTenantId { get; set; }
  673. /// <summary>KPI/DWD 落库目标工厂;默认 1。</summary>
  674. public long TargetFactoryId { get; set; } = 1L;
  675. public DateTime BizDate { get; set; }
  676. public string BizMonth { get; set; } = "";
  677. public DateTime MonthlyPeriodStart { get; set; }
  678. public DateTime MonthlyPeriodEnd { get; set; }
  679. public static S6MdpRefreshOption Default()
  680. {
  681. var today = DateTime.Today;
  682. var yesterday = today.AddDays(-1);
  683. var lastMonth = today.AddMonths(-1);
  684. var monthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
  685. var monthEnd = monthStart.AddMonths(1).AddDays(-1);
  686. return new S6MdpRefreshOption
  687. {
  688. SourceZtid = "pbxfxp",
  689. TargetTenantId = 0,
  690. TargetFactoryId = 1L,
  691. BizDate = yesterday,
  692. BizMonth = lastMonth.ToString("yyyy-MM"),
  693. MonthlyPeriodStart = monthStart,
  694. MonthlyPeriodEnd = monthEnd.AddDays(1).AddSeconds(-1)
  695. };
  696. }
  697. }
  698. public sealed class S6MdpSyncTransformResult
  699. {
  700. public string BatchId { get; set; } = "";
  701. public long RunLogId { get; set; }
  702. public string TriggerType { get; set; } = "AUTO";
  703. public string SourceZtid { get; set; } = "";
  704. public long TargetTenantId { get; set; }
  705. public long TargetFactoryId { get; set; }
  706. public DateTime BizDate { get; set; }
  707. public string BizMonth { get; set; } = "";
  708. public DateTime MonthlyPeriodStart { get; set; }
  709. public DateTime MonthlyPeriodEnd { get; set; }
  710. public int StageRows { get; set; }
  711. public int StandardRows { get; set; }
  712. public int DwdRows { get; set; }
  713. public int KpiRows { get; set; }
  714. public Dictionary<string, int> PerKpiDwdRows { get; } = new();
  715. public Dictionary<string, int> PerKpiKpiRows { get; } = new();
  716. public List<string> KpiDenominatorStatus { get; } = new();
  717. public void MergeSub(string kpiCode, KpiBuildSubResult sub)
  718. {
  719. PerKpiDwdRows[kpiCode] = sub.DwdRows;
  720. PerKpiKpiRows[kpiCode] = sub.KpiRows;
  721. DwdRows += sub.DwdRows;
  722. KpiRows += sub.KpiRows;
  723. KpiDenominatorStatus.Add($"{kpiCode}:{sub.DenominatorStatus}");
  724. }
  725. }
  726. public sealed class KpiBuildSubResult
  727. {
  728. public int T8Rows { get; set; }
  729. public int DwdRows { get; set; }
  730. public int KpiRows { get; set; }
  731. public string DenominatorStatus { get; set; } = "OK";
  732. }
  733. // T8 result set 投影类型 ──────────────────────────────────────────────────────
  734. internal sealed class S6MfgFulfillmentRow
  735. {
  736. public string? noid { get; set; }
  737. public string? rwnoid { get; set; }
  738. public string? code { get; set; }
  739. public decimal? sl { get; set; }
  740. public decimal? slzx { get; set; }
  741. }
  742. internal sealed class S6CountRow
  743. {
  744. public int ddnum { get; set; }
  745. }
  746. internal sealed class S6PeNumRow
  747. {
  748. public int penum { get; set; }
  749. }
  750. internal sealed class S6OrderCycleRow
  751. {
  752. public string? noid { get; set; }
  753. public int? cycle_days { get; set; }
  754. }
  755. internal sealed class S6WorkOrderCycleRow
  756. {
  757. public string? rwnoid { get; set; }
  758. public int? cycle_days { get; set; }
  759. }
  760. internal sealed class S6OrderFulfillmentRow
  761. {
  762. public string? order_no { get; set; }
  763. public decimal? plan_qty { get; set; }
  764. public decimal? done_qty_in_window { get; set; }
  765. }