using Microsoft.Extensions.Configuration; using SqlSugar; using System.IO; using System.Net.Http; using System.Text; 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.Services; namespace YY.Admin.Services.Service.MixerMaterial; public class MixerMaterialService : IMixerMaterialService, ISingletonDependency { private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly ISqlSugarClient _dbContext; private readonly ILoggerService _logger; private readonly SemaphoreSlim _syncLock = new(1, 1); private readonly object _cacheLock = new(); private readonly string _cacheFilePath; private List _localCache = []; private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new NullableDateTimeJsonConverter() } }; public MixerMaterialService( IHttpClientFactory httpClientFactory, IConfiguration configuration, ISqlSugarClient dbContext, ILoggerService logger) { _httpClientFactory = httpClientFactory; _configuration = configuration; _dbContext = dbContext; _logger = logger; var appDataDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "YY.Admin", "sync-cache"); Directory.CreateDirectory(appDataDir); _cacheFilePath = Path.Combine(appDataDir, "mixer-material-cache.json"); _ = LoadCacheAsync(); } 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"); private async Task LoadCacheAsync() { try { if (!File.Exists(_cacheFilePath)) return; var json = await File.ReadAllTextAsync(_cacheFilePath).ConfigureAwait(false); var list = JsonSerializer.Deserialize>(json, _jsonOpts); if (list != null) lock (_cacheLock) { _localCache = list; } } catch (Exception ex) { _logger.Warning($"[密炼物料] 加载本地缓存失败:{ex.Message}"); } } private async Task SaveCacheAsync(List list) { try { var json = JsonSerializer.Serialize(list, _jsonOpts); await File.WriteAllTextAsync(_cacheFilePath, json).ConfigureAwait(false); } catch (Exception ex) { _logger.Warning($"[密炼物料] 保存本地缓存失败:{ex.Message}"); } } public async Task SyncFromRemoteAsync(CancellationToken ct = default) { // 等待当前正在进行的同步结束,再执行本次同步(WaitAsync(0) 会静默跳过,导致 STOMP 通知丢失) await _syncLock.WaitAsync(ct).ConfigureAwait(false); try { var all = new List(); var pageNo = 1; const int pageSize = 500; while (true) { var qs = HttpUtility.ParseQueryString(string.Empty); qs["pageNo"] = pageNo.ToString(); qs["pageSize"] = pageSize.ToString(); // mes_mixer_material 不在多租户隔离表中,不传 tenantId 可拉取全部记录 var url = $"{BaseUrl}/mes/material/mixerMaterial/anon/list?{qs}"; using var client = CreateClient(); var resp = await client.GetAsync(url, ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) { _logger.Warning($"[密炼物料] 同步请求失败,状态码:{(int)resp.StatusCode}"); break; } 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 records = resultEl.TryGetProperty("records", out var recEl) ? recEl.Deserialize>(_jsonOpts) ?? [] : []; all.AddRange(records); if (records.Count < pageSize) break; pageNo++; } if (all.Count > 0) { lock (_cacheLock) { _localCache = all; } await SaveCacheAsync(all).ConfigureAwait(false); _logger.Information($"[密炼物料] 同步完成,共 {all.Count} 条"); } else { _logger.Warning("[密炼物料] 同步返回 0 条,可能是 tenantId 配置有误或网络异常,保留原缓存"); } } catch (Exception ex) { _logger.Warning($"[密炼物料] 远程同步失败:{ex.Message}"); } finally { _syncLock.Release(); } } public Task PageAsync( int pageNo, int pageSize, string? materialCode = null, string? materialName = null, string? erpCode = null, string? majorCategoryId = null, string? minorCategoryId = null, CancellationToken ct = default) { List cache; lock (_cacheLock) { cache = _localCache; } if (cache.Count > 0) return Task.FromResult(PageFromCache(cache, pageNo, pageSize, materialCode, materialName, erpCode, majorCategoryId, minorCategoryId)); return PageFromRemoteAsync(pageNo, pageSize, materialCode, materialName, erpCode, majorCategoryId, minorCategoryId, ct); } private static MixerMaterialPageResult PageFromCache( List cache, int pageNo, int pageSize, string? materialCode, string? materialName, string? erpCode, string? majorCategoryId, string? minorCategoryId) { var q = cache.AsEnumerable(); if (!string.IsNullOrWhiteSpace(materialCode)) q = q.Where(x => x.MaterialCode?.Contains(materialCode, StringComparison.OrdinalIgnoreCase) == true); if (!string.IsNullOrWhiteSpace(materialName)) q = q.Where(x => x.MaterialName?.Contains(materialName, StringComparison.OrdinalIgnoreCase) == true); if (!string.IsNullOrWhiteSpace(erpCode)) q = q.Where(x => x.ErpCode?.Contains(erpCode, StringComparison.OrdinalIgnoreCase) == true); if (!string.IsNullOrWhiteSpace(majorCategoryId)) q = q.Where(x => x.MajorCategoryId == majorCategoryId); if (!string.IsNullOrWhiteSpace(minorCategoryId)) q = q.Where(x => x.MinorCategoryId == minorCategoryId); var filtered = q.ToList(); var records = filtered.Skip((pageNo - 1) * pageSize).Take(pageSize).ToList(); return new MixerMaterialPageResult(records, filtered.Count, pageNo, pageSize); } private async Task PageFromRemoteAsync( int pageNo, int pageSize, string? materialCode, string? materialName, string? erpCode, string? majorCategoryId, string? minorCategoryId, CancellationToken ct) { var qs = HttpUtility.ParseQueryString(string.Empty); qs["pageNo"] = pageNo.ToString(); qs["pageSize"] = pageSize.ToString(); if (!string.IsNullOrWhiteSpace(materialCode)) qs["materialCode"] = materialCode; if (!string.IsNullOrWhiteSpace(materialName)) qs["materialName"] = materialName; if (!string.IsNullOrWhiteSpace(erpCode)) qs["erpCode"] = erpCode; if (!string.IsNullOrWhiteSpace(majorCategoryId)) qs["majorCategoryId"] = majorCategoryId; if (!string.IsNullOrWhiteSpace(minorCategoryId)) qs["minorCategoryId"] = minorCategoryId; var url = $"{BaseUrl}/mes/material/mixerMaterial/anon/list?{qs}"; 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); var result = doc.RootElement.GetProperty("result"); var records = result.GetProperty("records").Deserialize>(_jsonOpts) ?? []; var total = result.TryGetProperty("total", out var totalEl) ? totalEl.GetInt64() : records.Count; return new MixerMaterialPageResult(records, total, pageNo, pageSize); } public async Task GetByIdAsync(string id, CancellationToken ct = default) { List cache; lock (_cacheLock) { cache = _localCache; } var local = cache.FirstOrDefault(x => x.Id == id); if (local != null) return local; var url = $"{BaseUrl}/mes/material/mixerMaterial/anon/queryById?id={Uri.EscapeDataString(id)}"; using var client = CreateClient(); var resp = await client.GetAsync(url, ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) return null; var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(json); if (!doc.RootElement.TryGetProperty("result", out var resultEl)) return null; return resultEl.Deserialize(_jsonOpts); } public Task AddAsync(MesMixerMaterial material, CancellationToken ct = default) => PostJsonAsync($"{BaseUrl}/mes/material/mixerMaterial/anon/add", material, ct); public Task EditAsync(MesMixerMaterial material, CancellationToken ct = default) => PostJsonAsync($"{BaseUrl}/mes/material/mixerMaterial/anon/edit", material, ct); public async Task DeleteAsync(string id, CancellationToken ct = default) { var url = $"{BaseUrl}/mes/material/mixerMaterial/anon/delete?id={Uri.EscapeDataString(id)}"; using var client = CreateClient(); var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false); return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); } public async Task DeleteBatchAsync(string ids, CancellationToken ct = default) { var url = $"{BaseUrl}/mes/material/mixerMaterial/anon/deleteBatch?ids={Uri.EscapeDataString(ids)}"; using var client = CreateClient(); var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false); return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); } public async Task>> GetMajorCategoryOptionsAsync(CancellationToken ct = default) { try { var root = await _dbContext.Queryable() .ClearFilter().Where(x => x.Code == "XSLMES_MATERIAL").FirstAsync(); if (root == null) return []; var majors = await _dbContext.Queryable() .ClearFilter().Where(x => x.Pid == root.Id).OrderBy(x => x.Code).ToListAsync(); return majors.Where(x => !string.IsNullOrWhiteSpace(x.Id)) .Select(x => new KeyValuePair(x.Name ?? x.Code ?? x.Id, x.Id)) .ToList(); } catch (Exception ex) { _logger.Warning($"[密炼物料] 加载大类选项失败:{ex.Message}"); return []; } } public async Task>> GetMinorCategoryOptionsAsync(string majorCategoryId, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(majorCategoryId)) return []; try { var minors = await _dbContext.Queryable() .ClearFilter().Where(x => x.Pid == majorCategoryId).OrderBy(x => x.Code).ToListAsync(); return minors.Where(x => !string.IsNullOrWhiteSpace(x.Id)) .Select(x => new KeyValuePair(x.Name ?? x.Code ?? x.Id, x.Id)) .ToList(); } catch (Exception ex) { _logger.Warning($"[密炼物料] 加载小类选项失败:{ex.Message}"); return []; } } public async Task> GetMaterialCategoryTreeAsync(CancellationToken ct = default) { try { var root = await _dbContext.Queryable() .ClearFilter().Where(x => x.Code == "XSLMES_MATERIAL").FirstAsync(); if (root == null) return []; var majors = await _dbContext.Queryable() .ClearFilter().Where(x => x.Pid == root.Id).OrderBy(x => x.Code).ToListAsync(); if (majors.Count == 0) return []; var majorIds = majors.Select(x => x.Id).ToList(); var allMinors = await _dbContext.Queryable() .ClearFilter().Where(x => majorIds.Contains(x.Pid!)).OrderBy(x => x.Code).ToListAsync(); return majors.Select(major => { var minorNodes = allMinors .Where(m => m.Pid == major.Id) .Select(m => new MaterialCategoryNode(m.Id, m.Name, m.Code, [])) .ToList(); return new MaterialCategoryNode(major.Id, major.Name, major.Code, minorNodes); }).ToList(); } catch (Exception ex) { _logger.Warning($"[密炼物料] 加载分类树失败:{ex.Message}"); return []; } } private async Task PostJsonAsync(string url, object body, CancellationToken ct) { var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json"); using var client = CreateClient(); var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false); } private static async Task IsSuccessResultAsync(HttpResponseMessage resp, CancellationToken ct) { try { var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); using var doc = JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("code", out var code)) return code.GetInt32() == 200; if (doc.RootElement.TryGetProperty("success", out var success)) return success.GetBoolean(); return true; } catch { return true; } } 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" ]; 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 exact)) return exact; if (DateTime.TryParse(raw, out var fallback)) return fallback; } throw new JsonException($"无法将 JSON 值转换为 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(); } } }