Files
qhmes/yy-admin-master/YY.Admin.Services/Service/RawMaterialCard/RawMaterialCardService.cs

736 lines
30 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.RawMaterialCard;
public class RawMaterialCardService : IRawMaterialCardService, 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<RawMaterialCardPendingOperation> _pendingOps = new();
private List<MesXslRawMaterialCard> _localCache = new();
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new NullableDateTimeJsonConverter() }
};
public RawMaterialCardService(
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, "mes-xsl-raw-material-card-pending-ops.json");
_cacheFilePath = Path.Combine(appDataDir, "mes-xsl-raw-material-card-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<RawMaterialCardPageResult> PageAsync(int pageNo, int pageSize,
string? barcode = null, string? batchNo = null, string? materialName = null,
string? supplierName = null, string? status = null, CancellationToken ct = default)
{
List<MesXslRawMaterialCard>? 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, barcode, batchNo, materialName, supplierName, status);
var total = filtered.Count;
var records = filtered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList();
return new RawMaterialCardPageResult(records, total, pageNo, pageSize);
}
public async Task<MesXslRawMaterialCard?> GetByIdAsync(string id, CancellationToken ct = default)
{
if (_networkMonitor.IsOnline)
{
try
{
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/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 null;
return resultEl.Deserialize<MesXslRawMaterialCard>(_jsonOpts);
}
catch (Exception ex)
{
_logger.Warning($"[原材料卡片] 远端查询异常,回退缓存 id={id}: {ex.Message}");
}
}
lock (_cacheLock)
{
return _localCache.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found
? Clone(found) : null;
}
}
public async Task<bool> AddAsync(MesXslRawMaterialCard card, CancellationToken ct = default)
{
if (!card.TenantId.HasValue || card.TenantId.Value <= 0)
card.TenantId = DefaultTenantId;
var local = Clone(card);
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($"[原材料卡片] 远端新增异常,转离线入队 id={local.Id}: {ex.Message}");
}
}
EnqueuePendingOperation(new RawMaterialCardPendingOperation
{
OpType = RawMaterialCardOperationType.Add,
CardId = local.Id,
Card = local,
CreatedAt = DateTime.UtcNow
});
UpsertLocalCache(local);
return true;
}
public async Task<bool> EditAsync(MesXslRawMaterialCard card, CancellationToken ct = default)
{
if (!card.TenantId.HasValue || card.TenantId.Value <= 0)
card.TenantId = DefaultTenantId;
var local = Clone(card);
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($"[原材料卡片] 远端修改异常,转离线入队 id={local.Id}: {ex.Message}");
}
}
EnqueuePendingOperation(new RawMaterialCardPendingOperation
{
OpType = RawMaterialCardOperationType.Edit,
CardId = local.Id,
Card = local,
AnchorUpdateTime = local.UpdateTime,
CreatedAt = DateTime.UtcNow
});
UpsertLocalCache(local);
return true;
}
public async Task<bool> DeleteAsync(string id, CancellationToken ct = default)
{
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($"[原材料卡片] 远端删除异常,转离线入队 id={id}: {ex.Message}");
}
}
DateTime? anchor;
lock (_cacheLock)
{
anchor = _localCache.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
}
EnqueuePendingOperation(new RawMaterialCardPendingOperation
{
OpType = RawMaterialCardOperationType.Delete,
CardId = id,
AnchorUpdateTime = anchor,
CreatedAt = DateTime.UtcNow
});
RemoveFromLocalCache(id);
return true;
}
public async Task<bool> DeleteBatchAsync(string ids, CancellationToken ct = default)
{
var idList = ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var allSuccess = true;
foreach (var id in idList)
allSuccess &= await DeleteAsync(id, ct).ConfigureAwait(false);
return allSuccess;
}
public async Task<bool> UpdatePriorityAsync(string id, string priorityPickup, CancellationToken ct = default)
{
if (_networkMonitor.IsOnline)
{
try
{
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/updatePriority?id={Uri.EscapeDataString(id)}&priorityPickup={Uri.EscapeDataString(priorityPickup)}&tenantId={DefaultTenantId}";
using var client = CreateClient();
var resp = await client.PutAsync(url, null, ct).ConfigureAwait(false);
var ok = resp.IsSuccessStatusCode && await IsSuccessResultAsync(resp, ct).ConfigureAwait(false);
if (ok)
{
UpdateLocalPriority(id, priorityPickup);
return true;
}
return false;
}
catch (Exception ex)
{
_logger.Warning($"[原材料卡片] 远端优先出库更新异常 id={id}: {ex.Message}");
}
}
UpdateLocalPriority(id, priorityPickup);
return true;
}
// ─────────────────────────── Remote helpers ────────────────────────────
private async Task<List<MesXslRawMaterialCard>> FetchRemoteListAsync(CancellationToken ct)
{
var query = HttpUtility.ParseQueryString(string.Empty);
query["pageNo"] = "1";
query["pageSize"] = "10000";
query["tenantId"] = DefaultTenantId.ToString();
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/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);
var result = doc.RootElement.GetProperty("result");
return result.GetProperty("records").Deserialize<List<MesXslRawMaterialCard>>(_jsonOpts) ?? new();
}
private async Task<MesXslRawMaterialCard?> FetchRemoteSingleAsync(string id, CancellationToken ct)
{
try
{
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/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<MesXslRawMaterialCard>(_jsonOpts);
return null;
}
catch { return null; }
}
private async Task<bool> RemoteAddAsync(MesXslRawMaterialCard card, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/anon/add?tenantId={DefaultTenantId}";
var payload = Clone(card);
if (IsLocalTempId(payload.Id)) payload.Id = null;
return await PostJsonAsync(url, payload, ct).ConfigureAwait(false);
}
private async Task<(bool Ok, bool IsVersionConflict)> RemoteEditAsync(MesXslRawMaterialCard card, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/anon/edit?tenantId={DefaultTenantId}";
return await PostJsonCheckVersionAsync(url, card, ct).ConfigureAwait(false);
}
private async Task<bool> RemoteDeleteAsync(string id, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/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);
return resp.IsSuccessStatusCode && 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))
{
var msg = msgEl.GetString() ?? "";
if (msg.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; }
}
// ─────────────────────────── Reconnect sync ────────────────────────────
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<RawMaterialCardChangedEvent>().Publish(new RawMaterialCardChangedPayload { 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<RawMaterialCardPendingOperation> 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))
RemovePendingOpsByCardId(result.EntityId!);
continue;
}
lock (_cacheLock)
{
if (op.OpType == RawMaterialCardOperationType.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(RawMaterialCardPendingOperation op, CancellationToken ct)
{
try
{
switch (op.OpType)
{
case RawMaterialCardOperationType.Add:
{
var ok = op.Card != null && await RemoteAddAsync(op.Card, ct).ConfigureAwait(false);
return ok ? new(true, false, op.CardId) : new(false, false, null);
}
case RawMaterialCardOperationType.Edit:
{
if (op.Card == null || string.IsNullOrWhiteSpace(op.Card.Id)) return new(false, false, null);
var id = op.Card.Id;
var remote = await FetchRemoteSingleAsync(id, ct).ConfigureAwait(false);
if (remote != null && op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime)
{
UpsertLocalCache(remote);
return new(true, true, id);
}
var (ok, isConflict) = await RemoteEditAsync(op.Card, ct).ConfigureAwait(false);
if (isConflict)
{
var fresh = await FetchRemoteSingleAsync(id, ct).ConfigureAwait(false);
if (fresh != null) UpsertLocalCache(fresh);
return new(true, true, id);
}
return ok ? new(true, false, id) : new(false, false, null);
}
case RawMaterialCardOperationType.Delete:
{
if (string.IsNullOrWhiteSpace(op.CardId)) return new(false, false, null);
var id = op.CardId!;
var remote = await FetchRemoteSingleAsync(id, ct).ConfigureAwait(false);
if (remote == null) return new(true, false, id);
if (op.AnchorUpdateTime != null && remote.UpdateTime != op.AnchorUpdateTime)
{
UpsertLocalCache(remote);
return new(true, true, id);
}
var ok = await RemoteDeleteAsync(id, ct).ConfigureAwait(false);
return ok ? new(true, false, id) : new(false, false, null);
}
default:
return new(true, false, null);
}
}
catch (Exception ex)
{
_logger.Warning($"[原材料卡片] 执行pending异常 op={op.OpType}: {ex.Message}");
return new(false, false, null);
}
}
// ─────────────────────────── Local cache helpers ────────────────────────────
private void RemovePendingOpsByCardId(string cardId)
{
lock (_cacheLock)
{
_pendingOps.RemoveAll(x =>
(!string.IsNullOrWhiteSpace(x.CardId) && string.Equals(x.CardId, cardId, StringComparison.OrdinalIgnoreCase)) ||
(x.Card?.Id != null && string.Equals(x.Card.Id, cardId, StringComparison.OrdinalIgnoreCase)));
SavePendingOpsToDiskUnsafe();
}
}
private void EnqueuePendingOperation(RawMaterialCardPendingOperation op)
{
lock (_cacheLock) { _pendingOps.Add(op); SavePendingOpsToDiskUnsafe(); }
}
private void UpsertLocalCache(MesXslRawMaterialCard card)
{
lock (_cacheLock)
{
var idx = _localCache.FindIndex(c => string.Equals(c.Id, card.Id, StringComparison.OrdinalIgnoreCase));
if (idx >= 0) _localCache[idx] = Clone(card);
else _localCache.Insert(0, Clone(card));
SaveCacheToDiskUnsafe();
}
}
private void RemoveFromLocalCache(string id)
{
lock (_cacheLock)
{
_localCache.RemoveAll(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase));
SaveCacheToDiskUnsafe();
}
}
private void UpdateLocalPriority(string id, string priorityPickup)
{
lock (_cacheLock)
{
var item = _localCache.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase));
if (item != null) { item.PriorityPickup = priorityPickup; SaveCacheToDiskUnsafe(); }
}
}
private List<MesXslRawMaterialCard> ApplyPendingOpsSnapshotUnsafe(List<MesXslRawMaterialCard> source)
{
var map = source.Where(c => !string.IsNullOrWhiteSpace(c.Id))
.ToDictionary(c => c.Id!, Clone, StringComparer.OrdinalIgnoreCase);
foreach (var op in _pendingOps.OrderBy(x => x.CreatedAt))
{
switch (op.OpType)
{
case RawMaterialCardOperationType.Add:
case RawMaterialCardOperationType.Edit:
if (op.Card != null && !string.IsNullOrWhiteSpace(op.Card.Id))
map[op.Card.Id] = Clone(op.Card);
break;
case RawMaterialCardOperationType.Delete:
if (!string.IsNullOrWhiteSpace(op.CardId)) map.Remove(op.CardId);
break;
}
}
return map.Values.ToList();
}
private static List<MesXslRawMaterialCard> ApplyFilters(List<MesXslRawMaterialCard> source,
string? barcode, string? batchNo, string? materialName, string? supplierName, string? status)
{
IEnumerable<MesXslRawMaterialCard> q = source;
if (!string.IsNullOrWhiteSpace(barcode))
q = q.Where(c => (c.Barcode ?? "").Contains(barcode, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(batchNo))
q = q.Where(c => (c.BatchNo ?? "").Contains(batchNo, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(materialName))
q = q.Where(c => (c.MaterialName ?? "").Contains(materialName, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(supplierName))
q = q.Where(c => (c.SupplierName ?? "").Contains(supplierName, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(status))
q = q.Where(c => string.Equals(c.Status, status, StringComparison.OrdinalIgnoreCase));
return q.OrderByDescending(c => c.CreateTime ?? DateTime.MinValue).ToList();
}
private void LoadPendingOpsFromDisk()
{
try
{
if (!File.Exists(_pendingOpsFilePath)) return;
var data = JsonSerializer.Deserialize<List<RawMaterialCardPendingOperation>>(File.ReadAllText(_pendingOpsFilePath), _jsonOpts);
_pendingOps = data ?? new();
}
catch (Exception ex) { _pendingOps = new(); _logger.Warning($"[原材料卡片] 载入待上传失败:{ex.Message}"); }
}
private void LoadCacheFromDisk()
{
try
{
if (!File.Exists(_cacheFilePath)) return;
var data = JsonSerializer.Deserialize<List<MesXslRawMaterialCard>>(File.ReadAllText(_cacheFilePath), _jsonOpts);
_localCache = data ?? new();
}
catch (Exception ex) { _localCache = new(); _logger.Warning($"[原材料卡片] 载入缓存失败:{ex.Message}"); }
}
private void SavePendingOpsToDiskUnsafe() =>
File.WriteAllText(_pendingOpsFilePath, JsonSerializer.Serialize(_pendingOps, _jsonOpts));
private void SaveCacheToDiskUnsafe() =>
File.WriteAllText(_cacheFilePath, JsonSerializer.Serialize(_localCache, _jsonOpts));
private static MesXslRawMaterialCard Clone(MesXslRawMaterialCard input) => new()
{
Id = input.Id,
Barcode = input.Barcode,
BatchNo = input.BatchNo,
EntryDate = input.EntryDate,
MaterialId = input.MaterialId,
MaterialName = input.MaterialName,
MaterialDesc = input.MaterialDesc,
SupplierId = input.SupplierId,
SupplierName = input.SupplierName,
ManufacturerMaterialName = input.ManufacturerMaterialName,
ShelfLife = input.ShelfLife,
TotalWeight = input.TotalWeight,
RemainingWeight = input.RemainingWeight,
RemainingQuantity = input.RemainingQuantity,
Status = input.Status,
TestResult = input.TestResult,
WarehouseArea = input.WarehouseArea,
UnloadOperator = input.UnloadOperator,
PriorityPickup = input.PriorityPickup,
CreateBy = input.CreateBy,
CreateTime = input.CreateTime,
UpdateBy = input.UpdateBy,
UpdateTime = input.UpdateTime,
TenantId = input.TenantId
};
private static bool IsLocalTempId(string? id) =>
!string.IsNullOrWhiteSpace(id) && id.StartsWith("local-", StringComparison.OrdinalIgnoreCase);
private sealed class RawMaterialCardPendingOperation
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public RawMaterialCardOperationType OpType { get; set; }
public string? CardId { get; set; }
public MesXslRawMaterialCard? Card { get; set; }
public DateTime? AnchorUpdateTime { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; } = 0;
}
private enum RawMaterialCardOperationType { Add = 1, Edit = 2, Delete = 3 }
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($"无法将 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")); return; }
writer.WriteNullValue();
}
}
}