Files
qhmes/yy-admin-master/YY.Admin.Services/Service/WarehouseArea/WarehouseAreaService.cs

780 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.Configuration;
using System.Net.Http;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Web;
using Prism.Events;
using YY.Admin.Core;
using YY.Admin.Core.Entity;
using YY.Admin.Core.Events;
using YY.Admin.Core.Services;
namespace YY.Admin.Services.Service.WarehouseArea;
public class WarehouseAreaService : IWarehouseAreaService, 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 _pendingOpsFilePath;
private readonly string _cacheFilePath;
private List<WarehouseAreaPendingOperation> _pendingOps = new();
private List<MesXslWarehouseArea> _localCache = new();
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new NullableDateTimeJsonConverter() }
};
public WarehouseAreaService(
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);
_pendingOpsFilePath = Path.Combine(appDataDir, "warehouse-area-pending-ops.json");
_cacheFilePath = Path.Combine(appDataDir, "warehouse-area-cache.json");
LoadPendingOpsFromDisk();
LoadCacheFromDisk();
_logger.Information($"[库区同步] 服务初始化完成,缓存={_localCache.Count},待上传={_pendingOps.Count},在线={_networkMonitor.IsOnline}");
_networkMonitor.StatusChanged += OnNetworkStatusChanged;
if (_networkMonitor.IsOnline)
_ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None));
}
private const int MaxPendingRetries = 5;
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<WarehouseAreaPageResult> PageAsync(
int pageNo, int pageSize,
string? areaCode = null,
string? areaName = null,
string? warehouseId = null,
string? status = null,
CancellationToken ct = default)
{
List<MesXslWarehouseArea>? source = null;
if (_networkMonitor.IsOnline)
{
try
{
source = await FetchRemoteListAsync(ct).ConfigureAwait(false);
lock (_cacheLock)
{
_localCache = source.Select(Clone).ToList();
SaveCacheToDiskUnsafe();
}
_logger.Information($"[库区列表] 远端拉取成功 count={source.Count}");
}
catch (Exception ex)
{
source = null;
_logger.Warning($"[库区列表] 远端拉取失败,回退本地缓存:{ex.Message}");
}
}
lock (_cacheLock)
{
source ??= _localCache.Select(Clone).ToList();
source = ApplyPendingOpsSnapshotUnsafe(source);
}
var filtered = ApplyFilters(source, areaCode, areaName, warehouseId, status);
var total = filtered.Count;
var records = filtered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList();
return new WarehouseAreaPageResult(records, total, pageNo, pageSize);
}
// ─────────────────── 单查 ───────────────────
public async Task<MesXslWarehouseArea?> GetByIdAsync(string id, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(id)) return null;
if (_networkMonitor.IsOnline)
{
try
{
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/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) && resultEl.ValueKind != JsonValueKind.Null)
return resultEl.Deserialize<MesXslWarehouseArea>(_jsonOpts);
}
}
catch (Exception ex)
{
_logger.Warning($"[库区详情] 远端查询异常 id={id}: {ex.Message}");
}
}
lock (_cacheLock)
return _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found
? Clone(found) : null;
}
// ─────────────────── 新增 ───────────────────
public async Task<bool> AddAsync(MesXslWarehouseArea area, CancellationToken ct = default)
{
if (area == null) return false;
if (!area.TenantId.HasValue || area.TenantId.Value <= 0)
area.TenantId = DefaultTenantId;
var local = Clone(area);
if (string.IsNullOrWhiteSpace(local.Id))
local.Id = $"local-{Guid.NewGuid():N}";
if (_networkMonitor.IsOnline)
{
try
{
var ok = await RemoteAddAsync(local, ct).ConfigureAwait(false);
if (ok) { UpsertLocalCache(local); return true; }
_logger.Warning($"[库区新增] 远端返回失败 id={local.Id}");
return false;
}
catch (Exception ex)
{
_logger.Warning($"[库区新增] 远端异常,转离线入队:{ex.Message}");
}
}
EnqueuePendingOperation(new WarehouseAreaPendingOperation
{
OpType = WarehouseAreaOperationType.Add,
AreaId = local.Id,
Entity = local
});
UpsertLocalCache(local);
return true;
}
// ─────────────────── 编辑 ───────────────────
public async Task<bool> EditAsync(MesXslWarehouseArea area, CancellationToken ct = default)
{
if (area == null || string.IsNullOrWhiteSpace(area.Id)) return false;
if (!area.TenantId.HasValue || area.TenantId.Value <= 0)
area.TenantId = DefaultTenantId;
var local = Clone(area);
if (IsLocalTempId(local.Id))
{
if (TryMergeIntoPendingAdd(local)) { UpsertLocalCache(local); return true; }
EnqueuePendingOperation(new WarehouseAreaPendingOperation
{
OpType = WarehouseAreaOperationType.Add,
AreaId = local.Id,
Entity = local
});
UpsertLocalCache(local);
return true;
}
if (_networkMonitor.IsOnline)
{
try
{
var (ok, _) = await RemoteEditAsync(local, ct).ConfigureAwait(false);
if (ok) { UpsertLocalCache(local); return true; }
_logger.Warning($"[库区修改] 远端返回失败 id={local.Id}");
return false;
}
catch (Exception ex)
{
_logger.Warning($"[库区修改] 远端异常,转离线入队:{ex.Message}");
}
}
EnqueuePendingOperation(new WarehouseAreaPendingOperation
{
OpType = WarehouseAreaOperationType.Edit,
AreaId = local.Id,
Entity = local,
AnchorUpdateTime = local.UpdateTime
});
UpsertLocalCache(local);
return true;
}
// ─────────────────── 删除 ───────────────────
public async Task<bool> DeleteAsync(string id, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(id)) return false;
if (_networkMonitor.IsOnline)
{
try
{
var ok = await RemoteDeleteAsync(id, ct).ConfigureAwait(false);
if (ok) { RemoveFromLocalCache(id); return true; }
return false;
}
catch (Exception ex)
{
_logger.Warning($"[库区删除] 远端异常,转离线入队:{ex.Message}");
}
}
DateTime? anchor;
lock (_cacheLock)
anchor = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
EnqueuePendingOperation(new WarehouseAreaPendingOperation
{
OpType = WarehouseAreaOperationType.Delete,
AreaId = id,
AnchorUpdateTime = anchor
});
RemoveFromLocalCache(id);
return true;
}
// ─────────────────── 启停 ───────────────────
public async Task<bool> UpdateStatusAsync(string id, string newStatus, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(id)) return false;
if (newStatus != "0" && newStatus != "1") return false;
if (_networkMonitor.IsOnline)
{
try
{
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/anon/updateStatus?id={Uri.EscapeDataString(id)}&status={newStatus}&tenantId={DefaultTenantId}";
using var client = CreateClient();
var resp = await client.PostAsync(url, new StringContent(string.Empty), ct).ConfigureAwait(false);
if (resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false))
{
lock (_cacheLock)
{
var cached = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase));
if (cached != null) cached.Status = newStatus;
SaveCacheToDiskUnsafe();
}
return true;
}
return false;
}
catch (Exception ex)
{
_logger.Warning($"[库区状态] 远端异常,转离线入队:{ex.Message}");
}
}
EnqueuePendingOperation(new WarehouseAreaPendingOperation
{
OpType = WarehouseAreaOperationType.UpdateStatus,
AreaId = id,
NewStatus = newStatus
});
lock (_cacheLock)
{
var cached = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase));
if (cached != null) cached.Status = newStatus;
SaveCacheToDiskUnsafe();
}
return true;
}
// ─────────────────── 编码校验 ───────────────────
public async Task<bool> CheckAreaCodeAsync(string areaCode, string? excludeId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(areaCode)) return true;
if (!_networkMonitor.IsOnline)
{
lock (_cacheLock)
return !_localCache.Any(v =>
string.Equals(v.AreaCode, areaCode.Trim(), StringComparison.OrdinalIgnoreCase) &&
!string.Equals(v.Id, excludeId, StringComparison.OrdinalIgnoreCase));
}
try
{
var query = HttpUtility.ParseQueryString(string.Empty);
query["areaCode"] = areaCode.Trim();
if (!string.IsNullOrWhiteSpace(excludeId)) query["dataId"] = excludeId;
query["tenantId"] = DefaultTenantId.ToString();
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/anon/checkAreaCode?{query}";
using var client = CreateClient();
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) return false;
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("code", out var codeEl)) return codeEl.GetInt32() == 200;
if (doc.RootElement.TryGetProperty("success", out var successEl)) return successEl.GetBoolean();
return false;
}
catch (Exception ex)
{
_logger.Warning($"[库区编码校验] 远端异常,回退本地校验:{ex.Message}");
lock (_cacheLock)
return !_localCache.Any(v =>
string.Equals(v.AreaCode, areaCode.Trim(), StringComparison.OrdinalIgnoreCase) &&
!string.Equals(v.Id, excludeId, StringComparison.OrdinalIgnoreCase));
}
}
// ─────────────────── 远端 HTTP ───────────────────
private async Task<List<MesXslWarehouseArea>> FetchRemoteListAsync(CancellationToken ct)
{
var query = HttpUtility.ParseQueryString(string.Empty);
query["pageNo"] = "1";
query["pageSize"] = "10000";
query["tenantId"] = DefaultTenantId.ToString();
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/anon/list?{query}";
using var client = CreateClient();
_logger.Information($"[库区远端] GET {url}");
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");
return result.GetProperty("records").Deserialize<List<MesXslWarehouseArea>>(_jsonOpts) ?? new();
}
private async Task<bool> RemoteAddAsync(MesXslWarehouseArea entity, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/anon/add?tenantId={DefaultTenantId}";
var payload = Clone(entity);
if (IsLocalTempId(payload.Id)) payload.Id = null;
return await PostJsonAsync(url, payload, ct).ConfigureAwait(false);
}
private async Task<(bool Ok, bool IsVersionConflict)> RemoteEditAsync(MesXslWarehouseArea entity, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/anon/edit?tenantId={DefaultTenantId}";
return await PostJsonCheckVersionAsync(url, entity, ct).ConfigureAwait(false);
}
private async Task<bool> RemoteDeleteAsync(string id, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/anon/delete?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
using var client = CreateClient();
var resp = await client.DeleteAsync(url, ct).ConfigureAwait(false);
return resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
}
private async Task<bool> 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);
if (!resp.IsSuccessStatusCode)
{
_logger.Warning($"[库区] POST {url} HTTP {(int)resp.StatusCode}");
return false;
}
return await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
}
private async Task<(bool Ok, bool IsVersionConflict)> PostJsonCheckVersionAsync(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);
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) && (msgEl.GetString() ?? "").Contains("已被他人修改"))
return (false, true);
return (false, false);
}
catch { return (true, false); }
}
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();
return true;
}
catch { return true; }
}
// ─────────────────── 重连同步 ───────────────────
private void OnNetworkStatusChanged(bool isOnline)
{
if (!isOnline) return;
_ = Task.Run(() => SyncAfterReconnectAsync(CancellationToken.None));
}
private async Task SyncAfterReconnectAsync(CancellationToken ct)
{
_logger.Information("[库区重连] 开始重连同步");
var pushResult = await PushPendingOnReconnectAsync(ct).ConfigureAwait(false);
if (!_networkMonitor.IsOnline) return;
try
{
var remote = await FetchRemoteListAsync(ct).ConfigureAwait(false);
lock (_cacheLock) { _localCache = remote.Select(Clone).ToList(); SaveCacheToDiskUnsafe(); }
_eventAggregator.GetEvent<WarehouseAreaChangedEvent>().Publish(new WarehouseAreaChangedPayload { Action = "pull" });
_logger.Information($"[库区重连] 全量回拉成功 count={remote.Count}");
}
catch (Exception ex) { _logger.Warning($"[库区重连] 全量回拉失败:{ex.Message}"); }
var hasActivity = pushResult.PushedCount > 0 || pushResult.ConflictCount > 0 || pushResult.NewRecordsPushed > 0;
if (hasActivity)
_eventAggregator.GetEvent<SyncConflictEvent>().Publish(new SyncConflictPayload
{
EntityName = "库区",
PushedCount = pushResult.PushedCount,
ConflictCount = pushResult.ConflictCount,
NewRecordsPushed = pushResult.NewRecordsPushed
});
}
private sealed record PendingReplayResult(bool Ok, bool IsConflict, string? EntityId);
private async Task<PushPendingResult> PushPendingOnReconnectAsync(CancellationToken ct)
{
if (!await _syncLock.WaitAsync(0, ct).ConfigureAwait(false)) return new PushPendingResult(0, 0, 0);
try
{
List<WarehouseAreaPendingOperation> snapshot;
lock (_cacheLock) { snapshot = _pendingOps.OrderBy(x => x.CreatedAt).ToList(); }
_logger.Information($"[库区推送] 开始推送 pending={snapshot.Count}");
int pushed = 0, conflicts = 0, newPushed = 0;
foreach (var op in snapshot)
{
if (!_networkMonitor.IsOnline) break;
lock (_cacheLock) { if (!_pendingOps.Any(x => x.Id == op.Id)) continue; }
var result = await ExecutePendingOperationAsync(op, ct).ConfigureAwait(false);
if (!result.Ok)
{
lock (_cacheLock)
{
op.RetryCount++;
if (op.RetryCount >= MaxPendingRetries)
{
_pendingOps.RemoveAll(x => x.Id == op.Id);
SavePendingOpsToDiskUnsafe();
continue;
}
SavePendingOpsToDiskUnsafe();
}
break;
}
if (result.IsConflict)
{
conflicts++;
if (!string.IsNullOrWhiteSpace(result.EntityId)) RemovePendingOpsByEntityId(result.EntityId!);
continue;
}
lock (_cacheLock)
{
if (op.OpType == WarehouseAreaOperationType.Add) newPushed++; else pushed++;
_pendingOps.RemoveAll(x => x.Id == op.Id);
SavePendingOpsToDiskUnsafe();
}
}
return new PushPendingResult(pushed, conflicts, newPushed);
}
finally { _syncLock.Release(); }
}
private async Task<PendingReplayResult> ExecutePendingOperationAsync(WarehouseAreaPendingOperation op, CancellationToken ct)
{
try
{
switch (op.OpType)
{
case WarehouseAreaOperationType.Add:
{
var ok = op.Entity != null && await RemoteAddAsync(op.Entity, ct).ConfigureAwait(false);
return ok ? new PendingReplayResult(true, false, op.AreaId) : new PendingReplayResult(false, false, null);
}
case WarehouseAreaOperationType.Edit:
{
if (op.Entity?.Id == null) return new PendingReplayResult(false, false, null);
var remote = await GetByIdAsync(op.Entity.Id, ct).ConfigureAwait(false);
if (remote != null && op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime)
{
UpsertLocalCache(remote);
return new PendingReplayResult(true, true, op.Entity.Id);
}
var (ok, isConflict) = await RemoteEditAsync(op.Entity, ct).ConfigureAwait(false);
if (isConflict)
{
var fresh = await GetByIdAsync(op.Entity.Id, ct).ConfigureAwait(false);
if (fresh != null) UpsertLocalCache(fresh);
return new PendingReplayResult(true, true, op.Entity.Id);
}
return ok ? new PendingReplayResult(true, false, op.Entity.Id) : new PendingReplayResult(false, false, null);
}
case WarehouseAreaOperationType.Delete:
{
if (string.IsNullOrWhiteSpace(op.AreaId)) return new PendingReplayResult(false, false, null);
var id = op.AreaId!;
var remote = await GetByIdAsync(id, ct).ConfigureAwait(false);
if (remote == null) return new PendingReplayResult(true, false, id);
if (op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime)
{
UpsertLocalCache(remote);
return new PendingReplayResult(true, true, id);
}
var ok = await RemoteDeleteAsync(id, ct).ConfigureAwait(false);
return ok ? new PendingReplayResult(true, false, id) : new PendingReplayResult(false, false, null);
}
case WarehouseAreaOperationType.UpdateStatus:
{
if (string.IsNullOrWhiteSpace(op.AreaId) || string.IsNullOrWhiteSpace(op.NewStatus))
return new PendingReplayResult(false, false, null);
var url = $"{BaseUrl}/xslmes/mesXslWarehouseArea/anon/updateStatus?id={Uri.EscapeDataString(op.AreaId!)}&status={op.NewStatus}&tenantId={DefaultTenantId}";
using var client = CreateClient();
var resp = await client.PostAsync(url, new StringContent(string.Empty), ct).ConfigureAwait(false);
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
return ok ? new PendingReplayResult(true, false, op.AreaId) : new PendingReplayResult(false, false, null);
}
default: return new PendingReplayResult(true, false, null);
}
}
catch (Exception ex)
{
_logger.Warning($"[库区推送] 执行异常 op={op.OpType}: {ex.Message}");
return new PendingReplayResult(false, false, null);
}
}
// ─────────────────── 过滤 / 缓存辅助 ───────────────────
private static List<MesXslWarehouseArea> ApplyFilters(
List<MesXslWarehouseArea> source,
string? areaCode, string? areaName, string? warehouseId, string? status)
{
IEnumerable<MesXslWarehouseArea> q = source;
if (!string.IsNullOrWhiteSpace(areaCode))
q = q.Where(v => (v.AreaCode ?? "").Contains(areaCode, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(areaName))
q = q.Where(v => (v.AreaName ?? "").Contains(areaName, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(warehouseId))
q = q.Where(v => string.Equals(v.WarehouseId, warehouseId, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(status))
q = q.Where(v => string.Equals(v.Status, status, StringComparison.OrdinalIgnoreCase));
return q.OrderByDescending(v => v.CreateTime ?? DateTime.MinValue).ToList();
}
private List<MesXslWarehouseArea> ApplyPendingOpsSnapshotUnsafe(List<MesXslWarehouseArea> source)
{
var map = source.Where(v => !string.IsNullOrWhiteSpace(v.Id))
.ToDictionary(v => v.Id!, Clone, StringComparer.OrdinalIgnoreCase);
foreach (var op in _pendingOps.OrderBy(x => x.CreatedAt))
{
switch (op.OpType)
{
case WarehouseAreaOperationType.Add:
case WarehouseAreaOperationType.Edit:
if (op.Entity?.Id != null) map[op.Entity.Id] = Clone(op.Entity);
break;
case WarehouseAreaOperationType.Delete:
if (!string.IsNullOrWhiteSpace(op.AreaId)) map.Remove(op.AreaId);
break;
case WarehouseAreaOperationType.UpdateStatus:
if (!string.IsNullOrWhiteSpace(op.AreaId) && map.TryGetValue(op.AreaId, out var entry))
entry.Status = op.NewStatus;
break;
}
}
return map.Values.ToList();
}
private void EnqueuePendingOperation(WarehouseAreaPendingOperation op)
{
lock (_cacheLock) { _pendingOps.Add(op); SavePendingOpsToDiskUnsafe(); }
}
private bool TryMergeIntoPendingAdd(MesXslWarehouseArea local)
{
if (string.IsNullOrWhiteSpace(local.Id)) return false;
lock (_cacheLock)
{
var pendingAdd = _pendingOps
.Where(x => x.OpType == WarehouseAreaOperationType.Add)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefault(x =>
string.Equals(x.AreaId, local.Id, StringComparison.OrdinalIgnoreCase) ||
string.Equals(x.Entity?.Id, local.Id, StringComparison.OrdinalIgnoreCase));
if (pendingAdd == null) return false;
pendingAdd.Entity = Clone(local);
pendingAdd.AreaId = local.Id;
SavePendingOpsToDiskUnsafe();
return true;
}
}
private void UpsertLocalCache(MesXslWarehouseArea entity)
{
lock (_cacheLock)
{
var idx = _localCache.FindIndex(v => string.Equals(v.Id, entity.Id, StringComparison.OrdinalIgnoreCase));
if (idx >= 0) _localCache[idx] = Clone(entity); else _localCache.Insert(0, Clone(entity));
SaveCacheToDiskUnsafe();
}
}
private void RemoveFromLocalCache(string id)
{
lock (_cacheLock) { _localCache.RemoveAll(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase)); SaveCacheToDiskUnsafe(); }
}
private void RemovePendingOpsByEntityId(string id)
{
lock (_cacheLock)
{
_pendingOps.RemoveAll(x =>
(!string.IsNullOrWhiteSpace(x.AreaId) && string.Equals(x.AreaId, id, StringComparison.OrdinalIgnoreCase)) ||
(x.Entity?.Id != null && string.Equals(x.Entity.Id, id, StringComparison.OrdinalIgnoreCase)));
SavePendingOpsToDiskUnsafe();
}
}
private void LoadPendingOpsFromDisk()
{
try
{
if (!File.Exists(_pendingOpsFilePath)) return;
var data = JsonSerializer.Deserialize<List<WarehouseAreaPendingOperation>>(File.ReadAllText(_pendingOpsFilePath), _jsonOpts);
_pendingOps = data ?? new();
}
catch { _pendingOps = new(); }
}
private void LoadCacheFromDisk()
{
try
{
if (!File.Exists(_cacheFilePath)) return;
var data = JsonSerializer.Deserialize<List<MesXslWarehouseArea>>(File.ReadAllText(_cacheFilePath), _jsonOpts);
_localCache = data ?? new();
}
catch { _localCache = new(); }
}
private void SavePendingOpsToDiskUnsafe() =>
File.WriteAllText(_pendingOpsFilePath, JsonSerializer.Serialize(_pendingOps, _jsonOpts));
private void SaveCacheToDiskUnsafe() =>
File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts));
private static MesXslWarehouseArea Clone(MesXslWarehouseArea input) => new()
{
Id = input.Id,
AreaCode = input.AreaCode,
AreaName = input.AreaName,
WarehouseId = input.WarehouseId,
WarehouseName = input.WarehouseName,
WarehouseCategory = input.WarehouseCategory,
WarehouseCategoryName = input.WarehouseCategoryName,
MaxCapacity = input.MaxCapacity,
ActualCapacity = input.ActualCapacity,
Remark = input.Remark,
Status = input.Status,
TenantId = input.TenantId,
CreateBy = input.CreateBy,
CreateTime = input.CreateTime,
UpdateBy = input.UpdateBy,
UpdateTime = input.UpdateTime
};
private static bool IsLocalTempId(string? id) =>
!string.IsNullOrWhiteSpace(id) && id.StartsWith("local-", StringComparison.OrdinalIgnoreCase);
// ─────────────────── 内部数据结构 ───────────────────
private sealed class WarehouseAreaPendingOperation
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public WarehouseAreaOperationType OpType { get; set; }
public string? AreaId { get; set; }
public MesXslWarehouseArea? Entity { get; set; }
public DateTime? AnchorUpdateTime { get; set; }
public string? NewStatus { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; } = 0;
}
private enum WarehouseAreaOperationType { Add = 1, Edit = 2, Delete = 3, UpdateStatus = 4 }
// ───────────────────────────────────────────────────────────────────
// 兼容 Jeecg 常见时间字符串格式yyyy-MM-dd HH:mm:ss 等)。
// ───────────────────────────────────────────────────────────────────
private sealed class NullableDateTimeJsonConverter : JsonConverter<DateTime?>
{
private static readonly string[] SupportedFormats =
[
"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, SupportedFormats,
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($"无法转换为 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"));
else writer.WriteNullValue();
}
}
}