Files
qhmes/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs

1493 lines
62 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 HandyControl.Controls;
using HandyControl.Data;
using Prism.Commands;
using System.Collections.ObjectModel;
using System.IO;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Windows;
using System.Windows.Threading;
using YY.Admin.Core.Entity;
using YY.Admin.Core.Services;
using YY.Admin.Infrastructure.Print;
using YY.Admin.Services.Service;
using YY.Admin.Services.Service.Print;
using YY.Admin.Views.RawMaterialEntry;
namespace YY.Admin.ViewModels.RawMaterialEntry;
/// <summary>
/// 「新增原料入场记录」独立页面:左侧表单逻辑继承编辑 VM右侧展示当日入场简要列表并支持选中回填模板。
/// </summary>
public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogViewModel
{
private const int TodayListFetchSize = 5000;
private const string RawMaterialEntryBizCode = "1900000000000000530";
private const string RawMaterialEntryTemplateCode = "MES_RAW_MATERIAL_ENTRY";
private const string RawMaterialCardTemplateCode = "MES_RAW_MATERIAL_CARD";
private readonly IRawMaterialCardService _rawMaterialCardService;
private readonly IPrintDotService _printDotService;
private readonly IPrintBizTemplateBindService _printBizTemplateBindService;
private readonly IPrintTemplateService _printTemplateService;
private static readonly JsonSerializerOptions LayoutJsonOpts = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private static string LayoutFilePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"YY.Admin",
"raw-material-entry-add-layout.json");
private bool _suppressTodaySelectionReaction;
public ObservableCollection<MesXslRawMaterialEntry> TodayEntries { get; } = new();
public IReadOnlyList<string> DateRangeOptions { get; } =
["今日", "过去24小时", "过去48小时", "过去72小时"];
private string _selectedDateRange = "今日";
public string SelectedDateRange
{
get => _selectedDateRange;
set
{
if (SetProperty(ref _selectedDateRange, value))
_ = LoadTodayEntriesAsync();
}
}
private MesXslRawMaterialEntry? _selectedTodayEntry;
public MesXslRawMaterialEntry? SelectedTodayEntry
{
get => _selectedTodayEntry;
set
{
if (!SetProperty(ref _selectedTodayEntry, value)) return;
if (_suppressTodaySelectionReaction || value == null) return;
_ = ApplyTodayRowToFormAsync(value);
}
}
private bool _isRightPanelExpanded = true;
public bool IsRightPanelExpanded
{
get => _isRightPanelExpanded;
set
{
if (!SetProperty(ref _isRightPanelExpanded, value)) return;
SaveLayoutState();
}
}
private double _expandedRightPanelWidth = 280;
/// <summary>右侧面板展开时的目标宽度(像素),由 GridSplitter 拖拽结束时回写。</summary>
public double ExpandedRightPanelWidth
{
get => _expandedRightPanelWidth;
private set
{
var v = Math.Clamp(value, 200, 560);
if (SetProperty(ref _expandedRightPanelWidth, v))
SaveLayoutState();
}
}
/// <summary>
/// 「生成原材料卡片」按钮可用条件:
/// 入场记录已保存(有 Id且至少存在一行「未生成卡片 + 份数>0」的明细。
/// 已打印态下用户「继续拆码」新增了行,按钮自动重新可用;全部行都已生成卡片时不可用。
/// </summary>
public bool CanGenerateCards =>
!string.IsNullOrWhiteSpace(Entry?.Id)
&& SplitCodeDetails.Any(d => !d.HasCard && (d.Portions ?? 0) > 0);
public DelegateCommand ToggleRightPanelCommand { get; }
public DelegateCommand RefreshTodayEntriesCommand { get; }
public DelegateCommand GenerateRawMaterialCardsCommand { get; }
/// <summary>「重新拆码」:清除已生成的卡片 + 清空明细,仅在编辑态可用。</summary>
public DelegateCommand ResplitCommand { get; }
public DelegateCommand SaveAndPrintCommand { get; }
public DelegateCommand RefreshPrintersCommand { get; }
public DelegateCommand ZoomOutPrintPreviewCommand { get; }
public DelegateCommand ZoomInPrintPreviewCommand { get; }
public DelegateCommand ResetPrintPreviewZoomCommand { get; }
/// <summary>PrintDot 桥接器返回的打印机列表(与打印模板页一致)。</summary>
public ObservableCollection<PrintDotPrinter> Printers { get; } = new();
private bool _suppressPrinterSave;
private PrintDotPrinter? _selectedPrinter;
public PrintDotPrinter? SelectedPrinter
{
get => _selectedPrinter;
set
{
if (!SetProperty(ref _selectedPrinter, value)) return;
if (_suppressPrinterSave) return;
var s = PrintDotSettings.Load();
s.SelectedPrinter = value?.Name ?? string.Empty;
s.Save();
}
}
private string _printerStatus = string.Empty;
public string PrinterStatus
{
get => _printerStatus;
set => SetProperty(ref _printerStatus, value);
}
private static readonly JsonSerializerOptions PreviewSnapshotJsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly DispatcherTimer _printPreviewTimer;
private string _lastPreviewSnapshot = string.Empty;
private int _previewRequestGeneration;
private bool _printPreviewBusy;
private bool _previewTemplateLoaded;
private string _previewTemplateJson = string.Empty;
private string _previewTemplateName = "原料入场记录";
private string _previewTemplateCode = RawMaterialEntryTemplateCode;
private string _previewFieldMappingJson = "[]";
private bool _rawMaterialCardTemplateLoaded;
private string _rawMaterialCardTemplateJson = string.Empty;
private string _rawMaterialCardTemplateName = "原材料卡片";
private string _rawMaterialCardTemplateCode = RawMaterialCardTemplateCode;
private string _rawMaterialCardFieldMappingJson = "[]";
private bool _isPrintPreviewExpanded = true;
/// <summary>右侧下方「入场标签打印预览」折叠面板是否展开。</summary>
public bool IsPrintPreviewExpanded
{
get => _isPrintPreviewExpanded;
set
{
if (!SetProperty(ref _isPrintPreviewExpanded, value)) return;
if (value) _lastPreviewSnapshot = string.Empty;
RaisePropertyChanged(nameof(IsPrintPreviewWebAreaVisible));
}
}
/// <summary>右侧下方预览区是否显示 WebView2 宿主HandyControl 内嵌弹窗期间因 Airspace 临时隐藏)。</summary>
public bool IsPrintPreviewWebAreaVisible => !SuspendEmbeddedPrintPreviewAirspace && IsPrintPreviewExpanded;
protected override void OnSuspendEmbeddedPrintPreviewAirspaceChanged()
{
RaisePropertyChanged(nameof(IsPrintPreviewWebAreaVisible));
}
private string _printPreviewStatus = string.Empty;
/// <summary>预览区状态提示(如离线、加载中、错误摘要)。</summary>
public string PrintPreviewStatus
{
get => _printPreviewStatus;
private set => SetProperty(ref _printPreviewStatus, value);
}
private const double PrintPreviewMinZoom = 0.5d;
private const double PrintPreviewMaxZoom = 2.0d;
private const double PrintPreviewDefaultZoom = 0.7d;
private const double PrintPreviewZoomStep = 0.1d;
private double _printPreviewZoomFactor = PrintPreviewDefaultZoom;
/// <summary>打印预览缩放倍率WebView2 ZoomFactor。</summary>
public double PrintPreviewZoomFactor
{
get => _printPreviewZoomFactor;
set
{
var clamped = Math.Round(Math.Clamp(value, PrintPreviewMinZoom, PrintPreviewMaxZoom), 2);
if (!SetProperty(ref _printPreviewZoomFactor, clamped)) return;
RaisePropertyChanged(nameof(PrintPreviewZoomText));
}
}
/// <summary>打印预览缩放显示文本(百分比)。</summary>
public string PrintPreviewZoomText => $"{Math.Round(PrintPreviewZoomFactor * 100):0}%";
/// <summary>由 View 订阅,在 UI 线程将 HTML 交给 WebView2。</summary>
public event EventHandler<string>? PrintPreviewHtmlReady;
private bool _isActionBusy;
/// <summary>底部动作按钮(保存/保存并打印/生成卡片)是否处于执行中。</summary>
public bool IsActionBusy
{
get => _isActionBusy;
private set
{
if (SetProperty(ref _isActionBusy, value))
{
RaisePropertyChanged(nameof(IsNotActionBusy));
}
}
}
public bool IsNotActionBusy => !IsActionBusy;
private string _actionBusyText = "处理中...";
/// <summary>动作执行中的遮罩提示文案。</summary>
public string ActionBusyText
{
get => _actionBusyText;
private set => SetProperty(ref _actionBusyText, value);
}
public RawMaterialEntryOperationViewModel(
IRawMaterialEntryService entryService,
IJeecgDictSyncService dictSyncService,
IMixerMaterialService mixerMaterialService,
IRawMaterialCardService rawMaterialCardService,
IPrintDotService printDotService,
IPrintBizTemplateBindService printBizTemplateBindService,
IPrintTemplateService printTemplateService,
IContainerExtension container,
IRegionManager regionManager)
: base(entryService, dictSyncService, mixerMaterialService, container, regionManager)
{
_rawMaterialCardService = rawMaterialCardService;
_printDotService = printDotService;
_printBizTemplateBindService = printBizTemplateBindService;
_printTemplateService = printTemplateService;
LoadLayoutState();
ToggleRightPanelCommand = new DelegateCommand(() => IsRightPanelExpanded = !IsRightPanelExpanded);
RefreshTodayEntriesCommand = new DelegateCommand(async () => await LoadTodayEntriesAsync());
GenerateRawMaterialCardsCommand = new DelegateCommand(async () => await GenerateRawMaterialCardsAsync());
ResplitCommand = new DelegateCommand(async () => await ResplitAsync(), () => CanResplit)
.ObservesProperty(() => Entry);
SaveAndPrintCommand = new DelegateCommand(async () => await SaveAndPrintAsync());
RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true));
ZoomOutPrintPreviewCommand = new DelegateCommand(() => PrintPreviewZoomFactor -= PrintPreviewZoomStep);
ZoomInPrintPreviewCommand = new DelegateCommand(() => PrintPreviewZoomFactor += PrintPreviewZoomStep);
ResetPrintPreviewZoomCommand = new DelegateCommand(() => PrintPreviewZoomFactor = PrintPreviewDefaultZoom);
// 集合变化:批量重订阅 item.PropertyChanged 监听 HasCard/Portions并同步刷新两个 Can*。
SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChangedForCanFlags;
_ = RefreshPrintersAsync(verbose: false);
var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
_printPreviewTimer = new DispatcherTimer(
TimeSpan.FromMilliseconds(480),
DispatcherPriority.Background,
(_, _) => _ = OnPrintPreviewTickAsync(),
dispatcher);
_printPreviewTimer.Start();
}
/// <summary>
/// 「重新拆码」按钮可用条件:入场记录已保存 + 至少有一行已生成卡片。
/// 全是新增未生成的行时,用户直接点「删除」即可,无需走「清空卡片」流程。
/// </summary>
public bool CanResplit =>
!string.IsNullOrWhiteSpace(Entry?.Id)
&& SplitCodeDetails.Any(d => d.HasCard);
/// <summary>
/// 集合变化时:对新增/移除的 item 维护 PropertyChanged 订阅,并刷新两个 Can* 属性。
/// HasCard / Portions 变化会让「生成原材料卡片」和「重新拆码」按钮可用性同步刷新。
/// </summary>
private void OnSplitCodeDetailsCollectionChangedForCanFlags(
object? sender,
System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.OldItems != null)
{
foreach (RawMaterialSplitDetailItem o in e.OldItems)
o.PropertyChanged -= OnSplitDetailHasCardChanged;
}
if (e.NewItems != null)
{
foreach (RawMaterialSplitDetailItem n in e.NewItems)
n.PropertyChanged += OnSplitDetailHasCardChanged;
}
RaisePropertyChanged(nameof(CanGenerateCards));
RaisePropertyChanged(nameof(CanResplit));
ResplitCommand.RaiseCanExecuteChanged();
}
private void OnSplitDetailHasCardChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(RawMaterialSplitDetailItem.HasCard)
or nameof(RawMaterialSplitDetailItem.Portions))
{
RaisePropertyChanged(nameof(CanGenerateCards));
RaisePropertyChanged(nameof(CanResplit));
ResplitCommand.RaiseCanExecuteChanged();
}
}
public override void InitializeForAdd()
{
_lastPreviewSnapshot = string.Empty;
_suppressTodaySelectionReaction = true;
_selectedTodayEntry = null;
RaisePropertyChanged(nameof(SelectedTodayEntry));
_suppressTodaySelectionReaction = false;
base.InitializeForAdd();
RaisePropertyChanged(nameof(CanGenerateCards));
}
/// <summary>保存后按业务打印绑定拉取模板与 printData直接发送到 PrintDot 打印机(不弹预览窗)。</summary>
private async Task SaveAndPrintAsync()
{
if (IsActionBusy) return;
if (Entry == null) return;
var wasEdit = !IsAddMode;
var barcode = Entry.Barcode;
BeginActionBusy("保存并打印中...");
try
{
IsLoading = true;
if (!await PersistEntryCoreAsync()) return;
string? entryId = Entry.Id;
if (string.IsNullOrWhiteSpace(entryId) && !string.IsNullOrWhiteSpace(barcode))
{
var page = await EntryService.PageAsync(1, 50, barcode: barcode);
entryId = page.Records
.FirstOrDefault(e => string.Equals(e.Barcode, barcode, StringComparison.OrdinalIgnoreCase))?.Id
?? page.Records
.OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue)
.FirstOrDefault()?.Id;
}
if (string.IsNullOrWhiteSpace(entryId))
{
HandyControl.Controls.MessageBox.Warning("保存成功,但未能解析记录主键,无法打印。请从右侧列表选中该条后再试。");
}
else
{
var (templateJson, printDataJson, err) = await EntryService.PrepareNativePrintAsync(entryId);
if (!string.IsNullOrWhiteSpace(err))
{
HandyControl.Controls.MessageBox.Error($"保存成功,但打印准备失败:{err}");
}
else
{
var selectedPrinterName = SelectedPrinter?.Name?.Trim();
if (string.IsNullOrWhiteSpace(selectedPrinterName))
{
HandyControl.Controls.MessageBox.Warning("保存成功,但未选择打印机。请先选择打印机后再试。");
}
else
{
JsonObject? dataObj = JsonNode.Parse(printDataJson) as JsonObject;
dataObj ??= new JsonObject();
var html = NativePrintRenderService.RenderToHtml(templateJson, dataObj);
var tpl = BuildPrintTemplateForEntry(templateJson);
var pdfBase64 = await HtmlToPdfRenderer.RenderAsync(
html,
tpl.PaperWidthMm ?? 210d,
tpl.PaperHeightMm ?? 297d);
var jobName = string.IsNullOrWhiteSpace(barcode) ? "原料入场记录" : $"原料入场记录-{barcode}";
await _printDotService.PrintAsync(selectedPrinterName, pdfBase64, jobName, 1);
}
}
}
Growl.Success(new GrowlInfo
{
Message = "保存成功",
ShowDateTime = false,
WaitTime = 1
});
Result = true;
RaisePropertyChanged(nameof(CanGenerateCards));
await LoadTodayEntriesAsync();
InitializeForAdd();
}
catch (Exception ex)
{
HandyControl.Controls.MessageBox.Error($"保存或打印失败:{ex.Message}");
}
finally
{
IsLoading = false;
EndActionBusy();
}
}
/// <summary>通过 PrintDot 桥接器拉取打印机列表(与打印模板页一致)。</summary>
private async Task RefreshPrintersAsync(bool verbose)
{
if (verbose) PrinterStatus = "刷新打印机中...";
var savedName = PrintDotSettings.Load().SelectedPrinter;
try
{
var list = await _printDotService.GetPrintersAsync();
Application.Current.Dispatcher.Invoke(() =>
{
Printers.Clear();
foreach (var p in list) Printers.Add(p);
var match = list.FirstOrDefault(p => p.Name == savedName)
?? list.FirstOrDefault(p => p.IsDefault)
?? (list.Count > 0 ? list[0] : null);
_suppressPrinterSave = true;
SelectedPrinter = match;
_suppressPrinterSave = false;
PrinterStatus = list.Count > 0 ? $"共 {list.Count} 台打印机" : "未检测到打印机";
});
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
{
Printers.Clear();
_suppressPrinterSave = true;
SelectedPrinter = null;
_suppressPrinterSave = false;
PrinterStatus = verbose
? $"PrintDot 未连接:{ex.Message}"
: "PrintDot 未连接";
});
}
}
private static PrintTemplate BuildPrintTemplateForEntry(string? templateJson)
{
try
{
if (string.IsNullOrWhiteSpace(templateJson) || templateJson == "{}")
return new PrintTemplate { TemplateName = "原料入场记录", TemplateCode = "MES_RAW_MATERIAL_ENTRY" };
var root = JsonDocument.Parse(templateJson).RootElement;
double w = 210, h = 297;
if (root.TryGetProperty("page", out var page))
{
if (page.TryGetProperty("width", out var wEl)) w = wEl.GetDouble();
if (page.TryGetProperty("height", out var hEl)) h = hEl.GetDouble();
}
return new PrintTemplate
{
TemplateName = "原料入场记录",
TemplateCode = "MES_RAW_MATERIAL_ENTRY",
PaperWidthMm = w,
PaperHeightMm = h,
PaperOrientation = w > h ? "横向" : "纵向",
};
}
catch
{
return new PrintTemplate { TemplateName = "原料入场记录", TemplateCode = "MES_RAW_MATERIAL_ENTRY" };
}
}
/// <summary>页面首次加载时拉取「今日」列表(由 View Loaded 调用)。</summary>
public async Task LoadTodayEntriesOnFirstShowAsync() => await LoadTodayEntriesAsync();
/// <summary>用户在拖拽结束 GridSplitter 后提交实际列宽。</summary>
public void CommitRightPanelWidthFromView(double actualWidth)
{
if (!IsRightPanelExpanded || actualWidth < 1) return;
ExpandedRightPanelWidth = actualWidth;
}
private async Task LoadTodayEntriesAsync()
{
try
{
IsLoading = true;
var result = await EntryService.PageAsync(1, TodayListFetchSize);
DateTime? threshold = _selectedDateRange switch
{
"过去24小时" => DateTime.Now.AddHours(-24),
"过去48小时" => DateTime.Now.AddHours(-48),
"过去72小时" => DateTime.Now.AddHours(-72),
_ => null // "今日":按自然日过滤
};
var rows = result.Records
.Where(e => IsInRange(e, threshold))
.OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue)
.ToList();
TodayEntries.Clear();
foreach (var r in rows)
TodayEntries.Add(r);
}
catch
{
// 列表失败不阻断左侧新增(与物料加载策略一致)
}
finally
{
IsLoading = false;
}
}
private static bool IsInRange(MesXslRawMaterialEntry e, DateTime? threshold)
{
if (threshold == null)
{
var today = DateTime.Today;
return (e.EntryTime?.Date == today) || (e.CreateTime?.Date == today);
}
return (e.EntryTime >= threshold) || (e.CreateTime >= threshold);
}
/// <summary>
/// 点击右侧今日记录:直接以「编辑模式」加载源记录到左侧表单(保留 Id/条码/批次号),
/// 不再走「新增模板」逻辑,避免重新生成条码、覆盖原数据。
/// </summary>
private async Task ApplyTodayRowToFormAsync(MesXslRawMaterialEntry src)
{
// 复用基类 InitializeForEdit保留 Id/Barcode/BatchNo/状态等所有字段,标题自动切到「编辑原料入场记录」
base.InitializeForEdit(src);
RaisePropertyChanged(nameof(CanGenerateCards));
// 若物料列表此前未加载导致选中项未回填,则补一次拉取(与编辑弹窗逻辑一致)
if (_pendingMaterialId != null)
await LoadMaterialOptionsAsync();
_lastPreviewSnapshot = string.Empty;
}
/// <summary>
/// 保存成功后:无论新增还是编辑,都立即刷新右侧「今日入场」列表。
/// 编辑成功额外把表单切回「新增态」,避免用户连续误改同一条;新增成功后由基类负责清空表单。
/// </summary>
protected override async Task SaveAsync()
{
if (IsActionBusy) return;
BeginActionBusy("保存中...");
var wasEdit = !IsAddMode;
try
{
await base.SaveAsync();
RaisePropertyChanged(nameof(CanGenerateCards));
if (!Result || CloseAction != null)
{
return;
}
await LoadTodayEntriesAsync();
if (wasEdit)
{
Growl.Success(new GrowlInfo
{
Message = "保存成功",
ShowDateTime = false,
WaitTime = 1
});
InitializeForAdd();
}
}
finally
{
EndActionBusy();
}
}
/// <summary>
/// 「重新拆码」:弹窗预提示「将清除 N 张原材料卡片」→ 确认 → 后端按 splitDetailId IN 批删
/// → 清空 SplitCodeDetails → Entry.PrintFlag=0 → EditAsync(Entry) 持久化新状态 → 刷新今日列表。
/// 流程对应「清空卡片重新生成」的业务诉求;用户可立即重新维护明细并再次「生成原材料卡片」。
/// </summary>
private async Task ResplitAsync()
{
if (Entry == null || string.IsNullOrWhiteSpace(Entry.Id))
{
HandyControl.Controls.MessageBox.Warning("请先选中一条已保存的入场记录后再「重新拆码」。");
return;
}
var detailIds = SplitCodeDetails
.Select(d => d.Id)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Distinct()
.ToList();
try
{
IsLoading = true;
// dryRun 预查:统计当前明细行已生成的卡片数量
var cardCount = detailIds.Count > 0
? await _rawMaterialCardService.DeleteBySplitDetailIdsAsync(detailIds, dryRun: true)
: 0;
if (cardCount < 0)
{
HandyControl.Controls.MessageBox.Error("无法连接服务器,重新拆码已取消。");
return;
}
var confirm = HandyControl.Controls.MessageBox.Show(
$"当前入场记录已生成 {cardCount} 条原材料卡片,是否清空卡片重新生成?",
"重新拆码",
System.Windows.MessageBoxButton.OKCancel,
System.Windows.MessageBoxImage.Question);
if (confirm != System.Windows.MessageBoxResult.OK)
{
return;
}
// 真正执行批删(即使 cardCount=0 也调用一次,结果幂等)
if (cardCount > 0)
{
var deleted = await _rawMaterialCardService.DeleteBySplitDetailIdsAsync(detailIds, dryRun: false);
if (deleted < 0)
{
HandyControl.Controls.MessageBox.Error("清除已生成的原材料卡片失败,重新拆码已中止。");
return;
}
}
// 清空明细 + 重置打印标记,并把变更持久化到后端入场记录
SplitCodeDetails.Clear();
Entry.PrintFlag = "0";
ApplySplitDetailsToEntry(); // 同步把 PortionDetailIds 等清空到 Entry 上
var ok = await EntryService.EditAsync(Entry);
if (!ok)
{
HandyControl.Controls.MessageBox.Error("保存重新拆码后的入场记录失败,请刷新后重试。");
return;
}
RaisePropertyChanged(nameof(Entry));
RaisePropertyChanged(nameof(IsPrinted));
RaisePropertyChanged(nameof(IsTotalWeightEditable));
AddSplitDetailCommand.RaiseCanExecuteChanged();
RaisePropertyChanged(nameof(CanGenerateCards));
RaisePropertyChanged(nameof(CanResplit));
HandyControl.Controls.MessageBox.Success($"已清除 {cardCount} 条原材料卡片,请重新维护拆码明细。");
await LoadTodayEntriesAsync();
}
catch (Exception ex)
{
HandyControl.Controls.MessageBox.Error($"重新拆码失败:{ex.Message}");
}
finally
{
IsLoading = false;
}
}
/// <summary>
/// 「生成原材料卡片」:仅处理 HasCard==false 的明细行(「继续拆码」流程的核心)。
/// 条码续号 = 已有卡片总数(Σ HasCard==true 行的 Portions+ 1避免与已生成卡片的条码冲突。
/// 成功生成的行置 HasCard=trueEntry.PrintFlag=1整票级标记
/// </summary>
private async Task GenerateRawMaterialCardsAsync()
{
if (IsActionBusy) return;
if (Entry == null || string.IsNullOrWhiteSpace(Entry.Id))
{
HandyControl.Controls.MessageBox.Warning("请先保存入场记录再生成原材料卡片!");
return;
}
// 只处理未生成卡片的行 + 份数>0已生成行直接跳过避免重复加卡和条码冲突
var pendingRows = SplitCodeDetails
.Where(d => !d.HasCard && (d.Portions ?? 0) > 0)
.ToList();
if (pendingRows.Count == 0)
{
HandyControl.Controls.MessageBox.Warning("当前没有待生成卡片的拆码明细(已生成的行不会重复处理)。请先「新增明细」再生成。");
return;
}
if (!await EnsureEntryBarcodeAndBatchNoAsync())
{
return;
}
// 库位必填仅校验本批待生成行;行号取在原集合的真实索引,避免编号偏移误导
foreach (var row in pendingRows)
{
if (string.IsNullOrWhiteSpace(row.WarehouseLocation))
{
var realIndex = SplitCodeDetails.IndexOf(row);
HandyControl.Controls.MessageBox.Warning($"拆码明细第 {realIndex + 1} 行的「库位」未填写,请点击该行库位选择库区后再生成原材料卡片。");
return;
}
}
// 总重核对:拆码明细合计 vs 基础资料总重
{
var splitTotal = SplitCodeDetails.Sum(d => (d.PortionWeight ?? 0d) * (d.Portions ?? 0));
var basicTotal = Entry.TotalWeight ?? 0d;
if (Math.Abs(splitTotal - basicTotal) > 0.01d)
{
string hint;
if (splitTotal > basicTotal)
hint = $"本次打印拆码总重 {splitTotal:0.##},超出基础资料总重 {basicTotal:0.##},超出部分是否需要核对?";
else
hint = $"本次打印拆码总重 {splitTotal:0.##},少于基础资料总重 {basicTotal:0.##},剩余部分将无法继续拆码,是否继续?";
var confirm = System.Windows.MessageBox.Show(
hint,
"总重核对",
System.Windows.MessageBoxButton.OKCancel,
System.Windows.MessageBoxImage.Warning);
if (confirm != System.Windows.MessageBoxResult.OK) return;
}
}
// 续号起点:已有卡片数 = Σ HasCard==true 行的 Portions新增卡片从其后续编
var alreadyGenerated = SplitCodeDetails
.Where(d => d.HasCard)
.Sum(d => d.Portions ?? 0);
var plannedCards = BuildPlannedRawMaterialCards(Entry, pendingRows, alreadyGenerated + 1);
if (plannedCards.Count == 0)
{
HandyControl.Controls.MessageBox.Warning("未生成有效的原材料卡片预览数据,请检查拆码明细后重试。");
return;
}
if (!await EnsureRawMaterialCardTemplateLoadedAsync())
{
HandyControl.Controls.MessageBox.Warning("未找到原材料卡片打印模板,请先同步“业务打印绑定/打印模板”后再试。");
return;
}
var confirmWindow = new RawMaterialCardGenerateConfirmWindow(
plannedCards,
_rawMaterialCardTemplateName,
_rawMaterialCardTemplateCode,
_printDotService,
row =>
{
var matched = plannedCards.FirstOrDefault(p =>
string.Equals(p.DetailId, row.DetailId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(p.Card.Barcode, row.Card.Barcode, StringComparison.OrdinalIgnoreCase));
var target = matched ?? plannedCards[Math.Clamp(row.Index - 1, 0, plannedCards.Count - 1)];
return BuildRawMaterialCardPreviewHtml(target);
})
{
Owner = Application.Current.MainWindow,
WindowStartupLocation = WindowStartupLocation.CenterOwner
};
// WebView2 Airspace模态子窗体仍可能与本窗体内 HWND 叠层异常,弹窗期间收起嵌入预览
var confirmed = await SuspendEmbeddedPrintPreviewAirspaceWhileAsync(() =>
Task.FromResult(confirmWindow.ShowDialog() == true));
if (!confirmed) return;
var selectedPrinterName = confirmWindow.SelectedPrinterName;
if (string.IsNullOrWhiteSpace(selectedPrinterName))
{
HandyControl.Controls.MessageBox.Warning("未选择打印机,已取消打印。");
return;
}
BeginActionBusy("生成中...");
try
{
IsLoading = true;
var globalIndex = alreadyGenerated + 1;
var baseBarcode = Entry.Barcode ?? "";
var failCount = 0;
var newCardCount = 0;
var generatedCards = new List<MesXslRawMaterialCard>();
// 按行收集成功标记:只要该行所有份都加卡成功,行 HasCard=true
// 中途任一份失败则保留 HasCard=false下次再点「生成」时会重试该行
var rowSuccessMap = new Dictionary<RawMaterialSplitDetailItem, bool>();
foreach (var detail in pendingRows)
{
var portions = detail.Portions ?? 0;
var rowAllOk = true;
for (var i = 0; i < portions; i++)
{
var card = new MesXslRawMaterialCard
{
// 关联到当前拆码明细行 — 便于「重新拆码」按此 ID 批删
SplitDetailId = detail.Id,
Barcode = baseBarcode + globalIndex.ToString("D3"),
BatchNo = Entry.BatchNo,
EntryDate = Entry.EntryTime?.Date ?? DateTime.Today,
MaterialId = Entry.MaterialId,
MaterialName = Entry.MaterialName,
SupplierId = Entry.SupplierId,
SupplierName = Entry.SupplierName,
ManufacturerMaterialName = Entry.ManufacturerMaterialName,
ShelfLife = Entry.ShelfLife,
TotalWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null,
RemainingWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null,
RemainingQuantity = detail.PortionPackages,
WarehouseArea = detail.WarehouseLocation,
UnloadOperator = Entry.UnloadOperator,
Status = "1",
TestResult = "0",
PriorityPickup = "0",
TenantId = Entry.TenantId
};
var ok = await _rawMaterialCardService.AddAsync(card);
if (ok)
{
newCardCount++;
generatedCards.Add(card);
}
else { failCount++; rowAllOk = false; }
globalIndex++;
}
rowSuccessMap[detail] = rowAllOk;
}
// 把整行加卡都成功的标记 HasCard=true触发该行 UI 自动锁定 + 删除按钮隐藏 + 打印列显示「已打印」)
foreach (var kv in rowSuccessMap)
{
if (kv.Value) kv.Key.HasCard = true;
}
// 整票级 PrintFlag只要本批至少有一张卡片成功即置 1与原行为兼容
// 关键:必须先 ApplySplitDetailsToEntry(),把刚被设为 true 的 HasCard 序列化到 PortionCardFlags
// 否则下次重新打开时该行会回退为 falsePortionCardFlags 旧值未更新),从而被「继续拆码」流程误纳入待生成。
if (newCardCount > 0)
{
Entry.PrintFlag = "1";
ApplySplitDetailsToEntry();
await EntryService.EditAsync(Entry);
RaisePropertyChanged(nameof(Entry));
RaisePropertyChanged(nameof(IsPrinted));
RaisePropertyChanged(nameof(IsTotalWeightEditable));
AddSplitDetailCommand.RaiseCanExecuteChanged();
}
RaisePropertyChanged(nameof(CanGenerateCards));
RaisePropertyChanged(nameof(CanResplit));
if (newCardCount > 0 && generatedCards.Count > 0)
{
var printError = await PrintGeneratedRawMaterialCardsAsync(selectedPrinterName, generatedCards);
if (!string.IsNullOrWhiteSpace(printError))
{
HandyControl.Controls.MessageBox.Warning($"卡片已生成 {newCardCount} 张,但打印存在异常:{printError}");
}
}
if (failCount == 0)
HandyControl.Controls.MessageBox.Success($"已生成并打印 {newCardCount} 张原材料卡片(累计 {alreadyGenerated + newCardCount} 张),打印状态已更新为「已打印」!");
else
HandyControl.Controls.MessageBox.Warning($"本次共尝试生成 {newCardCount + failCount} 张,成功 {newCardCount} 张,失败 {failCount} 张。失败的行未标记为「已打印」,可检查网络后再次点击「生成原材料卡片」重试。");
}
catch (Exception ex)
{
HandyControl.Controls.MessageBox.Error($"生成原材料卡片失败:{ex.Message}");
}
finally
{
IsLoading = false;
EndActionBusy();
}
}
/// <summary>
/// 生成原材料卡片前确保入场记录已有条码/批次号。
/// 规则:条码优先沿用现有值;为空时调用入场服务生成(在线优先,离线本地兜底);
/// 批次号为空则回填为条码。
/// </summary>
private async Task<bool> EnsureEntryBarcodeAndBatchNoAsync()
{
if (Entry == null) return false;
if (string.IsNullOrWhiteSpace(Entry.Barcode))
{
if (string.IsNullOrWhiteSpace(Entry.MaterialCode))
{
HandyControl.Controls.MessageBox.Warning("当前记录缺少物料编码,无法生成原材料卡片条码。");
return false;
}
var code = await EntryService.GenerateBarcodeAsync(Entry.MaterialCode);
if (string.IsNullOrWhiteSpace(code))
{
HandyControl.Controls.MessageBox.Warning("未能生成入场条码,请检查网络或稍后重试。");
return false;
}
Entry.Barcode = code.Trim();
}
if (string.IsNullOrWhiteSpace(Entry.BatchNo))
{
Entry.BatchNo = Entry.Barcode;
}
RaisePropertyChanged(nameof(Entry));
return true;
}
private List<RawMaterialCardGeneratePlanItem> BuildPlannedRawMaterialCards(
MesXslRawMaterialEntry entry,
IReadOnlyList<RawMaterialSplitDetailItem> pendingRows,
int startIndex)
{
var list = new List<RawMaterialCardGeneratePlanItem>();
var cursor = startIndex;
var baseBarcode = entry.Barcode ?? string.Empty;
foreach (var detail in pendingRows)
{
var portions = detail.Portions ?? 0;
for (var i = 0; i < portions; i++)
{
var card = BuildRawMaterialCardFromDetail(entry, detail, baseBarcode + cursor.ToString("D3"));
list.Add(new RawMaterialCardGeneratePlanItem
{
Card = card,
DetailId = detail.Id,
SourceRowNo = SplitCodeDetails.IndexOf(detail) + 1
});
cursor++;
}
}
return list;
}
private static MesXslRawMaterialCard BuildRawMaterialCardFromDetail(
MesXslRawMaterialEntry entry,
RawMaterialSplitDetailItem detail,
string barcode)
{
return new MesXslRawMaterialCard
{
SplitDetailId = detail.Id,
Barcode = barcode,
BatchNo = entry.BatchNo,
EntryDate = entry.EntryTime?.Date ?? DateTime.Today,
MaterialId = entry.MaterialId,
MaterialName = entry.MaterialName,
SupplierId = entry.SupplierId,
SupplierName = entry.SupplierName,
ManufacturerMaterialName = entry.ManufacturerMaterialName,
ShelfLife = entry.ShelfLife,
TotalWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null,
RemainingWeight = detail.PortionWeight.HasValue ? (decimal?)detail.PortionWeight.Value : null,
RemainingQuantity = detail.PortionPackages,
WarehouseArea = detail.WarehouseLocation,
UnloadOperator = entry.UnloadOperator,
Status = "1",
TestResult = "0",
PriorityPickup = "0",
TenantId = entry.TenantId
};
}
private async Task<bool> EnsureRawMaterialCardTemplateLoadedAsync()
{
if (_rawMaterialCardTemplateLoaded && !string.IsNullOrWhiteSpace(_rawMaterialCardTemplateJson))
{
return true;
}
var bindList = _printBizTemplateBindService.GetCached();
if (bindList.Count == 0)
{
try { bindList = await _printBizTemplateBindService.ListAsync().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 false;
_rawMaterialCardFieldMappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!;
_rawMaterialCardTemplateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialCardTemplateCode : bind.TemplateCode!;
var templates = _printTemplateService.GetCached();
if (templates.Count == 0)
{
try { templates = await _printTemplateService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ }
}
var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase))
?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, _rawMaterialCardTemplateCode, StringComparison.OrdinalIgnoreCase));
if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson)) return false;
_rawMaterialCardTemplateJson = tpl.TemplateJson!;
_rawMaterialCardTemplateName = string.IsNullOrWhiteSpace(tpl.TemplateName) ? "原材料卡片" : tpl.TemplateName!;
_rawMaterialCardTemplateLoaded = true;
return true;
}
private string BuildRawMaterialCardPreviewHtml(RawMaterialCardGeneratePlanItem planItem)
{
try
{
var data = BuildRawMaterialCardPrintData(planItem.Card);
return NativePrintRenderService.RenderToHtml(_rawMaterialCardTemplateJson, data, enableScreenAutoFit: false);
}
catch (Exception ex)
{
return BuildPrintPreviewErrorHtml($"模板预览失败:{ex.Message}");
}
}
private JsonObject BuildRawMaterialCardPrintData(MesXslRawMaterialCard card)
{
JsonObject printData = new();
JsonNode? bizRoot = JsonSerializer.SerializeToNode(card, PreviewSnapshotJsonOpts);
try
{
var mappingNode = JsonNode.Parse(string.IsNullOrWhiteSpace(_rawMaterialCardFieldMappingJson) ? "[]" : _rawMaterialCardFieldMappingJson) 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? val = string.IsNullOrWhiteSpace(bizField)
? JsonValue.Create(string.Empty)
: ResolvePath(bizRoot, bizField!);
var normalized = NormalizePreviewNodeValue(val);
SetPath(printData, templateField!, normalized);
}
}
}
catch
{
// 映射失败时降级为模板字段补空,不中断后续流程
}
try
{
var root = JsonNode.Parse(_rawMaterialCardTemplateJson);
var fields = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
CollectTemplateBindFields(root, fields);
foreach (var key in fields)
{
if (!HasPath(printData, key))
{
SetPath(printData, key, JsonValue.Create(string.Empty));
}
}
}
catch
{
// 忽略模板结构异常
}
return printData;
}
private async Task<string?> PrintGeneratedRawMaterialCardsAsync(string printerName, IReadOnlyList<MesXslRawMaterialCard> cards)
{
if (cards.Count == 0) return null;
if (string.IsNullOrWhiteSpace(printerName)) return "未选择打印机";
if (!await EnsureRawMaterialCardTemplateLoadedAsync()) return "未找到原材料卡片打印模板";
var tpl = BuildPrintTemplateForRawMaterialCard(_rawMaterialCardTemplateJson);
try
{
var idx = 0;
foreach (var card in cards)
{
idx++;
BeginActionBusy($"打印中({idx}/{cards.Count}...");
var data = BuildRawMaterialCardPrintData(card);
var html = NativePrintRenderService.RenderToHtml(_rawMaterialCardTemplateJson, data);
var pdfBase64 = await HtmlToPdfRenderer.RenderAsync(
html,
tpl.PaperWidthMm ?? 210d,
tpl.PaperHeightMm ?? 297d);
var jobName = string.IsNullOrWhiteSpace(card.Barcode) ? "原材料卡片" : $"原材料卡片-{card.Barcode}";
await _printDotService.PrintAsync(printerName, pdfBase64, jobName, 1);
}
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
private static PrintTemplate BuildPrintTemplateForRawMaterialCard(string? templateJson)
{
try
{
if (string.IsNullOrWhiteSpace(templateJson) || templateJson == "{}")
return new PrintTemplate { TemplateName = "原材料卡片", TemplateCode = RawMaterialCardTemplateCode };
var root = JsonDocument.Parse(templateJson).RootElement;
double w = 210, h = 297;
if (root.TryGetProperty("page", out var page))
{
if (page.TryGetProperty("width", out var wEl)) w = wEl.GetDouble();
if (page.TryGetProperty("height", out var hEl)) h = hEl.GetDouble();
}
return new PrintTemplate
{
TemplateName = "原材料卡片",
TemplateCode = RawMaterialCardTemplateCode,
PaperWidthMm = w,
PaperHeightMm = h,
PaperOrientation = w > h ? "横向" : "纵向",
};
}
catch
{
return new PrintTemplate { TemplateName = "原材料卡片", TemplateCode = RawMaterialCardTemplateCode };
}
}
private void BeginActionBusy(string text)
{
ActionBusyText = string.IsNullOrWhiteSpace(text) ? "处理中..." : text;
IsActionBusy = true;
}
private void EndActionBusy()
{
IsActionBusy = false;
ActionBusyText = "处理中...";
}
/// <summary>页面卸载时停止轮询,避免后台仍请求已释放的 WebView。</summary>
public void StopPrintPreviewTimer() => _printPreviewTimer.Stop();
/// <summary>视图重新附加时恢复轮询(与 <see cref="StopPrintPreviewTimer"/> 配对)。</summary>
public void StartPrintPreviewTimer()
{
if (!_printPreviewTimer.IsEnabled) _printPreviewTimer.Start();
}
private async Task OnPrintPreviewTickAsync()
{
if (!IsPrintPreviewExpanded || Entry == null || _printPreviewBusy) return;
try
{
ApplySplitDetailsToEntry();
var snap = JsonSerializer.Serialize(Entry, PreviewSnapshotJsonOpts);
if (string.Equals(snap, _lastPreviewSnapshot, StringComparison.Ordinal)) return;
_lastPreviewSnapshot = snap;
_printPreviewBusy = true;
PrintPreviewStatus = "更新预览中…";
var myGen = ++_previewRequestGeneration;
if (!await EnsureLocalPreviewTemplateLoadedAsync().ConfigureAwait(false))
{
PostPrintPreviewHtml(BuildPrintPreviewPlaceholderHtml("未找到本地打印模板缓存,请先联网完成一次“业务打印绑定/打印模板”同步。"));
PrintPreviewStatus = "未找到本地模板缓存";
return;
}
if (string.IsNullOrWhiteSpace(_previewTemplateJson) || _previewTemplateJson == "{}")
{
PostPrintPreviewHtml(BuildPrintPreviewPlaceholderHtml("尚未配置业务打印绑定或模板内容为空"));
PrintPreviewStatus = "未配置模板";
return;
}
var dataObj = BuildLocalPreviewData(Entry);
if (myGen != _previewRequestGeneration) return;
string html;
try
{
// 实时预览关闭模板内部 fitPage 自适应,避免与 WebView2 外层缩放叠加后出现“越放大越小”。
html = NativePrintRenderService.RenderToHtml(_previewTemplateJson, dataObj, enableScreenAutoFit: false);
}
catch (Exception ex)
{
html = BuildPrintPreviewErrorHtml(ex.Message);
PrintPreviewStatus = "渲染失败";
PostPrintPreviewHtml(html);
return;
}
PostPrintPreviewHtml(html);
PrintPreviewStatus = string.Empty;
}
catch (Exception ex)
{
PostPrintPreviewHtml(BuildPrintPreviewErrorHtml(ex.Message));
PrintPreviewStatus = "预览异常";
}
finally
{
_printPreviewBusy = false;
}
}
private void PostPrintPreviewHtml(string html)
{
var app = Application.Current;
if (app?.Dispatcher == null)
{
PrintPreviewHtmlReady?.Invoke(this, html);
return;
}
if (app.Dispatcher.CheckAccess()) PrintPreviewHtmlReady?.Invoke(this, html);
else app.Dispatcher.Invoke(() => PrintPreviewHtmlReady?.Invoke(this, html));
}
private static string BuildPrintPreviewErrorHtml(string message)
{
var esc = WebUtility.HtmlEncode(message ?? string.Empty);
return "<html><head><meta charset=\"utf-8\"/><style>"
+ "body{margin:0;background:#525659;display:flex;align-items:center;"
+ "justify-content:center;height:100vh;font-family:'Microsoft YaHei',Arial,sans-serif;}"
+ ".card{background:#fff;border-radius:8px;padding:28px 40px;text-align:center;"
+ "box-shadow:0 6px 24px rgba(0,0,0,.4);max-width:520px;}"
+ "</style></head><body><div class=\"card\">"
+ "<div style=\"font-size:36px;margin-bottom:10px\">⚠️</div>"
+ "<div style=\"font-size:13px;color:#e74c3c;word-break:break-all;\">预览:" + esc + "</div>"
+ "</div></body></html>";
}
private static string BuildPrintPreviewPlaceholderHtml(string tip)
{
var esc = WebUtility.HtmlEncode(tip ?? string.Empty);
return "<html><head><meta charset=\"utf-8\"/><style>"
+ "body{margin:0;background:#525659;display:flex;align-items:center;justify-content:center;"
+ "height:100vh;font-family:'Microsoft YaHei',Arial,sans-serif;}"
+ ".card{background:#fff;border-radius:8px;padding:32px 48px;text-align:center;}"
+ "</style></head><body><div class=\"card\">"
+ "<div style=\"font-size:14px;color:#888;\">" + esc + "</div></div></body></html>";
}
private async Task<bool> EnsureLocalPreviewTemplateLoadedAsync()
{
if (_previewTemplateLoaded && !string.IsNullOrWhiteSpace(_previewTemplateJson))
{
return true;
}
// 离线优先用本地缓存;若当前在线再尝试刷新缓存
var bindList = _printBizTemplateBindService.GetCached();
if (bindList.Count == 0)
{
try { bindList = await _printBizTemplateBindService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ }
}
var bind = bindList.FirstOrDefault(x => string.Equals(x.BizCode, RawMaterialEntryBizCode, StringComparison.OrdinalIgnoreCase))
?? bindList.FirstOrDefault(x => string.Equals(x.TemplateCode, RawMaterialEntryTemplateCode, StringComparison.OrdinalIgnoreCase))
?? bindList.FirstOrDefault(x =>
(x.BizName ?? string.Empty).Contains("原料入场记录", StringComparison.OrdinalIgnoreCase)
|| (x.BizName ?? string.Empty).Contains("原材料流转卡片", StringComparison.OrdinalIgnoreCase));
if (bind == null) return false;
_previewFieldMappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!;
_previewTemplateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialEntryTemplateCode : bind.TemplateCode!;
var templates = _printTemplateService.GetCached();
if (templates.Count == 0)
{
try { templates = await _printTemplateService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ }
}
var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase))
?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, _previewTemplateCode, StringComparison.OrdinalIgnoreCase));
if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson)) return false;
_previewTemplateJson = tpl.TemplateJson!;
_previewTemplateName = string.IsNullOrWhiteSpace(tpl.TemplateName) ? "原料入场记录" : tpl.TemplateName!;
_previewTemplateLoaded = true;
return true;
}
private JsonObject BuildLocalPreviewData(MesXslRawMaterialEntry entry)
{
JsonObject printData = new();
JsonNode? bizRoot = JsonSerializer.SerializeToNode(entry, PreviewSnapshotJsonOpts);
try
{
var mappingNode = JsonNode.Parse(string.IsNullOrWhiteSpace(_previewFieldMappingJson) ? "[]" : _previewFieldMappingJson) as JsonArray;
if (mappingNode != null)
{
foreach (var rule in mappingNode)
{
try
{
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? val = string.IsNullOrWhiteSpace(bizField)
? JsonValue.Create(string.Empty)
: ResolvePath(bizRoot, bizField!);
// JsonNode 不能同时挂在两个父节点,必须深拷贝后再写入 printData
var normalized = NormalizePreviewNodeValue(val);
SetPath(printData, templateField!, normalized);
}
catch
{
// 单条映射异常不应影响其余字段,继续后续规则
}
}
}
}
catch
{
// 忽略 mapping JSON 异常,继续尝试按模板字段补空
}
try
{
var root = JsonNode.Parse(_previewTemplateJson);
var fields = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
CollectTemplateBindFields(root, fields);
foreach (var key in fields)
{
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 paramsArr)
{
foreach (var p in paramsArr)
{
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 bool HasPath(JsonObject root, string path)
{
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
JsonNode? cur = root;
for (int i = 0; i < parts.Length; i++)
{
if (cur is not JsonObject obj) return false;
if (!obj.TryGetPropertyValue(parts[i], out cur)) return false;
if (i == parts.Length - 1) return true;
}
return false;
}
private static JsonNode? ResolvePath(JsonNode? root, string path)
{
if (root == null || string.IsNullOrWhiteSpace(path)) return null;
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
JsonNode? cur = root;
foreach (var p in parts)
{
if (cur == null) return null;
if (cur is JsonArray arr)
{
if (int.TryParse(p, out var idx))
{
cur = idx >= 0 && idx < arr.Count ? arr[idx] : null;
}
else
{
cur = arr.Count > 0 ? arr[0]?[p] : null;
}
}
else
{
cur = cur[p];
}
}
return cur;
}
/// <summary>
/// 本地实时预览与后端保持一致DateTime/DateTimeOffset 统一输出 yyyy-MM-dd HH:mm:ss不带时区后缀
/// </summary>
private static JsonNode NormalizePreviewNodeValue(JsonNode? node)
{
if (node == null) return JsonValue.Create(string.Empty)!;
if (node is JsonValue v)
{
if (v.TryGetValue<DateTime>(out var dt))
{
return JsonValue.Create(dt.ToString("yyyy-MM-dd HH:mm:ss"))!;
}
if (v.TryGetValue<DateTimeOffset>(out var dto))
{
return JsonValue.Create(dto.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
}
if (v.TryGetValue<string>(out var s) && !string.IsNullOrWhiteSpace(s))
{
// 仅处理明显的 ISO 时间串,避免误伤普通文本
if ((s.Contains('T') || s.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || s.Contains('+'))
&& DateTimeOffset.TryParse(s, 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 cur = target;
for (int i = 0; i < parts.Length - 1; i++)
{
if (cur[parts[i]] is not JsonObject child)
{
child = new JsonObject();
cur[parts[i]] = child;
}
cur = child;
}
cur[parts[^1]] = value;
}
private void LoadLayoutState()
{
try
{
if (!File.Exists(LayoutFilePath)) return;
var json = File.ReadAllText(LayoutFilePath);
var dto = JsonSerializer.Deserialize<AddPageLayoutDto>(json, LayoutJsonOpts);
if (dto == null) return;
if (dto.ExpandedWidth is > 0 and < 2000)
_expandedRightPanelWidth = Math.Clamp(dto.ExpandedWidth.Value, 200, 560);
if (dto.IsExpanded.HasValue)
_isRightPanelExpanded = dto.IsExpanded.Value;
RaisePropertyChanged(nameof(ExpandedRightPanelWidth));
RaisePropertyChanged(nameof(IsRightPanelExpanded));
}
catch { /* 布局文件损坏时忽略 */ }
}
private void SaveLayoutState()
{
try
{
var dir = Path.GetDirectoryName(LayoutFilePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var dto = new AddPageLayoutDto
{
ExpandedWidth = _expandedRightPanelWidth,
IsExpanded = _isRightPanelExpanded
};
File.WriteAllText(LayoutFilePath, JsonSerializer.Serialize(dto, LayoutJsonOpts));
}
catch { /* 写本地失败时不影响业务 */ }
}
private sealed class AddPageLayoutDto
{
public double? ExpandedWidth { get; set; }
public bool? IsExpanded { get; set; }
}
public sealed class RawMaterialCardGeneratePlanItem
{
public required MesXslRawMaterialCard Card { get; init; }
public required string DetailId { get; init; }
public int SourceRowNo { get; init; }
}
}