using Microsoft.Extensions.Configuration; using Prism.Events; using System.IO; using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; using System.Web; using YY.Admin.Core; using YY.Admin.Core.Entity; using YY.Admin.Core.Events; using YY.Admin.Core.Helper; using YY.Admin.Core.Services; namespace YY.Admin.Services.Service.MixingProductionPlan; /// 密炼生产计划:MES 只读拉取 + 本地缓存,断网读缓存,联网刷新 public class MixingProductionPlanService : IMixingProductionPlanService, ISingletonDependency { private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly INetworkMonitor _networkMonitor; private readonly IEventAggregator _eventAggregator; private readonly ILoggerService _logger; private readonly SemaphoreSlim _syncLock = new(1, 1); private readonly object _cacheLock = new(); private readonly string _cacheFilePath; private List _localCache = new(); private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new NullableDateTimeJsonConverter() } }; public MixingProductionPlanService( IHttpClientFactory httpClientFactory, IConfiguration configuration, INetworkMonitor networkMonitor, IEventAggregator eventAggregator, ILoggerService logger) { _httpClientFactory = httpClientFactory; _configuration = configuration; _networkMonitor = networkMonitor; _eventAggregator = eventAggregator; _logger = logger; var appDataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "YY.Admin", "sync-cache"); Directory.CreateDirectory(appDataDir); _cacheFilePath = Path.Combine(appDataDir, "mes-xsl-mixing-production-plan-cache.json"); LoadCacheFromDisk(); _logger.Information($"[密炼计划] 服务初始化,缓存={_localCache.Count},在线={_networkMonitor.IsOnline}"); _networkMonitor.StatusChanged += OnNetworkStatusChanged; if (_networkMonitor.IsOnline) _ = Task.Run(() => SyncFromRemoteAsync(CancellationToken.None)); } private string BaseUrl => (_configuration.GetValue("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); private int DefaultTenantId => (int?)_configuration.GetValue("JeecgIntegration:DefaultTenantId") ?? 1002; private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi"); public async Task PageAsync( int pageNo, int pageSize, DateTime? planDateFrom = null, DateTime? planDateTo = null, string? machineName = null, int? shiftFlag = null, string? planNo = null, string? materialName = null, CancellationToken ct = default) { if (_networkMonitor.IsOnline) { try { await SyncFromRemoteAsync(ct).ConfigureAwait(false); } catch (Exception ex) { _logger.Warning($"[密炼计划] 列表拉取失败,使用本地缓存:{ex.Message}"); } } List source; lock (_cacheLock) source = _localCache.Select(Clone).ToList(); var filtered = ApplyFilters(source, planDateFrom, planDateTo, machineName, shiftFlag, planNo, materialName); var total = filtered.Count; var records = filtered .Skip(Math.Max(0, (pageNo - 1) * pageSize)) .Take(pageSize) .ToList(); return new MixingProductionPlanPageResult(records, total, pageNo, pageSize); } public async Task> GetAllCachedAsync(CancellationToken ct = default) { if (_networkMonitor.IsOnline) { try { await SyncFromRemoteAsync(ct).ConfigureAwait(false); } catch (Exception ex) { _logger.Warning($"[密炼计划] 全量拉取失败,使用本地缓存:{ex.Message}"); } } lock (_cacheLock) return _localCache.Select(Clone).ToList(); } public async Task SyncFromRemoteAsync(CancellationToken ct = default) { await _syncLock.WaitAsync(ct).ConfigureAwait(false); try { if (!_networkMonitor.IsOnline) return false; var all = new List(); int pageNo = 1; const int pageSize = 500; while (true) { var query = HttpUtility.ParseQueryString(string.Empty); query["pageNo"] = pageNo.ToString(); query["pageSize"] = pageSize.ToString(); query["tenantId"] = DefaultTenantId.ToString(); var url = $"{BaseUrl}/xslmes/mesXslMixingProductionPlan/anon/list?{query}"; using var client = CreateClient(); var resp = await client.GetAsync(url, ct).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("result", out var resultEl)) break; var page = resultEl.GetProperty("records") .Deserialize>(_jsonOpts) ?? new(); all.AddRange(page); long total = 0; if (resultEl.TryGetProperty("total", out var totalEl)) total = totalEl.GetInt64(); if (all.Count >= total || page.Count < pageSize) break; pageNo++; } List localSnapshot; lock (_cacheLock) localSnapshot = _localCache.Select(Clone).ToList(); var (merged, stats) = MesReadOnlyCacheMergeHelper.Merge( localSnapshot, all, x => x.Id, IsPlanContentEqual, Clone); if (!stats.HasChanges) { _logger.Information($"[密炼计划] 与 MES 对比无差异,跳过更新 count={merged.Count}"); return false; } lock (_cacheLock) { _localCache = merged; SaveCacheToDiskUnsafe(); } _logger.Information( $"[密炼计划] 差异同步完成 total={merged.Count} 新增={stats.Added} 变更={stats.Updated} 删除={stats.Removed}"); return true; } catch (Exception ex) { _logger.Warning($"[密炼计划] 远程同步失败:{ex.Message}"); throw; } finally { _syncLock.Release(); } } private void OnNetworkStatusChanged(bool isOnline) { if (!isOnline) return; _ = Task.Run(async () => { try { if (!await SyncFromRemoteAsync(CancellationToken.None).ConfigureAwait(false)) return; _eventAggregator.GetEvent() .Publish(new MixingProductionPlanChangedPayload { Action = "reconnect" }); } catch (Exception ex) { _logger.Warning($"[密炼计划] 重连同步失败:{ex.Message}"); } }); } private static bool IsPlanContentEqual(MesXslMixingProductionPlan a, MesXslMixingProductionPlan b) => string.Equals(GetPlanFingerprint(a), GetPlanFingerprint(b), StringComparison.Ordinal); private static string GetPlanFingerprint(MesXslMixingProductionPlan x) => JsonSerializer.Serialize(Clone(x), _jsonOpts); private static List ApplyFilters( List source, DateTime? planDateFrom, DateTime? planDateTo, string? machineName, int? shiftFlag, string? planNo, string? materialName) { IEnumerable q = source; if (planDateFrom.HasValue) q = q.Where(x => x.PlanDate?.Date >= planDateFrom.Value.Date); if (planDateTo.HasValue) q = q.Where(x => x.PlanDate?.Date <= planDateTo.Value.Date); if (!string.IsNullOrWhiteSpace(machineName)) q = q.Where(x => (x.MachineName ?? "").Contains(machineName.Trim(), StringComparison.OrdinalIgnoreCase)); if (shiftFlag.HasValue && shiftFlag.Value > 0) q = q.Where(x => x.ShiftFlag == shiftFlag.Value); if (!string.IsNullOrWhiteSpace(planNo)) q = q.Where(x => (x.PlanNo ?? "").Contains(planNo.Trim(), StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrWhiteSpace(materialName)) q = q.Where(x => (x.MaterialName ?? "").Contains(materialName.Trim(), StringComparison.OrdinalIgnoreCase)); return q .OrderByDescending(x => x.PlanDate ?? DateTime.MinValue) .ThenBy(x => x.SortNo ?? int.MaxValue) .ThenBy(x => x.MachineName) .ToList(); } private void LoadCacheFromDisk() { try { if (!File.Exists(_cacheFilePath)) return; _localCache = JsonSerializer.Deserialize>( File.ReadAllText(_cacheFilePath), _jsonOpts) ?? new(); } catch { _localCache = new(); } } private void SaveCacheToDiskUnsafe() => File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts)); private static MesXslMixingProductionPlan Clone(MesXslMixingProductionPlan x) => new() { Id = x.Id, SortNo = x.SortNo, MachineId = x.MachineId, MachineName = x.MachineName, ShiftFlag = x.ShiftFlag, PlanDate = x.PlanDate, PlanNo = x.PlanNo, PlanId = x.PlanId, PlanType = x.PlanType, SourceOrderId = x.SourceOrderId, MaterialId = x.MaterialId, MaterialName = x.MaterialName, OrderNo = x.OrderNo, OrderDate = x.OrderDate, FormulaName = x.FormulaName, PlanWeight = x.PlanWeight, PlannedCarCount = x.PlannedCarCount, ScheduledCarCount = x.ScheduledCarCount, FinishedCarCount = x.FinishedCarCount, PlanCount = x.PlanCount, Remark = x.Remark, TenantId = x.TenantId, SysOrgCode = x.SysOrgCode, CreateBy = x.CreateBy, CreateTime = x.CreateTime, UpdateBy = x.UpdateBy, UpdateTime = x.UpdateTime, DelFlag = x.DelFlag }; private sealed class NullableDateTimeJsonConverter : JsonConverter { private static readonly string[] Formats = [ "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss.fff", "yyyy-MM-ddTHH:mm:ss", "yyyy-MM-ddTHH:mm:ss.fff", "yyyy-MM-ddTHH:mm:ssZ", "yyyy-MM-ddTHH:mm:ss.fffZ", "yyyy-MM-dd" ]; public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.Null) return null; if (reader.TokenType == JsonTokenType.String) { var raw = reader.GetString(); if (string.IsNullOrWhiteSpace(raw)) return null; if (DateTime.TryParseExact(raw, Formats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AssumeLocal, out var dt)) return dt; if (DateTime.TryParse(raw, out var fb)) return fb; } throw new JsonException($"无法转换为 DateTime?,token={reader.TokenType}"); } public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) { if (value.HasValue) writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss")); else writer.WriteNullValue(); } } }