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;
}
}