Files
qhmes/yy-admin-master/YY.Admin.Services/Service/WeightRecord/WeightRecordService.cs

774 lines
33 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;
using YY.Admin.Core.Util;
namespace YY.Admin.Services.Service.WeightRecord;
public class WeightRecordService : IWeightRecordService, ISingletonDependency
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly INetworkMonitor _networkMonitor;
private readonly IEventAggregator _eventAggregator;
private readonly ILoggerService _logger;
// 用于按 BillNo 实时累计「已入场重量」,从本地入场记录缓存推导,
// 与后端 IMesXslRawMaterialEntryService.sumEnteredWeightByBillNos 同一口径。
private readonly IRawMaterialEntryService _rawMaterialEntryService;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private readonly object _cacheLock = new();
private readonly string _pendingOpsFilePath;
private readonly string _cacheFilePath;
private List<WeightRecordPendingOperation> _pendingOps = new();
private List<MesXslWeightRecord> _localCache = new();
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new NullableDateTimeJsonConverter() }
};
public WeightRecordService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
INetworkMonitor networkMonitor,
IEventAggregator eventAggregator,
ILoggerService logger,
IRawMaterialEntryService rawMaterialEntryService)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_networkMonitor = networkMonitor;
_eventAggregator = eventAggregator;
_logger = logger;
_rawMaterialEntryService = rawMaterialEntryService;
var appDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YY.Admin", "sync-cache");
Directory.CreateDirectory(appDataDir);
_pendingOpsFilePath = Path.Combine(appDataDir, "weight-record-pending-ops.json");
_cacheFilePath = Path.Combine(appDataDir, "weight-record-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<WeightRecordPageResult> PageAsync(
int pageNo, int pageSize,
string? filterBillNo = null,
string? filterPlateNumber = null,
string? filterInoutDirection = null,
string? filterDriverName = null,
string? filterMixerMaterialName = null,
CancellationToken ct = default)
{
List<MesXslWeightRecord>? 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, filterBillNo, filterPlateNumber, filterInoutDirection, filterDriverName, filterMixerMaterialName);
var total = filtered.Count;
var records = filtered.Skip(Math.Max(0, (pageNo - 1) * pageSize)).Take(pageSize).ToList();
// 当前页结果按本地入场记录缓存,按 BillNo 实时累计「已入场重量」(与后端口径一致)。
// 放在分页之后做:避免对全量 source 做不必要的计算。
FillEnteredWeightFromLocalEntries(records);
return new WeightRecordPageResult(records, total, pageNo, pageSize);
}
/// <summary>
/// 给一批磅单记录批量填充「已入场重量」。
/// 数据来源:本地 RawMaterialEntry 缓存的拆码明细字段totalPortions / portionWeight
/// 与后端 sumEnteredWeightByBillNos 同口径,确保离线场景也能正确显示。
/// </summary>
private void FillEnteredWeightFromLocalEntries(List<MesXslWeightRecord> records)
{
if (records.Count == 0)
{
return;
}
var billNos = records
.Select(r => r.BillNo)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (billNos.Count == 0)
{
// 全部磅单都没有 BillNo保留服务端返回值若有避免无端置 0。
return;
}
var entries = _rawMaterialEntryService.GetCachedSnapshot();
var sumMap = EnteredWeightCalculator.SumByBillNos(entries, billNos!);
foreach (var r in records)
{
if (string.IsNullOrWhiteSpace(r.BillNo))
{
r.EnteredWeight = 0d;
continue;
}
r.EnteredWeight = sumMap.TryGetValue(r.BillNo, out var v) ? v : 0d;
}
}
public async Task<MesXslWeightRecord?> GetByIdAsync(string id, CancellationToken ct = default)
{
MesXslWeightRecord? record = null;
if (_networkMonitor.IsOnline)
{
try
{
var url = $"{BaseUrl}/xslmes/mesXslWeightRecord/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))
{
record = resultEl.Deserialize<MesXslWeightRecord>(_jsonOpts);
}
}
}
catch (Exception ex)
{
_logger.Warning($"[磅单详情] 远端查询异常 id={id}: {ex.Message}");
}
}
if (record == null)
{
lock (_cacheLock)
{
record = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found
? Clone(found) : null;
}
}
if (record != null)
{
FillEnteredWeightFromLocalEntries(new List<MesXslWeightRecord> { record });
}
return record;
}
public async Task<bool> AddAsync(MesXslWeightRecord entity, CancellationToken ct = default)
{
if (!entity.TenantId.HasValue || entity.TenantId.Value <= 0)
entity.TenantId = DefaultTenantId;
if (string.IsNullOrWhiteSpace(entity.BillNo))
entity.BillNo = GenerateBillNo();
if (string.IsNullOrWhiteSpace(entity.BillType))
entity.BillType = ResolveBillType(entity);
var local = Clone(entity);
if (string.IsNullOrWhiteSpace(local.Id))
local.Id = $"local-{Guid.NewGuid():N}";
if (_networkMonitor.IsOnline)
{
try
{
_logger.Information($"[磅单新增] 尝试远端新增 id={local.Id}");
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 WeightRecordPendingOperation
{
OpType = WeightRecordOperationType.Add,
WeightRecordId = local.Id,
Entity = local
});
UpsertLocalCache(local);
return true;
}
/// <summary>
/// 按后端规则生成磅单号BDH-yyyyMMddHHmmss + 3位随机数
/// </summary>
private static string GenerateBillNo()
{
var dateStr = DateTime.Now.ToString("yyyyMMddHHmmss");
var seq = Random.Shared.Next(0, 1000).ToString("D3");
return $"BDH-{dateStr}{seq}";
}
/// <summary>
/// 根据称重数据推导单据类型:仅毛重=已称毛重;仅皮重=已称皮重;毛重+皮重=称重完成。
/// </summary>
private static string? ResolveBillType(MesXslWeightRecord entity)
{
if (entity.GrossWeight.HasValue && entity.TareWeight.HasValue) return "2";
if (entity.GrossWeight.HasValue) return "1";
if (entity.TareWeight.HasValue) return "3";
return null;
}
public async Task<bool> EditAsync(MesXslWeightRecord entity, CancellationToken ct = default)
{
if (!entity.TenantId.HasValue || entity.TenantId.Value <= 0)
entity.TenantId = DefaultTenantId;
if (string.IsNullOrWhiteSpace(entity.BillType))
entity.BillType = ResolveBillType(entity);
var local = Clone(entity);
if (IsLocalTempId(local.Id))
{
// 本地临时ID表示该记录尚未上云将“二次称重编辑”合并到同一条待上传新增避免重连后产生冲突。
if (TryMergeIntoPendingAdd(local))
{
UpsertLocalCache(local);
return true;
}
// 兜底:若找不到待上传新增,则按新增入队,确保本地修改不丢失。
EnqueuePendingOperation(new WeightRecordPendingOperation
{
OpType = WeightRecordOperationType.Add,
WeightRecordId = local.Id,
Entity = local
});
UpsertLocalCache(local);
return true;
}
if (_networkMonitor.IsOnline)
{
try
{
_logger.Information($"[磅单修改] 尝试远端修改 id={local.Id}");
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 WeightRecordPendingOperation
{
OpType = WeightRecordOperationType.Edit,
WeightRecordId = local.Id,
Entity = local,
AnchorUpdateTime = local.UpdateTime
});
UpsertLocalCache(local);
return true;
}
private bool TryMergeIntoPendingAdd(MesXslWeightRecord local)
{
if (string.IsNullOrWhiteSpace(local.Id)) return false;
lock (_cacheLock)
{
var pendingAdd = _pendingOps
.Where(x => x.OpType == WeightRecordOperationType.Add)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefault(x =>
string.Equals(x.WeightRecordId, local.Id, StringComparison.OrdinalIgnoreCase) ||
string.Equals(x.Entity?.Id, local.Id, StringComparison.OrdinalIgnoreCase));
if (pendingAdd == null) return false;
pendingAdd.Entity = Clone(local);
pendingAdd.WeightRecordId = local.Id;
SavePendingOpsToDiskUnsafe();
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($"[磅单删除] 远端异常,转离线入队:{ex.Message}");
}
}
DateTime? anchor;
lock (_cacheLock)
anchor = _localCache.FirstOrDefault(v => string.Equals(v.Id, id, StringComparison.OrdinalIgnoreCase))?.UpdateTime;
EnqueuePendingOperation(new WeightRecordPendingOperation
{
OpType = WeightRecordOperationType.Delete,
WeightRecordId = id,
AnchorUpdateTime = anchor
});
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;
}
// ─────────────────── 远端 HTTP ───────────────────
private async Task<List<MesXslWeightRecord>> FetchRemoteListAsync(CancellationToken ct)
{
var query = HttpUtility.ParseQueryString(string.Empty);
query["pageNo"] = "1";
query["pageSize"] = "10000";
query["tenantId"] = DefaultTenantId.ToString();
var url = $"{BaseUrl}/xslmes/mesXslWeightRecord/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<MesXslWeightRecord>>(_jsonOpts) ?? new();
}
private async Task<bool> RemoteAddAsync(MesXslWeightRecord entity, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslWeightRecord/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(MesXslWeightRecord entity, CancellationToken ct)
{
var url = $"{BaseUrl}/xslmes/mesXslWeightRecord/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/mesXslWeightRecord/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) && (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<MesXslWeightRecordChangedEvent>().Publish(new MesXslWeightRecordChangedPayload { 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<WeightRecordPendingOperation> 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 == WeightRecordOperationType.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(WeightRecordPendingOperation op, CancellationToken ct)
{
try
{
switch (op.OpType)
{
case WeightRecordOperationType.Add:
{
var ok = op.Entity != null && await RemoteAddAsync(op.Entity, ct).ConfigureAwait(false);
return ok ? new PendingReplayResult(true, false, op.WeightRecordId) : new PendingReplayResult(false, false, null);
}
case WeightRecordOperationType.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 WeightRecordOperationType.Delete:
{
if (string.IsNullOrWhiteSpace(op.WeightRecordId)) return new PendingReplayResult(false, false, null);
var id = op.WeightRecordId!;
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);
}
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<MesXslWeightRecord> ApplyFilters(
List<MesXslWeightRecord> source,
string? billNo, string? plateNumber, string? inoutDirection, string? driverName,
string? mixerMaterialName = null)
{
IEnumerable<MesXslWeightRecord> q = source;
if (!string.IsNullOrWhiteSpace(billNo))
q = q.Where(v => (v.BillNo ?? "").Contains(billNo, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(plateNumber))
q = q.Where(v => (v.PlateNumber ?? "").Contains(plateNumber, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(inoutDirection))
q = q.Where(v => string.Equals(v.InoutDirection, inoutDirection, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(driverName))
q = q.Where(v => (v.DriverName ?? "").Contains(driverName, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(mixerMaterialName))
q = q.Where(v => (v.MixerMaterialNames ?? "").Contains(mixerMaterialName, StringComparison.OrdinalIgnoreCase));
return q.OrderByDescending(v => v.CreateTime ?? DateTime.MinValue).ToList();
}
private List<MesXslWeightRecord> ApplyPendingOpsSnapshotUnsafe(List<MesXslWeightRecord> 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 WeightRecordOperationType.Add:
case WeightRecordOperationType.Edit:
if (op.Entity?.Id != null) map[op.Entity.Id] = Clone(op.Entity);
break;
case WeightRecordOperationType.Delete:
if (!string.IsNullOrWhiteSpace(op.WeightRecordId)) map.Remove(op.WeightRecordId);
break;
}
}
return map.Values.ToList();
}
private void EnqueuePendingOperation(WeightRecordPendingOperation op)
{
lock (_cacheLock) { _pendingOps.Add(op); SavePendingOpsToDiskUnsafe(); }
}
private void UpsertLocalCache(MesXslWeightRecord 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.WeightRecordId) && string.Equals(x.WeightRecordId, 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<WeightRecordPendingOperation>>(File.ReadAllText(_pendingOpsFilePath), _jsonOpts);
_pendingOps = data ?? new();
}
catch { _pendingOps = new(); }
}
private void LoadCacheFromDisk()
{
try
{
if (!File.Exists(_cacheFilePath)) return;
var data = JsonSerializer.Deserialize<List<MesXslWeightRecord>>(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 MesXslWeightRecord Clone(MesXslWeightRecord input) => new()
{
Id = input.Id,
BillNo = input.BillNo,
WeighDate = input.WeighDate,
InoutDirection = input.InoutDirection,
VehicleId = input.VehicleId,
PlateNumber = input.PlateNumber,
SenderUnit = input.SenderUnit,
ReceiverUnit = input.ReceiverUnit,
MixerMaterialIds = input.MixerMaterialIds,
MixerMaterialNames = input.MixerMaterialNames,
MaterialType = input.MaterialType,
GrossWeight = input.GrossWeight,
TareWeight = input.TareWeight,
NetWeight = input.NetWeight,
// 「已入场重量」由实时聚合写入Clone 也要原样传递,避免本地缓存 / Pending 重放后被抹掉
EnteredWeight = input.EnteredWeight,
DriverName = input.DriverName,
DriverPhone = input.DriverPhone,
BillType = input.BillType,
TenantId = input.TenantId,
CreateBy = input.CreateBy,
CreateTime = input.CreateTime,
UpdateBy = input.UpdateBy,
UpdateTime = input.UpdateTime,
SysOrgCode = input.SysOrgCode,
InoutDirectionText = input.InoutDirectionText,
BillTypeText = input.BillTypeText,
MaterialTypeText = input.MaterialTypeText
};
private static bool IsLocalTempId(string? id) =>
!string.IsNullOrWhiteSpace(id) && id.StartsWith("local-", StringComparison.OrdinalIgnoreCase);
// ─────────────────── 内部数据结构 ───────────────────
private sealed class WeightRecordPendingOperation
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public WeightRecordOperationType OpType { get; set; }
public string? WeightRecordId { get; set; }
public MesXslWeightRecord? Entity { get; set; }
public DateTime? AnchorUpdateTime { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; } = 0;
}
private enum WeightRecordOperationType { 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($"无法转换为 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();
}
}
}