using System.Globalization; using System.Text.Json; using System.Text.RegularExpressions; namespace FoodLabeling.Application.Helpers; /// /// Print Log「Expiry Date」:从打印任务 JSON 解析到期日期时间(优先已渲染的 __previewFormatted)。 /// public static class ReportsPrintLogExpiryHelper { private static readonly string[] RootExpiryKeys = { "expiryDate", "expiry", "expirationDate", "bestBeforeDate", "useByDate" }; private static readonly HashSet OffsetUnits = new(StringComparer.Ordinal) { "Minutes", "Hours", "Days", "Weeks", "Months (30 Day)", "Years" }; private static readonly Regex ExpiryNamePattern = new( @"(durationdate|duration_date|duration\s*date|expirydate|expiry_date|expiry\s*date|expirationdate|expiration_date|usebydate|use_by)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex ExpiryTimeNamePattern = new( @"(durationtime|duration_time|duration\s*time|expirytime|expiry_time|expiry\s*time|usebytime|use_by_time)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); private static readonly Regex ExcludeNamePattern = new( @"(currentdate|currenttime|prepped|prepdate|prepon|labelname|durationdate2|duration_date_2|durationdate_2|duration\s*date\s*2)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled); /// 解析并格式化为 Print Log Expiration 列展示(含日期与时间)。 public static string ExtractFormattedExpiryText( string? printInputJson, string? renderTemplateJson = null, DateTime? baseTime = null, DateTime? printedAt = null) { var reference = baseTime ?? printedAt ?? DateTime.Now; if (TryResolveExpiryDateTime(printInputJson, renderTemplateJson, reference, out var resolved)) { return ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved); } var raw = ExtractExpiryText(printInputJson, reference); if (string.Equals(raw, "无", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(renderTemplateJson)) { raw = ExtractExpiryText(renderTemplateJson, reference); } return ReportsDateTimeDisplayHelper.FormatExpiryDisplay(raw, reference); } /// 兼容旧调用:仅 PrintInputJson。 public static string ExtractFormattedExpiryText(string? printInputJson) => ExtractFormattedExpiryText(printInputJson, null, null, null); private static bool TryResolveExpiryDateTime( string? printInputJson, string? renderTemplateJson, DateTime reference, out DateTime expiry) { expiry = default; foreach (var json in new[] { printInputJson, renderTemplateJson }) { if (TryResolveExpiryDateTimeFromTemplateJson(json, reference, out expiry)) { return true; } } return false; } private static bool TryResolveExpiryDateTimeFromTemplateJson(string? json, DateTime reference, out DateTime expiry) { expiry = default; if (string.IsNullOrWhiteSpace(json)) { return false; } try { using var doc = JsonDocument.Parse(json); if (doc.RootElement.ValueKind != JsonValueKind.Object) { return false; } var root = doc.RootElement; if (!TryGetProperty(root, "elements", out var elements) || elements.ValueKind != JsonValueKind.Array) { return false; } if (!TryFindBestElement(elements, isDateElement: true, out var dateElement)) { return false; } if (!TryResolveElementDateTime(dateElement, reference, out var datePart)) { return false; } expiry = datePart; if (HasMeaningfulTime(datePart)) { return true; } if (TryFindBestElement(elements, isDateElement: false, out var timeElement) && TryResolveElementDateTime(timeElement, reference, out var timePart)) { expiry = new DateTime( datePart.Year, datePart.Month, datePart.Day, timePart.Hour, timePart.Minute, 0); return true; } return true; } catch { return false; } } private static bool TryFindBestElement(JsonElement elements, bool isDateElement, out JsonElement bestElement) { bestElement = default; var bestScore = 0; var bestOrder = int.MinValue; var found = false; foreach (var el in elements.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) { continue; } var score = isDateElement ? ScoreExpiryDateElement(el) : ScoreExpiryTimeElement(el); if (score <= 0) { continue; } var order = TryGetIntProperty(el, "orderNum", "OrderNum") ?? 0; if (score > bestScore || (score == bestScore && order > bestOrder)) { bestScore = score; bestOrder = order; bestElement = el; found = true; } } return found; } private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result) { result = default; var display = ResolveElementDisplayText(el, reference); if (string.IsNullOrWhiteSpace(display)) { return false; } return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(display.Trim(), reference, out result); } private static string? ResolveElementDisplayText(JsonElement el, DateTime reference) { if (!TryGetProperty(el, "config", out var cfg) && !TryGetProperty(el, "Config", out cfg)) { return null; } var rawText = GetStringProperty(cfg, "text", "Text").Trim(); var formatText = GetStringProperty(cfg, "format", "Format").Trim(); var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant(); var fromOffset = TryResolveOffsetDisplay(el, cfg, reference, rawText) ?? TryResolveOffsetDisplay(el, cfg, reference, formatText); if (!string.IsNullOrWhiteSpace(fromOffset)) { return fromOffset; } if (!string.IsNullOrWhiteSpace(rawText) && IsLikelyResolvedDateTimeLiteral(rawText)) { return rawText; } if (type is "DATE" or "TIME" or "DURATION") { var off = ReadOffsetFromElementConfig(el, cfg, type); if (off.HasValue) { var resolved = ApplyOffsetToDate(reference, off.Value.amount, off.Value.unit); return type switch { "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture), "DATE" => FormatDateByElementPreset(el, cfg, resolved), _ => $"{off.Value.amount} {off.Value.unit}".Trim() }; } } return string.IsNullOrWhiteSpace(rawText) ? null : rawText; } private static string? TryResolveOffsetDisplay( JsonElement el, JsonElement cfg, DateTime reference, string raw) { if (string.IsNullOrWhiteSpace(raw)) { return null; } if (!TryParseOffsetStored(raw, out var offset)) { return null; } var amount = NormalizeOffsetAmount(offset.value); if (amount is null) { return null; } var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant(); var resolved = ApplyOffsetToDate(reference, amount.Value, offset.unit); return type switch { "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture), "DATE" => FormatDateByElementPreset(el, cfg, resolved), "DURATION" => $"{amount.Value} {offset.unit}".Trim(), _ => FormatDateByElementPreset(el, cfg, resolved) }; } private static string FormatDateByElementPreset(JsonElement el, JsonElement cfg, DateTime date) { var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant(); if (type == "TIME") { return date.ToString("HH:mm", CultureInfo.InvariantCulture); } var inputType = GetStringProperty(cfg, "inputType", "InputType").Trim().ToLowerInvariant(); var format = GetStringProperty(cfg, "format", "Format").Trim(); if (string.IsNullOrWhiteSpace(format) || OffsetUnits.Contains(format) || IsLikelyResolvedDateTimeLiteral(format)) { format = inputType == "datetime" ? "YYYY-MM-DD HH:mm" : "MM/DD/YY"; } return FormatDateByPreset(format, date); } private static string FormatDateByPreset(string format, DateTime date) { var yyyy = date.Year.ToString(CultureInfo.InvariantCulture); var yy = yyyy[^2..]; var mm = date.Month.ToString("00", CultureInfo.InvariantCulture); var dd = date.Day.ToString("00", CultureInfo.InvariantCulture); var hh = date.Hour.ToString("00", CultureInfo.InvariantCulture); var min = date.Minute.ToString("00", CultureInfo.InvariantCulture); return format switch { "DD/MM/YYYY" => $"{dd}/{mm}/{yyyy}", "MM/DD/YYYY" => $"{mm}/{dd}/{yyyy}", "DD/MM/YY" => $"{dd}/{mm}/{yy}", "MM/DD/YY" => $"{mm}/{dd}/{yy}", "MM/YY" => $"{mm}/{yy}", "MM/DD" => $"{mm}/{dd}", "MM" => mm, "DD" => dd, "YY" => yy, "YYYY-MM-DD HH:mm" => $"{yyyy}-{mm}-{dd} {hh}:{min}", _ => format .Replace("YYYY", yyyy, StringComparison.Ordinal) .Replace("YY", yy, StringComparison.Ordinal) .Replace("MM", mm, StringComparison.Ordinal) .Replace("DD", dd, StringComparison.Ordinal) .Replace("HH", hh, StringComparison.Ordinal) .Replace("mm", min, StringComparison.Ordinal) }; } private static (int amount, string unit)? ReadOffsetFromElementConfig(JsonElement el, JsonElement cfg, string type) { if (type == "DURATION") { var unit = GetStringProperty(cfg, "format", "Format").Trim(); if (string.IsNullOrWhiteSpace(unit) || !OffsetUnits.Contains(unit)) { unit = "Days"; } var rawV = GetNumberProperty(cfg, "durationValue", "value", "offsetDays", "DurationValue", "Value", "OffsetDays"); return (rawV ?? 0, unit); } var offsetDays = GetNumberProperty(cfg, "offsetDays", "OffsetDays"); if (offsetDays.HasValue) { return (offsetDays.Value, "Days"); } var fmt = GetStringProperty(cfg, "format", "Format").Trim(); if (!string.IsNullOrWhiteSpace(fmt) && OffsetUnits.Contains(fmt)) { var rawV = GetNumberProperty(cfg, "durationValue", "value", "DurationValue", "Value"); return (rawV ?? 0, fmt); } return null; } private static DateTime ApplyOffsetToDate(DateTime baseTime, int amount, string unit) { var d = baseTime; switch (unit) { case "Minutes": return d.AddMinutes(amount); case "Hours": return d.AddHours(amount); case "Days": return d.AddDays(amount); case "Weeks": return d.AddDays(amount * 7); case "Months (30 Day)": return d.AddDays(amount * 30); case "Years": return d.AddYears(amount); default: return d.AddDays(amount); } } private static bool TryParseOffsetStored(string raw, out (string unit, string value) offset) { offset = default; var t = raw.Trim(); if (!t.StartsWith('{')) { return false; } try { using var doc = JsonDocument.Parse(t); if (doc.RootElement.ValueKind != JsonValueKind.Object) { return false; } var root = doc.RootElement; var unit = GetStringProperty(root, "unit", "Unit").Trim(); var value = GetStringProperty(root, "value", "Value"); if (string.IsNullOrWhiteSpace(unit) || !OffsetUnits.Contains(unit)) { return false; } offset = (unit, value); return true; } catch { return false; } } private static int? NormalizeOffsetAmount(string valueRaw) { var t = valueRaw.Trim(); if (t.Length == 0) { return 0; } if (int.TryParse(t, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) { return n; } if (double.TryParse(t, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) && double.IsFinite(d)) { return (int)d; } return null; } private static bool IsLikelyResolvedDateTimeLiteral(string raw) { var t = raw.Trim(); if (t.Length == 0 || t.StartsWith('{')) { return false; } if (OffsetUnits.Contains(t)) { return false; } if (Regex.IsMatch(t, @"^\d{4}-\d{2}-\d{2}")) { return true; } if (Regex.IsMatch(t, @"^\d{1,2}/\d{1,2}/\d{2,4}")) { return true; } if (Regex.IsMatch(t, @"^\d{1,2}:\d{2}(:\d{2})?$")) { return true; } return false; } private static bool HasMeaningfulTime(DateTime dt) => dt.Hour != 0 || dt.Minute != 0; /// /// 解析保质期展示文本;无法解析时返回「无」。 /// public static string ExtractExpiryText(string? printInputJson, DateTime anchor) { if (string.IsNullOrWhiteSpace(printInputJson)) { return "无"; } try { using var doc = JsonDocument.Parse(printInputJson); if (doc.RootElement.ValueKind != JsonValueKind.Object) { return "无"; } var root = doc.RootElement; if (TryGetRootExpiryText(root, anchor, out var fromRoot)) { return fromRoot; } if (TryGetProperty(root, "elements", out var elements) && elements.ValueKind == JsonValueKind.Array) { if (TryExtractNamedDurationExpiry(elements, anchor, out var fromNamed)) { return fromNamed; } if (TryExtractFromElements(elements, anchor, out var fromElements)) { return fromElements; } } } catch { return "无"; } return "无"; } private static DateTime ResolveAnchor(DateTime? baseTime, DateTime? printedAt, string? printInputJson) { if (baseTime.HasValue && baseTime.Value != DateTime.MinValue) { return baseTime.Value; } if (printedAt.HasValue && printedAt.Value != DateTime.MinValue) { return printedAt.Value; } if (!string.IsNullOrWhiteSpace(printInputJson)) { try { using var doc = JsonDocument.Parse(printInputJson); if (doc.RootElement.ValueKind == JsonValueKind.Object) { foreach (var key in new[] { "baseTime", "BaseTime" }) { if (!TryGetProperty(doc.RootElement, key, out var v)) { continue; } if (v.ValueKind == JsonValueKind.String && DateTime.TryParse( v.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind | DateTimeStyles.AssumeLocal, out var parsed)) { return parsed; } } } } catch { // ignore } } return DateTime.Now; } private static bool IsMissingExpiry(string? raw) => string.IsNullOrWhiteSpace(raw) || string.Equals(raw.Trim(), "无", StringComparison.Ordinal); private static bool TryGetRootExpiryText(JsonElement root, DateTime anchor, out string text) { text = "无"; foreach (var prop in root.EnumerateObject()) { var key = prop.Name.Trim(); if (!RootExpiryKeys.Any(k => key.Equals(k, StringComparison.OrdinalIgnoreCase))) { continue; } if (TryFormatJsonValueAsExpiryText(prop.Value, anchor, out text)) { return true; } } return false; } /// 优先按 elementName 合并 durationdate + durationtime(App 打印快照常见结构)。 private static bool TryExtractNamedDurationExpiry(JsonElement elements, DateTime anchor, out string text) { text = "无"; string? datePart = null; string? timePart = null; foreach (var el in elements.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) { continue; } var name = GetStringProperty(el, "elementName", "ElementName").Trim(); if (name.Equals("durationdate", StringComparison.OrdinalIgnoreCase)) { datePart = GetElementConfigDisplayText(el, anchor); } else if (name.Equals("durationtime", StringComparison.OrdinalIgnoreCase)) { timePart = GetElementConfigDisplayText(el, anchor); } } if (string.IsNullOrWhiteSpace(datePart) || IsMissingExpiry(datePart)) { return false; } text = string.IsNullOrWhiteSpace(timePart) || IsMissingExpiry(timePart) ? datePart.Trim() : ReportsDateTimeDisplayHelper.MergeDateAndTimeParts(datePart.Trim(), timePart.Trim(), anchor); return true; } private static bool TryExtractFromElements(JsonElement elements, DateTime anchor, out string text) { text = "无"; var bestScore = 0; string? bestText = null; var bestOrder = int.MinValue; foreach (var el in elements.EnumerateArray()) { if (el.ValueKind != JsonValueKind.Object) { continue; } var score = ScoreExpiryDateElement(el); if (score <= 0) { continue; } var display = GetElementConfigDisplayText(el, anchor); if (string.IsNullOrWhiteSpace(display)) { continue; } var order = TryGetIntProperty(el, "orderNum", "OrderNum") ?? 0; if (score > bestScore || (score == bestScore && order > bestOrder)) { bestScore = score; bestOrder = order; bestText = display.Trim(); } } if (string.IsNullOrWhiteSpace(bestText)) { return false; } text = bestText; return true; } private static int ScoreExpiryDateElement(JsonElement el) => ScoreExpiryElement(el, ExpiryNamePattern); private static int ScoreExpiryTimeElement(JsonElement el) => ScoreExpiryElement(el, ExpiryTimeNamePattern); private static int ScoreExpiryElement(JsonElement el, Regex namePattern) { var nameHint = $"{GetStringProperty(el, "elementName", "ElementName")} {GetStringProperty(el, "inputKey", "InputKey")}" .Trim(); if (string.IsNullOrWhiteSpace(nameHint)) { nameHint = GetStringProperty(el, "id", "Id"); } if (!string.IsNullOrWhiteSpace(nameHint) && ExcludeNamePattern.IsMatch(nameHint)) { return -1; } var typeAdd = GetStringProperty(el, "typeAdd", "TypeAdd") .Trim() .ToLowerInvariant() .Replace(" ", " ", StringComparison.Ordinal); if (ExpiryNamePattern.IsMatch(nameHint) && !IsWeekdayBarName(nameHint)) { return 100; } if (typeAdd.Contains("duration date", StringComparison.Ordinal) && !IsWeekdayBarName(nameHint)) { return 90; } if (TryGetProperty(el, "config", out var cfg) || TryGetProperty(el, "Config", out cfg)) { var native = GetStringProperty(cfg, "nativeSourceType", "NativeSourceType") .Trim() .ToUpperInvariant(); if (native == "DATE" && ExpiryNamePattern.IsMatch(nameHint) && !IsWeekdayBarName(nameHint)) { return 85; } } var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant(); if (type is "DATE" or "DURATION" or "TIME") { if (ExpiryNamePattern.IsMatch(nameHint) && !IsWeekdayBarName(nameHint)) { return 75; } } return 0; } private static bool IsWeekdayBarName(string nameHint) => Regex.IsMatch( nameHint, @"durationdate2|duration_date_2|durationdate_2|duration\s*date\s*2", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static string? GetElementConfigDisplayText(JsonElement el, DateTime anchor) { if (!TryGetProperty(el, "config", out var cfg) && !TryGetProperty(el, "Config", out cfg)) { return null; } foreach (var key in new[] { "__previewFormatted", "__PreviewFormatted", "format", "Format", "text", "Text" }) { if (!cfg.TryGetProperty(key, out var v) || v.ValueKind != JsonValueKind.String) { continue; } var s = v.GetString()?.Trim(); if (string.IsNullOrWhiteSpace(s)) { continue; } if (TryResolveDisplayText(s, anchor, out var resolved)) { return resolved; } } return null; } private static bool TryResolveDisplayText(string raw, DateTime anchor, out string text) { text = raw.Trim(); if (TryParseOffsetPayload(text, out var amount, out var unit)) { text = ReportsDateTimeDisplayHelper.FormatExpiryDateTime( ApplyOffset(anchor, amount, unit)); return true; } return !string.IsNullOrWhiteSpace(text); } private static bool TryFormatJsonValueAsExpiryText(JsonElement v, DateTime anchor, out string text) { text = "无"; if (v.ValueKind == JsonValueKind.String) { var s = v.GetString()?.Trim(); if (string.IsNullOrWhiteSpace(s)) { return false; } if (TryResolveDisplayText(s, anchor, out text)) { return true; } text = s; return true; } if (v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var unix)) { text = ReportsDateTimeDisplayHelper.FormatExpiryDateTime( DateTimeOffset.FromUnixTimeSeconds(unix).LocalDateTime); return true; } if (v.ValueKind is JsonValueKind.True or JsonValueKind.False or JsonValueKind.Number) { text = v.ToString(); return true; } return false; } private static bool TryParseOffsetPayload(string? raw, out int amount, out string unit) { amount = 0; unit = "Days"; if (string.IsNullOrWhiteSpace(raw)) { return false; } var t = raw.Trim(); if (!t.StartsWith("{")) { if (Regex.IsMatch(t, @"^\d+$")) { amount = int.Parse(t, CultureInfo.InvariantCulture); return true; } return false; } try { using var doc = JsonDocument.Parse(t); if (doc.RootElement.ValueKind != JsonValueKind.Object) { return false; } var root = doc.RootElement; if (!TryGetProperty(root, "unit", out var unitEl)) { return false; } if (!TryGetProperty(root, "value", out var valueEl)) { return false; } unit = unitEl.ValueKind == JsonValueKind.String ? (unitEl.GetString() ?? "Days").Trim() : "Days"; var valStr = valueEl.ValueKind == JsonValueKind.Number ? valueEl.GetRawText() : valueEl.GetString(); if (!int.TryParse(valStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out amount)) { if (double.TryParse(valStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) { amount = (int)Math.Round(d); } else { return false; } } return true; } catch { return false; } } private static DateTime ApplyOffset(DateTime baseTime, int amount, string unit) { var u = unit.Trim().ToLowerInvariant(); return u switch { "hour" or "hours" => baseTime.AddHours(amount), "minute" or "minutes" => baseTime.AddMinutes(amount), _ => baseTime.AddDays(amount) }; } private static bool TryGetProperty(JsonElement obj, string name, out JsonElement value) { if (obj.TryGetProperty(name, out value)) { return true; } if (name.Length > 0) { var pascal = char.ToUpperInvariant(name[0]) + name[1..]; if (obj.TryGetProperty(pascal, out value)) { return true; } } value = default; return false; } private static string GetStringProperty(JsonElement obj, params string[] names) { foreach (var name in names) { if (TryGetProperty(obj, name, out var v) && v.ValueKind == JsonValueKind.String) { return v.GetString() ?? string.Empty; } } return string.Empty; } private static int? GetNumberProperty(JsonElement obj, params string[] names) { foreach (var name in names) { if (!TryGetProperty(obj, name, out var v)) { continue; } if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)) { return n; } if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out n)) { return n; } } return null; } private static int? TryGetIntProperty(JsonElement obj, params string[] names) { foreach (var name in names) { if (!TryGetProperty(obj, name, out var v)) { continue; } if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)) { return n; } } return null; } }