新增MES模块,包含供应商、客户、车辆和地磅数据记录管理功能,支持免密接口和数据同步。更新相关控制器、实体、服务和数据库配置,优化权限管理和数据字典支持,确保系统的灵活性和可扩展性。
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Prism.Events;
|
||||
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.Events;
|
||||
using YY.Admin.Core.Services;
|
||||
|
||||
namespace YY.Admin.Services.Service.Supplier;
|
||||
|
||||
public class SupplierService : ISupplierService, ISingletonDependency
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly INetworkMonitor _networkMonitor;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly ILoggerService _logger;
|
||||
|
||||
private readonly object _cacheLock = new();
|
||||
private readonly string _cacheFilePath;
|
||||
private readonly string _pendingFilePath;
|
||||
private List<MesXslSupplier> _localCache = new();
|
||||
|
||||
private const int MaxPendingRetries = 5;
|
||||
|
||||
// 断开期间被本地修改的条目(含版本锚点),重连后推送到后端
|
||||
private readonly HashSet<string> _pendingLocalModifiedIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
// 断开期间被本地删除的条目
|
||||
private readonly HashSet<string> _pendingLocalDeletedIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
// 版本锚点:最后一次从后端同步时该条目的 UpdateTime,用于冲突检测
|
||||
private readonly Dictionary<string, DateTime?> _anchors = new(StringComparer.OrdinalIgnoreCase);
|
||||
// 每条待推送记录的重试次数,超过上限后放弃
|
||||
private readonly Dictionary<string, int> _pendingRetryCount = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new NullableDateTimeJsonConverter() }
|
||||
};
|
||||
|
||||
public SupplierService(
|
||||
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-supplier-cache.json");
|
||||
_pendingFilePath = Path.Combine(appDataDir, "mes-xsl-supplier-pending.json");
|
||||
LoadCacheFromDisk();
|
||||
LoadPendingFromDisk();
|
||||
}
|
||||
|
||||
// ── 配置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private string BaseUrl =>
|
||||
(_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
|
||||
private int DefaultTenantId =>
|
||||
(int?)_configuration.GetValue<long?>("JeecgIntegration:DefaultTenantId") ?? 1002;
|
||||
private HttpClient CreateClient() => _httpClientFactory.CreateClient("JeecgApi");
|
||||
|
||||
// ── 公开接口 ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<SupplierPageResult> PageAsync(
|
||||
int pageNo, int pageSize,
|
||||
string? supplierCode = null, string? supplierName = null,
|
||||
string? supplierShortName = null, string? erpCode = null,
|
||||
string? status = null, CancellationToken ct = default)
|
||||
{
|
||||
List<MesXslSupplier> source;
|
||||
try
|
||||
{
|
||||
source = _networkMonitor.IsOnline
|
||||
? await FetchRemoteListAsync(ct).ConfigureAwait(false)
|
||||
: GetCacheSnapshot();
|
||||
lock (_cacheLock)
|
||||
{
|
||||
MergeIntoCache(source);
|
||||
SaveCacheToDiskUnsafe();
|
||||
}
|
||||
source = GetCacheSnapshot();
|
||||
}
|
||||
catch
|
||||
{
|
||||
source = GetCacheSnapshot();
|
||||
}
|
||||
|
||||
IEnumerable<MesXslSupplier> q = source;
|
||||
if (!string.IsNullOrWhiteSpace(supplierCode))
|
||||
q = q.Where(x => (x.SupplierCode ?? "").Contains(supplierCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(supplierName))
|
||||
q = q.Where(x => (x.SupplierName ?? "").Contains(supplierName, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(supplierShortName))
|
||||
q = q.Where(x => (x.SupplierShortName ?? "").Contains(supplierShortName, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(erpCode))
|
||||
q = q.Where(x => (x.ErpCode ?? "").Contains(erpCode, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
q = q.Where(x => string.Equals(x.Status, status, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var ordered = q.OrderByDescending(x => x.CreateTime ?? DateTime.MinValue).ToList();
|
||||
var total = ordered.Count;
|
||||
var records = ordered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList();
|
||||
return new SupplierPageResult(records, total, pageNo, pageSize);
|
||||
}
|
||||
|
||||
public async Task<MesXslSupplier?> GetByIdAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
// 有本地待同步改动时优先返回本地版本,避免编辑弹窗被后端旧版本覆盖
|
||||
if (_networkMonitor.IsOnline && !_pendingLocalModifiedIds.Contains(id))
|
||||
{
|
||||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||||
using var client = CreateClient();
|
||||
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("result", out var resultEl))
|
||||
return resultEl.Deserialize<MesXslSupplier>(_jsonOpts);
|
||||
}
|
||||
}
|
||||
return GetCacheSnapshot().FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public Task<bool> AddAsync(MesXslSupplier supplier, CancellationToken ct = default) =>
|
||||
PostAndRefreshAsync(
|
||||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/add?tenantId={DefaultTenantId}",
|
||||
supplier, "add", supplier.Id, ct);
|
||||
|
||||
public Task<bool> EditAsync(MesXslSupplier supplier, CancellationToken ct = default) =>
|
||||
PostAndRefreshAsync(
|
||||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/edit?tenantId={DefaultTenantId}",
|
||||
supplier, "edit", supplier.Id, ct);
|
||||
|
||||
public async Task<bool> DeleteAsync(string id, CancellationToken ct = default)
|
||||
{
|
||||
using var client = CreateClient();
|
||||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||||
var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false);
|
||||
|
||||
if (IsDisconnectedResponse(resp))
|
||||
{
|
||||
var anchor = GetCacheSnapshot()
|
||||
.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
|
||||
RemoveFromLocalCache(id);
|
||||
MarkLocalDeleted(id, anchor);
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||||
.Publish(new SupplierChangedPayload { Action = "delete", SupplierId = id });
|
||||
return true;
|
||||
}
|
||||
|
||||
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
{
|
||||
RemoveFromLocalCache(id);
|
||||
ClearPending(id);
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||||
.Publish(new SupplierChangedPayload { Action = "delete", SupplierId = id });
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatusAsync(string id, string status, CancellationToken ct = default)
|
||||
{
|
||||
using var client = CreateClient();
|
||||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/updateStatus?id={Uri.EscapeDataString(id)}&status={Uri.EscapeDataString(status)}&tenantId={DefaultTenantId}";
|
||||
var resp = await client.PostAsync(url, null, ct).ConfigureAwait(false);
|
||||
|
||||
if (IsDisconnectedResponse(resp))
|
||||
{
|
||||
var anchor = GetCacheSnapshot()
|
||||
.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
|
||||
UpdateLocalStatus(id, status);
|
||||
MarkLocalModified(id, anchor);
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||||
.Publish(new SupplierChangedPayload { Action = "status", SupplierId = id });
|
||||
return true;
|
||||
}
|
||||
|
||||
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
{
|
||||
UpdateLocalStatus(id, status);
|
||||
ClearPending(id);
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||||
.Publish(new SupplierChangedPayload { Action = "status", SupplierId = id });
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重连后将离线期间的本地改动推送到后端,并检测冲突。
|
||||
/// 调用方应在此方法完成后再触发 UI 刷新,确保页面看到的是推送结果。
|
||||
/// </summary>
|
||||
public async Task<PushPendingResult> PushPendingOnReconnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
int pushed = 0, conflicts = 0, newPushed = 0;
|
||||
|
||||
// ── 1. 推送离线期间修改的现有记录 ─────────────────────────────────────
|
||||
foreach (var id in new List<string>(_pendingLocalModifiedIds))
|
||||
{
|
||||
try
|
||||
{
|
||||
var local = GetCacheSnapshot()
|
||||
.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (local == null) { ClearPending(id); continue; }
|
||||
|
||||
var remote = await FetchRemoteSingleAsync(id, ct);
|
||||
|
||||
if (remote != null && HasConflict(id, remote.UpdateTime))
|
||||
{
|
||||
// 后端也改了 → 后端版本获胜(MES 多用户安全策略)
|
||||
UpsertLocalCache(remote);
|
||||
ClearPending(id);
|
||||
conflicts++;
|
||||
_logger.Warning($"[供应商同步] 冲突:{local.SupplierName}({id}),后端版本获胜");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 仅本地改了 → 安全推送
|
||||
var (ok, isVersionConflict) = await PushEditAsync(local, ct);
|
||||
if (isVersionConflict)
|
||||
{
|
||||
var fresh = await FetchRemoteSingleAsync(id, ct);
|
||||
if (fresh != null) UpsertLocalCache(fresh);
|
||||
ClearPending(id);
|
||||
conflicts++;
|
||||
_logger.Warning($"[供应商同步] 服务端版本冲突:{local.SupplierName}({id}),后端版本获胜");
|
||||
}
|
||||
else if (ok)
|
||||
{
|
||||
ClearPending(id);
|
||||
pushed++;
|
||||
_logger.Information($"[供应商同步] 推送成功:{local.SupplierName}({id})");
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingRetryCount.TryGetValue(id, out var retries);
|
||||
retries++;
|
||||
if (retries >= MaxPendingRetries)
|
||||
{
|
||||
_logger.Warning($"[供应商同步] 推送超过最大重试次数({MaxPendingRetries}),放弃:{id}");
|
||||
ClearPending(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingRetryCount[id] = retries;
|
||||
_logger.Warning($"[供应商同步] 推送失败,下次重连重试({retries}/{MaxPendingRetries}):{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[供应商同步] 处理修改记录 {id} 时异常:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. 推送离线期间删除的记录 ──────────────────────────────────────────
|
||||
foreach (var id in new List<string>(_pendingLocalDeletedIds))
|
||||
{
|
||||
try
|
||||
{
|
||||
var remote = await FetchRemoteSingleAsync(id, ct);
|
||||
if (remote == null) { ClearPending(id); continue; } // 后端已不存在,无需操作
|
||||
|
||||
if (HasConflict(id, remote.UpdateTime))
|
||||
{
|
||||
// 后端改动了但本地要删 → 保守策略:后端版本获胜,恢复到本地
|
||||
UpsertLocalCache(remote);
|
||||
ClearPending(id);
|
||||
conflicts++;
|
||||
_logger.Warning($"[供应商同步] 冲突:删除操作与后端改动冲突,已恢复记录 {id}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (await PushDeleteAsync(id, ct))
|
||||
{
|
||||
ClearPending(id);
|
||||
pushed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"[供应商同步] 删除推送失败,下次重连重试:{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[供应商同步] 处理删除记录 {id} 时异常:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. 推送离线期间新增的 local- 记录 ─────────────────────────────────
|
||||
var localOnlyRecords = GetCacheSnapshot()
|
||||
.Where(x => (x.Id ?? "").StartsWith("local-", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
foreach (var record in localOnlyRecords)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await PushAddAsync(record, ct))
|
||||
{
|
||||
RemoveFromLocalCache(record.Id!);
|
||||
newPushed++;
|
||||
// 下次 FetchRemoteListAsync 时后端会返回真实 ID 版本
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"[供应商同步] 新增推送失败,下次重连重试:{record.SupplierName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[供应商同步] 推送新增 {record.SupplierName} 时异常:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new PushPendingResult(pushed, conflicts, newPushed);
|
||||
}
|
||||
|
||||
// ── 私有:写操作核心 ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<bool> PostAndRefreshAsync(
|
||||
string url, MesXslSupplier supplier, string action, string? supplierId, CancellationToken ct)
|
||||
{
|
||||
if (!supplier.TenantId.HasValue || supplier.TenantId <= 0) supplier.TenantId = DefaultTenantId;
|
||||
if (string.IsNullOrWhiteSpace(supplier.Status)) supplier.Status = "0";
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(supplier, _jsonOpts), Encoding.UTF8, "application/json");
|
||||
using var client = CreateClient();
|
||||
var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false);
|
||||
|
||||
if (IsDisconnectedResponse(resp))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(supplier.Id))
|
||||
{
|
||||
// 新增:分配 local- ID,不进 pendingModifiedIds(通过 local- 前缀识别)
|
||||
supplier.Id = $"local-{Guid.NewGuid():N}";
|
||||
supplier.UpdateTime = DateTime.Now;
|
||||
UpsertLocalCache(supplier);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 编辑已有记录:捕获版本锚点(修改前的后端 UpdateTime)
|
||||
var anchor = GetCacheSnapshot()
|
||||
.FirstOrDefault(x => string.Equals(x.Id, supplier.Id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
|
||||
supplier.UpdateTime = DateTime.Now;
|
||||
UpsertLocalCache(supplier);
|
||||
MarkLocalModified(supplier.Id, anchor);
|
||||
}
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||||
.Publish(new SupplierChangedPayload { Action = action, SupplierId = supplierId ?? supplier.Id });
|
||||
return true;
|
||||
}
|
||||
|
||||
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||||
if (ok)
|
||||
{
|
||||
UpsertLocalCache(supplier);
|
||||
ClearPending(supplierId ?? supplier.Id ?? "");
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||||
.Publish(new SupplierChangedPayload { Action = action, SupplierId = supplierId ?? supplier.Id });
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ── 私有:重连推送方法 ────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(bool Ok, bool IsVersionConflict)> PushEditAsync(MesXslSupplier supplier, CancellationToken ct)
|
||||
{
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(supplier, _jsonOpts), Encoding.UTF8, "application/json");
|
||||
using var client = CreateClient();
|
||||
var resp = await client.PostAsync(
|
||||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/edit?tenantId={DefaultTenantId}", content, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode) return (false, false);
|
||||
try
|
||||
{
|
||||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
int code = 200;
|
||||
if (doc.RootElement.TryGetProperty("code", out var codeEl)) code = codeEl.GetInt32();
|
||||
if (code == 200) return (true, false);
|
||||
if (doc.RootElement.TryGetProperty("message", out var msgEl))
|
||||
{
|
||||
var msg = msgEl.GetString() ?? "";
|
||||
if (msg.Contains("已被他人修改")) return (false, true);
|
||||
}
|
||||
return (false, false);
|
||||
}
|
||||
catch { return (true, false); }
|
||||
}
|
||||
|
||||
private async Task<bool> PushDeleteAsync(string id, CancellationToken ct)
|
||||
{
|
||||
using var client = CreateClient();
|
||||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||||
var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> PushAddAsync(MesXslSupplier supplier, CancellationToken ct)
|
||||
{
|
||||
var payload = Clone(supplier);
|
||||
payload.Id = null; // 让后端生成真实 ID
|
||||
var content = new StringContent(
|
||||
JsonSerializer.Serialize(payload, _jsonOpts), Encoding.UTF8, "application/json");
|
||||
using var client = CreateClient();
|
||||
var resp = await client.PostAsync(
|
||||
$"{BaseUrl}/xslmes/mesXslSupplier/anon/add?tenantId={DefaultTenantId}", content, ct)
|
||||
.ConfigureAwait(false);
|
||||
return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<MesXslSupplier?> FetchRemoteSingleAsync(string id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/anon/queryById?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||||
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 resultEl.Deserialize<MesXslSupplier>(_jsonOpts);
|
||||
return null;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ── 私有:冲突检测 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 判断后端是否在断开期间修改了该记录:
|
||||
/// 后端当前 UpdateTime 与本地锚点不同 → 两端都改了 → 冲突。
|
||||
/// </summary>
|
||||
private bool HasConflict(string id, DateTime? remoteUpdateTime)
|
||||
{
|
||||
if (!_anchors.TryGetValue(id, out var anchor)) return false;
|
||||
return remoteUpdateTime != anchor;
|
||||
}
|
||||
|
||||
private static bool IsDisconnectedResponse(HttpResponseMessage resp) =>
|
||||
(int)resp.StatusCode == 499;
|
||||
|
||||
// ── 私有:pending 状态管理 ────────────────────────────────────────────────
|
||||
|
||||
private void MarkLocalModified(string id, DateTime? anchor)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_pendingLocalDeletedIds.Remove(id);
|
||||
_pendingLocalModifiedIds.Add(id);
|
||||
if (!_anchors.ContainsKey(id)) _anchors[id] = anchor; // 只记第一次(保留原始锚点)
|
||||
SavePendingToDiskUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkLocalDeleted(string id, DateTime? anchor)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_pendingLocalModifiedIds.Remove(id);
|
||||
_pendingLocalDeletedIds.Add(id);
|
||||
if (!_anchors.ContainsKey(id)) _anchors[id] = anchor;
|
||||
SavePendingToDiskUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearPending(string id)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_pendingLocalModifiedIds.Remove(id);
|
||||
_pendingLocalDeletedIds.Remove(id);
|
||||
_anchors.Remove(id);
|
||||
_pendingRetryCount.Remove(id);
|
||||
SavePendingToDiskUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 私有:缓存合并 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 后端全量数据与本地 pending 改动合并,保证本地未同步改动不被覆盖。
|
||||
/// </summary>
|
||||
private void MergeIntoCache(List<MesXslSupplier> backendList)
|
||||
{
|
||||
bool hasPending = _pendingLocalModifiedIds.Count > 0 || _pendingLocalDeletedIds.Count > 0;
|
||||
if (!hasPending)
|
||||
{
|
||||
_localCache = backendList.Select(Clone).ToList();
|
||||
return;
|
||||
}
|
||||
|
||||
var localById = _localCache.ToDictionary(x => x.Id ?? "", StringComparer.OrdinalIgnoreCase);
|
||||
var merged = new List<MesXslSupplier>(backendList.Count + 8);
|
||||
|
||||
foreach (var remote in backendList)
|
||||
{
|
||||
var id = remote.Id ?? "";
|
||||
if (_pendingLocalDeletedIds.Contains(id)) continue; // 保持本地删除状态
|
||||
if (_pendingLocalModifiedIds.Contains(id) && localById.TryGetValue(id, out var local))
|
||||
merged.Add(Clone(local)); // 本地版本优先(尚未推送成功)
|
||||
else
|
||||
merged.Add(Clone(remote));
|
||||
}
|
||||
|
||||
// 保留本地新增(local- 前缀,尚未推送到后端)
|
||||
foreach (var local in _localCache)
|
||||
{
|
||||
if ((local.Id ?? "").StartsWith("local-", StringComparison.OrdinalIgnoreCase))
|
||||
merged.Add(Clone(local));
|
||||
}
|
||||
|
||||
// 安全保留:pending-modified 中后端未返回的条目(后端异常情况)
|
||||
foreach (var modId in _pendingLocalModifiedIds)
|
||||
{
|
||||
if (!merged.Any(x => string.Equals(x.Id, modId, StringComparison.OrdinalIgnoreCase))
|
||||
&& localById.TryGetValue(modId, out var orphan))
|
||||
merged.Add(Clone(orphan));
|
||||
}
|
||||
|
||||
_localCache = merged;
|
||||
}
|
||||
|
||||
// ── 私有:磁盘持久化 ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task<List<MesXslSupplier>> FetchRemoteListAsync(CancellationToken ct)
|
||||
{
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
query["pageNo"] = "1";
|
||||
query["pageSize"] = "10000";
|
||||
query["tenantId"] = DefaultTenantId.ToString();
|
||||
var url = $"{BaseUrl}/xslmes/mesXslSupplier/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);
|
||||
return doc.RootElement.GetProperty("result").GetProperty("records")
|
||||
.Deserialize<List<MesXslSupplier>>(_jsonOpts) ?? new();
|
||||
}
|
||||
|
||||
private List<MesXslSupplier> GetCacheSnapshot()
|
||||
{
|
||||
lock (_cacheLock) return _localCache.Select(Clone).ToList();
|
||||
}
|
||||
|
||||
private void LoadCacheFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_cacheFilePath)) return;
|
||||
_localCache = JsonSerializer.Deserialize<List<MesXslSupplier>>(
|
||||
File.ReadAllText(_cacheFilePath), _jsonOpts) ?? new();
|
||||
}
|
||||
catch { _localCache = new(); }
|
||||
}
|
||||
|
||||
private void SaveCacheToDiskUnsafe() =>
|
||||
File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts));
|
||||
|
||||
private void LoadPendingFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_pendingFilePath)) return;
|
||||
var state = JsonSerializer.Deserialize<PendingState>(File.ReadAllText(_pendingFilePath)) ?? new();
|
||||
foreach (var id in state.Modified) _pendingLocalModifiedIds.Add(id);
|
||||
foreach (var id in state.Deleted) _pendingLocalDeletedIds.Add(id);
|
||||
foreach (var (id, timeStr) in state.Anchors)
|
||||
_anchors[id] = timeStr != null && DateTime.TryParse(timeStr, out var dt) ? dt : null;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void SavePendingToDiskUnsafe()
|
||||
{
|
||||
var state = new PendingState
|
||||
{
|
||||
Modified = _pendingLocalModifiedIds.ToList(),
|
||||
Deleted = _pendingLocalDeletedIds.ToList(),
|
||||
Anchors = _anchors.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value?.ToString("yyyy-MM-dd HH:mm:ss"))
|
||||
};
|
||||
File.WriteAllText(_pendingFilePath, JsonSerializer.Serialize(state));
|
||||
}
|
||||
|
||||
private void UpsertLocalCache(MesXslSupplier supplier)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(supplier.Id))
|
||||
{
|
||||
_localCache.Insert(0, Clone(supplier));
|
||||
}
|
||||
else
|
||||
{
|
||||
var idx = _localCache.FindIndex(x =>
|
||||
string.Equals(x.Id, supplier.Id, StringComparison.OrdinalIgnoreCase));
|
||||
if (idx >= 0) _localCache[idx] = Clone(supplier);
|
||||
else _localCache.Insert(0, Clone(supplier));
|
||||
}
|
||||
SaveCacheToDiskUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveFromLocalCache(string id)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_localCache.RemoveAll(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
SaveCacheToDiskUnsafe();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLocalStatus(string id, string status)
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
var item = _localCache.FirstOrDefault(x =>
|
||||
string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (item != null) { item.Status = status; SaveCacheToDiskUnsafe(); }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> 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();
|
||||
}
|
||||
catch { }
|
||||
return true;
|
||||
}
|
||||
|
||||
private static MesXslSupplier Clone(MesXslSupplier x) => new()
|
||||
{
|
||||
Id = x.Id, SupplierCode = x.SupplierCode, SupplierName = x.SupplierName,
|
||||
SupplierShortName = x.SupplierShortName, ErpCode = x.ErpCode, Remark = x.Remark,
|
||||
Status = x.Status, TenantId = x.TenantId, CreateBy = x.CreateBy,
|
||||
CreateTime = x.CreateTime, UpdateBy = x.UpdateBy, UpdateTime = x.UpdateTime,
|
||||
SysOrgCode = x.SysOrgCode
|
||||
};
|
||||
|
||||
private sealed class PendingState
|
||||
{
|
||||
public List<string> Modified { get; set; } = new();
|
||||
public List<string> Deleted { get; set; } = new();
|
||||
public Dictionary<string, string?> Anchors { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class NullableDateTimeJsonConverter : JsonConverter<DateTime?>
|
||||
{
|
||||
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> reader.TokenType == JsonTokenType.String
|
||||
&& DateTime.TryParse(reader.GetString(), out var dt) ? dt : null;
|
||||
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value?.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Prism.Events;
|
||||
using System.Text.Json;
|
||||
using YY.Admin.Core;
|
||||
using YY.Admin.Core.Events;
|
||||
using YY.Admin.Core.Services;
|
||||
|
||||
namespace YY.Admin.Services.Service.Supplier;
|
||||
|
||||
public class SupplierSyncCoordinator : ISingletonDependency
|
||||
{
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly ISupplierService _supplierService;
|
||||
private readonly ILoggerService _logger;
|
||||
|
||||
public SupplierSyncCoordinator(
|
||||
IEventAggregator eventAggregator,
|
||||
ISupplierService supplierService,
|
||||
ILoggerService logger)
|
||||
{
|
||||
_eventAggregator = eventAggregator;
|
||||
_supplierService = supplierService;
|
||||
_logger = logger;
|
||||
_eventAggregator.GetEvent<RemoteCommandReceivedEvent>()
|
||||
.Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread);
|
||||
_eventAggregator.GetEvent<NetworkStatusChangedEvent>()
|
||||
.Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread);
|
||||
}
|
||||
|
||||
private async void OnNetworkStatusChanged(NetworkStatusChangedPayload payload)
|
||||
{
|
||||
if (!payload.IsOnline) return;
|
||||
|
||||
// 先推送本地 pending 改动到后端,再通知 UI 刷新列表
|
||||
PushPendingResult result;
|
||||
try
|
||||
{
|
||||
result = await _supplierService.PushPendingOnReconnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[供应商同步] 重连推送异常:{ex.Message}");
|
||||
result = new PushPendingResult(0, 0, 0);
|
||||
}
|
||||
|
||||
// 通知列表刷新
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>()
|
||||
.Publish(new SupplierChangedPayload { Action = "reconnect" });
|
||||
|
||||
// 若有推送结果,通知 UI 显示摘要
|
||||
bool hasActivity = result.PushedCount > 0
|
||||
|| result.ConflictCount > 0
|
||||
|| result.NewRecordsPushed > 0;
|
||||
if (hasActivity)
|
||||
{
|
||||
_eventAggregator.GetEvent<SyncConflictEvent>().Publish(new SyncConflictPayload
|
||||
{
|
||||
EntityName = "供应商",
|
||||
PushedCount = result.PushedCount,
|
||||
ConflictCount = result.ConflictCount,
|
||||
NewRecordsPushed = result.NewRecordsPushed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemoteCommand(RemoteCommandPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = payload.CommandJson ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(json)) return;
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("cmd", out var cmdEl)) return;
|
||||
if (!cmdEl.GetString().Equals("MES_SUPPLIER_CHANGED", StringComparison.OrdinalIgnoreCase)) return;
|
||||
doc.RootElement.TryGetProperty("action", out var actionEl);
|
||||
doc.RootElement.TryGetProperty("supplierId", out var idEl);
|
||||
_eventAggregator.GetEvent<SupplierChangedEvent>().Publish(new SupplierChangedPayload
|
||||
{
|
||||
Action = actionEl.GetString() ?? string.Empty,
|
||||
SupplierId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"[供应商推送] 处理失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user