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

1069 lines
44 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.Nodes;
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 IPrintBizTemplateBindService _printBizTemplateBindService;
private readonly IPrintTemplateService _printTemplateService;
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,
IPrintBizTemplateBindService printBizTemplateBindService,
IPrintTemplateService printTemplateService)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_networkMonitor = networkMonitor;
_eventAggregator = eventAggregator;
_logger = logger;
_printBizTemplateBindService = printBizTemplateBindService;
_printTemplateService = printTemplateService;
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 const string RawMaterialCardTemplateCode = "MES_RAW_MATERIAL_CARD";
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;
}
/// <summary>
/// 「重新拆码」专用:按 splitDetailId IN 批量删除卡片。
/// 走后端 /anon/deleteBySplitDetailIdsserver 端用 LambdaUpdateWrapper.in().remove() 一次完成。
/// dryRun=true 时仅查询匹配数量、不删除(用于桌面端弹窗确认前的预提示)。
/// 仅在线时调用——离线场景不支持本操作(涉及跨记录批量删,难以走 PendingOps 同步还原)。
/// </summary>
public async Task<int> DeleteBySplitDetailIdsAsync(IEnumerable<string> splitDetailIds, bool dryRun = false, CancellationToken ct = default)
{
if (splitDetailIds == null) return 0;
var distinctIds = splitDetailIds
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (distinctIds.Count == 0) return 0;
if (!_networkMonitor.IsOnline)
{
_logger.Warning("[原材料卡片] 当前离线,无法按拆码明细 ID 批量删除卡片");
return -1;
}
try
{
var idsQs = string.Join(",", distinctIds.Select(Uri.EscapeDataString));
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/anon/deleteBySplitDetailIds"
+ $"?splitDetailIds={idsQs}&dryRun={(dryRun ? "true" : "false")}&tenantId={DefaultTenantId}";
using var client = CreateClient();
var resp = await client.PostAsync(url, new StringContent(string.Empty), ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
_logger.Warning($"[原材料卡片] 按拆码明细ID批删 HTTP {(int)resp.StatusCode}");
return -1;
}
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var code = doc.RootElement.TryGetProperty("code", out var codeEl) ? codeEl.GetInt32() : -1;
if (code != 200) return -1;
var count = doc.RootElement.TryGetProperty("result", out var resultEl) && resultEl.ValueKind == JsonValueKind.Number
? resultEl.GetInt32()
: 0;
if (!dryRun && count > 0)
{
// 同步清理本地缓存:把 SplitDetailId 在 distinctIds 中的卡片移除
var idSet = new HashSet<string>(distinctIds, StringComparer.OrdinalIgnoreCase);
lock (_cacheLock)
{
_localCache.RemoveAll(c => !string.IsNullOrWhiteSpace(c.SplitDetailId) && idSet.Contains(c.SplitDetailId!));
SaveCacheToDiskUnsafe();
}
}
return count;
}
catch (Exception ex)
{
_logger.Warning($"[原材料卡片] 按拆码明细ID批删异常: {ex.Message}");
return -1;
}
}
public async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default)
{
if (_networkMonitor.IsOnline)
{
try
{
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/anon/prepareNativePrint?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
using var client = CreateClient();
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("code", out var codeEl) || codeEl.GetInt32() != 200)
{
var msg = root.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "未知错误";
return (string.Empty, "{}", msg ?? "服务端返回错误");
}
var result = root.GetProperty("result");
var templateJson = result.TryGetProperty("templateJson", out var tjEl) ? tjEl.GetString() : null;
var printDataJson = result.TryGetProperty("printData", out var pdEl)
? pdEl.GetRawText()
: "{}";
if (string.IsNullOrWhiteSpace(templateJson))
return (string.Empty, "{}", "服务端未返回模板 JSON请先在「业务打印绑定」中配置原材料卡片");
return (templateJson!, printDataJson, null);
}
catch (Exception ex)
{
_logger.Warning($"[原材料卡片] 远端准备打印数据失败,回退本地模板渲染 id={id}: {ex.Message}");
}
}
return await PrepareLocalNativePrintAsync(id, ct).ConfigureAwait(false);
}
private async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareLocalNativePrintAsync(string id, CancellationToken ct)
{
var card = GetCardSnapshotById(id);
if (card == null)
{
return (string.Empty, "{}", "本地未找到原材料卡片,无法离线打印");
}
var bindList = _printBizTemplateBindService.GetCached();
if (bindList.Count == 0)
{
try { bindList = await _printBizTemplateBindService.ListAsync(ct).ConfigureAwait(false); } catch { }
}
var bind = bindList.FirstOrDefault(x => string.Equals(x.TemplateCode, RawMaterialCardTemplateCode, StringComparison.OrdinalIgnoreCase))
?? bindList.FirstOrDefault(x => (x.BizName ?? string.Empty).Contains("原材料卡片", StringComparison.OrdinalIgnoreCase));
if (bind == null)
{
return (string.Empty, "{}", "未找到本地业务打印绑定,请先在线同步「原材料卡片」模板配置");
}
var templates = _printTemplateService.GetCached();
if (templates.Count == 0)
{
try { templates = await _printTemplateService.ListAsync(ct).ConfigureAwait(false); } catch { }
}
var templateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialCardTemplateCode : bind.TemplateCode!;
var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase))
?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, templateCode, StringComparison.OrdinalIgnoreCase));
if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson))
{
return (string.Empty, "{}", "本地未找到打印模板,请先在线同步模板后再离线打印");
}
var mappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!;
var printData = BuildPrintDataFromMapping(card, mappingJson, tpl.TemplateJson!);
return (tpl.TemplateJson!, printData.ToJsonString(), null);
}
private MesXslRawMaterialCard? GetCardSnapshotById(string id)
{
if (string.IsNullOrWhiteSpace(id)) return null;
lock (_cacheLock)
{
var snapshot = ApplyPendingOpsSnapshotUnsafe(_localCache.Select(Clone).ToList());
return snapshot.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found
? Clone(found)
: null;
}
}
private JsonObject BuildPrintDataFromMapping<T>(T source, string mappingJson, string templateJson)
{
JsonObject printData = new();
JsonNode? bizRoot = JsonSerializer.SerializeToNode(source, _jsonOpts);
try
{
var mappingNode = JsonNode.Parse(mappingJson) as JsonArray;
if (mappingNode != null)
{
foreach (var rule in mappingNode)
{
if (rule is not JsonObject obj) continue;
var templateField = obj["templateField"]?.GetValue<string>()?.Trim();
if (string.IsNullOrWhiteSpace(templateField)) continue;
var bizField = obj["bizField"]?.GetValue<string>()?.Trim();
JsonNode? value = string.IsNullOrWhiteSpace(bizField)
? JsonValue.Create(string.Empty)
: ResolvePath(bizRoot, bizField!);
SetPath(printData, templateField!, NormalizePrintNodeValue(value));
}
}
}
catch
{
// 映射异常时继续按模板字段补空,避免影响整体打印
}
try
{
var templateNode = JsonNode.Parse(templateJson);
var bindFields = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
CollectTemplateBindFields(templateNode, bindFields);
foreach (var key in bindFields)
{
if (!HasPath(printData, key))
{
SetPath(printData, key, JsonValue.Create(string.Empty)!);
}
}
}
catch
{
// 模板结构异常时忽略补空逻辑
}
return printData;
}
private static void CollectTemplateBindFields(JsonNode? node, HashSet<string> fields)
{
if (node == null) return;
if (node is JsonObject obj)
{
if (obj["dataBinding"] is JsonObject db && db["params"] is JsonArray paramArr)
{
foreach (var p in paramArr)
{
var key = p?["key"]?.GetValue<string>()?.Trim();
if (!string.IsNullOrWhiteSpace(key)) fields.Add(key!);
}
}
var bindField = obj["bindField"]?.GetValue<string>()?.Trim();
if (!string.IsNullOrWhiteSpace(bindField)) fields.Add(bindField!);
if (obj["columns"] is JsonArray cols)
{
foreach (var c in cols)
{
var cBind = c?["bindField"]?.GetValue<string>()?.Trim();
var cField = c?["field"]?.GetValue<string>()?.Trim();
if (!string.IsNullOrWhiteSpace(cBind)) fields.Add(cBind!);
else if (!string.IsNullOrWhiteSpace(cField)) fields.Add(cField!);
}
}
foreach (var kv in obj)
{
CollectTemplateBindFields(kv.Value, fields);
}
return;
}
if (node is JsonArray arr)
{
foreach (var item in arr)
{
CollectTemplateBindFields(item, fields);
}
}
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root == null || string.IsNullOrWhiteSpace(path)) return null;
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
JsonNode? current = root;
foreach (var part in parts)
{
if (current == null) return null;
if (current is JsonArray arr)
{
if (int.TryParse(part, out var index))
{
current = index >= 0 && index < arr.Count ? arr[index] : null;
}
else
{
current = arr.Count > 0 ? arr[0]?[part] : null;
}
}
else
{
current = current[part];
}
}
return current;
}
private static bool HasPath(JsonObject root, string path)
{
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
JsonNode? current = root;
for (int i = 0; i < parts.Length; i++)
{
if (current is not JsonObject obj) return false;
if (!obj.TryGetPropertyValue(parts[i], out current)) return false;
if (i == parts.Length - 1) return true;
}
return false;
}
private static JsonNode NormalizePrintNodeValue(JsonNode? node)
{
if (node == null) return JsonValue.Create(string.Empty)!;
if (node is JsonValue value)
{
if (value.TryGetValue<DateTime>(out var dt))
{
return JsonValue.Create(dt.ToString("yyyy-MM-dd HH:mm:ss"))!;
}
if (value.TryGetValue<DateTimeOffset>(out var dto))
{
return JsonValue.Create(dto.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
}
if (value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text))
{
if ((text.Contains('T') || text.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || text.Contains('+'))
&& DateTimeOffset.TryParse(text, out var parsed))
{
return JsonValue.Create(parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
}
}
}
return node.DeepClone();
}
private static void SetPath(JsonObject target, string path, JsonNode value)
{
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0) return;
JsonObject current = target;
for (int i = 0; i < parts.Length - 1; i++)
{
if (current[parts[i]] is not JsonObject child)
{
child = new JsonObject();
current[parts[i]] = child;
}
current = child;
}
current[parts[^1]] = value;
}
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,
SplitDetailId = input.SplitDetailId,
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,
PackagingTare = input.PackagingTare,
PalletWeight = input.PalletWeight,
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();
}
}
}