1352 lines
53 KiB
C#
1352 lines
53 KiB
C#
using HandyControl.Controls;
|
||
using HandyControl.Data;
|
||
using HandyControl.Tools.Extension;
|
||
using System.Collections.Specialized;
|
||
using System.ComponentModel;
|
||
using System.Collections.ObjectModel;
|
||
using System.Globalization;
|
||
using YY.Admin.Core;
|
||
using YY.Admin.Core.Entity;
|
||
using YY.Admin.Core.Services;
|
||
using YY.Admin.Services.Service;
|
||
using YY.Admin.Services.Service.MixerMaterialTareStrategy;
|
||
using YY.Admin.Views.RawMaterialEntry;
|
||
using YY.Admin.ViewModels.WeightRecord;
|
||
using YY.Admin.Views.WeightRecord;
|
||
|
||
namespace YY.Admin.ViewModels.RawMaterialEntry;
|
||
|
||
public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResultable<bool>
|
||
{
|
||
private readonly IRawMaterialEntryService _entryService;
|
||
/// <summary>供子页面(如独立新增页右侧面板)复用列表/详情查询能力。</summary>
|
||
protected IRawMaterialEntryService EntryService => _entryService;
|
||
private readonly IJeecgDictSyncService _dictSyncService;
|
||
private readonly IMixerMaterialService _mixerMaterialService;
|
||
private readonly IMixerMaterialTareStrategyService _tareStrategyService;
|
||
private readonly ISupplierService _supplierService;
|
||
private bool _suppressTareStrategyRefresh;
|
||
protected string? _pendingMaterialId;
|
||
|
||
private MesXslRawMaterialEntry? _entry;
|
||
public MesXslRawMaterialEntry? Entry
|
||
{
|
||
get => _entry;
|
||
set => SetProperty(ref _entry, value);
|
||
}
|
||
|
||
protected MesMixerMaterial? _selectedMaterial;
|
||
public MesMixerMaterial? SelectedMaterial
|
||
{
|
||
get => _selectedMaterial;
|
||
set
|
||
{
|
||
if (!SetProperty(ref _selectedMaterial, value) || value == null || Entry == null) return;
|
||
Entry.MaterialId = value.Id;
|
||
Entry.MaterialCode = value.MaterialCode;
|
||
Entry.MaterialName = value.MaterialName;
|
||
Entry.ManufacturerMaterialName = value.AliasName;
|
||
RecalculateShelfLife(value);
|
||
RaisePropertyChanged(nameof(Entry));
|
||
RaisePropertyChanged(nameof(SelectedMaterialDisplay));
|
||
RaisePropertyChanged(nameof(HasSelectedMaterial));
|
||
|
||
_ = RefreshAllRowsTareStrategyAsync();
|
||
|
||
// 新增模式自动生成条码/批次号
|
||
if (IsAddMode && !string.IsNullOrEmpty(value.MaterialCode))
|
||
_ = AutoGenerateBarcodeAsync(value.MaterialCode);
|
||
}
|
||
}
|
||
|
||
private bool _isGenerating;
|
||
public bool IsGenerating
|
||
{
|
||
get => _isGenerating;
|
||
set => SetProperty(ref _isGenerating, value);
|
||
}
|
||
|
||
public bool IsAddMode => string.IsNullOrWhiteSpace(Entry?.Id);
|
||
public string DialogTitle => IsAddMode ? "新增原料入场记录" : "编辑原料入场记录";
|
||
/// <summary>已打印(PrintFlag=="1"):总重只读、不可新增明细。</summary>
|
||
public bool IsPrinted => string.Equals(Entry?.PrintFlag, "1", StringComparison.Ordinal);
|
||
public bool IsTotalWeightEditable => !IsPrinted;
|
||
public string SelectedMaterialDisplay => _selectedMaterial == null
|
||
? "请选择密炼物料"
|
||
: $"[{_selectedMaterial.MaterialCode}] {_selectedMaterial.MaterialName}";
|
||
public bool HasSelectedMaterial => _selectedMaterial != null;
|
||
|
||
public string? IsSpecialAdoptionValue
|
||
{
|
||
get => Entry?.IsSpecialAdoption;
|
||
set
|
||
{
|
||
if (Entry == null || Entry.IsSpecialAdoption == value)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Entry.IsSpecialAdoption = value;
|
||
if (!string.Equals(value, "1", StringComparison.Ordinal))
|
||
{
|
||
Entry.SpecialAdoptionOperator = null;
|
||
Entry.SpecialAdoptionTime = null;
|
||
Entry.SpecialAdoptionReason = null;
|
||
}
|
||
|
||
RaisePropertyChanged(nameof(Entry));
|
||
RaisePropertyChanged(nameof(IsSpecialAdoptionValue));
|
||
}
|
||
}
|
||
|
||
public double? TotalWeightInput
|
||
{
|
||
get => Entry?.TotalWeight;
|
||
set
|
||
{
|
||
if (Entry == null || Entry.TotalWeight == value) return;
|
||
Entry.TotalWeight = value;
|
||
RaisePropertyChanged(nameof(Entry));
|
||
}
|
||
}
|
||
|
||
public DateTime? EntryTimeInput
|
||
{
|
||
get => Entry?.EntryTime;
|
||
set
|
||
{
|
||
if (Entry == null || Entry.EntryTime == value) return;
|
||
Entry.EntryTime = value;
|
||
RaisePropertyChanged(nameof(Entry));
|
||
_ = RefreshAllRowsTareStrategyAsync();
|
||
}
|
||
}
|
||
|
||
/// <summary>基础资料「托盘及皮重(合计)」= Σ 份数 × (包装物皮重 + 托盘重量)。</summary>
|
||
public double PalletTareTotalDisplay => SplitCodeDetails.Sum(row =>
|
||
{
|
||
var portions = row.Portions ?? 0;
|
||
var packaging = row.PackagingTare ?? 0d;
|
||
var pallet = row.PalletWeight ?? 0d;
|
||
return portions * (packaging + pallet);
|
||
});
|
||
|
||
public ObservableCollection<MesMixerMaterial> MaterialOptions { get; } = new();
|
||
public ObservableCollection<KeyValuePair<string, string>> TestResultOptions { get; } = new();
|
||
public ObservableCollection<KeyValuePair<string, string>> TestStatusOptions { get; } = new();
|
||
public ObservableCollection<KeyValuePair<string, string>> PrintFlagOptions { get; } = new();
|
||
public ObservableCollection<KeyValuePair<string, string>> StockBalanceOptions { get; } = new();
|
||
public ObservableCollection<KeyValuePair<string, string>> IsSpecialAdoptionOptions { get; } = new();
|
||
public ObservableCollection<KeyValuePair<string, string>> StatusOptions { get; } = new();
|
||
public ObservableCollection<RawMaterialSplitDetailItem> SplitCodeDetails { get; } = new();
|
||
|
||
private bool _suspendEmbeddedPrintPreviewAirspace;
|
||
|
||
/// <summary>
|
||
/// WebView2 使用独立 HWND(Airspace),会同窗体内浮在 WPF 元素之上,遮挡 HandyControl 内嵌 Dialog。
|
||
/// 原料入场独立页在弹窗期间通过绑定收起预览宿主;其它页面无 WebView2 时可忽略该属性。
|
||
/// </summary>
|
||
public bool SuspendEmbeddedPrintPreviewAirspace
|
||
{
|
||
get => _suspendEmbeddedPrintPreviewAirspace;
|
||
private set
|
||
{
|
||
if (!SetProperty(ref _suspendEmbeddedPrintPreviewAirspace, value))
|
||
{
|
||
return;
|
||
}
|
||
|
||
OnSuspendEmbeddedPrintPreviewAirspaceChanged();
|
||
}
|
||
}
|
||
|
||
/// <summary>子类在预览区可见性依赖此标志时,覆写以联动通知。</summary>
|
||
protected virtual void OnSuspendEmbeddedPrintPreviewAirspaceChanged()
|
||
{
|
||
}
|
||
|
||
/// <summary>在异步弹窗期间挂起右侧嵌入 WebView2,避免遮挡模态内容。</summary>
|
||
protected async Task<TResult> SuspendEmbeddedPrintPreviewAirspaceWhileAsync<TResult>(Func<Task<TResult>> action)
|
||
{
|
||
SuspendEmbeddedPrintPreviewAirspace = true;
|
||
try
|
||
{
|
||
return await action();
|
||
}
|
||
finally
|
||
{
|
||
SuspendEmbeddedPrintPreviewAirspace = false;
|
||
}
|
||
}
|
||
|
||
public double SplitCodeTableHeight => CalculateSplitCodeTableHeight();
|
||
public string SplitTotalPortionsDisplay => JoinSplitValue(item => item.Portions?.ToString(CultureInfo.InvariantCulture), true);
|
||
public string SplitPortionWeightDisplay => JoinSplitValue(item => FormatNullableDecimal(item.PortionWeight), true);
|
||
public string SplitPortionPackagesDisplay => JoinSplitValue(item => item.PortionPackages?.ToString(CultureInfo.InvariantCulture), true);
|
||
|
||
private bool _result;
|
||
public bool Result { get => _result; set => SetProperty(ref _result, value); }
|
||
public Action? CloseAction { get; set; }
|
||
|
||
public DelegateCommand SaveCommand { get; }
|
||
public DelegateCommand CancelCommand { get; }
|
||
public DelegateCommand ResetCommand { get; }
|
||
public DelegateCommand AddSplitDetailCommand { get; }
|
||
public DelegateCommand<RawMaterialSplitDetailItem> RemoveSplitDetailCommand { get; }
|
||
public DelegateCommand OpenMaterialPickerCommand { get; }
|
||
public DelegateCommand ClearMaterialCommand { get; }
|
||
public DelegateCommand OpenWeightRecordPickerCommand { get; }
|
||
public DelegateCommand ClearWeightRecordCommand { get; }
|
||
public DelegateCommand OpenSupplierPickerCommand { get; }
|
||
public DelegateCommand ClearSupplierCommand { get; }
|
||
/// <summary>拆码明细 - 库位选择命令(弹出「库区选择」弹窗,单选)。CommandParameter 为当前行 RawMaterialSplitDetailItem。</summary>
|
||
public DelegateCommand<RawMaterialSplitDetailItem> OpenWarehouseAreaPickerCommand { get; }
|
||
/// <summary>拆码明细 - 手动选择皮重策略。</summary>
|
||
public DelegateCommand<RawMaterialSplitDetailItem> OpenTareStrategyPickerCommand { get; }
|
||
/// <summary>拆码明细 - 清除手动策略并按规则重新自动匹配。</summary>
|
||
public DelegateCommand<RawMaterialSplitDetailItem> ResetTareStrategyCommand { get; }
|
||
|
||
public RawMaterialEntryEditDialogViewModel(
|
||
IRawMaterialEntryService entryService,
|
||
IJeecgDictSyncService dictSyncService,
|
||
IMixerMaterialService mixerMaterialService,
|
||
IMixerMaterialTareStrategyService tareStrategyService,
|
||
ISupplierService supplierService,
|
||
IContainerExtension container,
|
||
IRegionManager regionManager) : base(container, regionManager)
|
||
{
|
||
_entryService = entryService;
|
||
_dictSyncService = dictSyncService;
|
||
_mixerMaterialService = mixerMaterialService;
|
||
_tareStrategyService = tareStrategyService;
|
||
_supplierService = supplierService;
|
||
SaveCommand = new DelegateCommand(async () => await SaveAsync());
|
||
CancelCommand = new DelegateCommand(() => CloseAction?.Invoke());
|
||
ResetCommand = new DelegateCommand(InitializeForAdd);
|
||
AddSplitDetailCommand = new DelegateCommand(AddSplitDetailRow, () => !IsPrinted);
|
||
RemoveSplitDetailCommand = new DelegateCommand<RawMaterialSplitDetailItem>(RemoveSplitDetailRow);
|
||
OpenMaterialPickerCommand = new DelegateCommand(async () => await OpenMaterialPickerAsync());
|
||
ClearMaterialCommand = new DelegateCommand(ClearMaterialSelection);
|
||
OpenWeightRecordPickerCommand = new DelegateCommand(async () => await OpenWeightRecordPickerAsync());
|
||
ClearWeightRecordCommand = new DelegateCommand(ClearWeightRecordSelection);
|
||
OpenSupplierPickerCommand = new DelegateCommand(async () => await OpenSupplierPickerAsync());
|
||
ClearSupplierCommand = new DelegateCommand(ClearSupplierSelection);
|
||
OpenWarehouseAreaPickerCommand = new DelegateCommand<RawMaterialSplitDetailItem>(async row => await OpenWarehouseAreaPickerAsync(row));
|
||
OpenTareStrategyPickerCommand = new DelegateCommand<RawMaterialSplitDetailItem>(async row => await OpenTareStrategyPickerAsync(row));
|
||
ResetTareStrategyCommand = new DelegateCommand<RawMaterialSplitDetailItem>(async row => await ResetTareStrategyAsync(row));
|
||
SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChanged;
|
||
_ = LoadAllAsync();
|
||
}
|
||
|
||
private async Task LoadAllAsync()
|
||
{
|
||
await Task.WhenAll(LoadDictOptionsAsync(), LoadMaterialOptionsAsync());
|
||
}
|
||
|
||
protected async Task LoadMaterialOptionsAsync()
|
||
{
|
||
try
|
||
{
|
||
// 每次打开弹窗都主动拉一次,确保直接写库的数据也能同步到
|
||
await _mixerMaterialService.SyncFromRemoteAsync();
|
||
var result = await _mixerMaterialService.PageAsync(1, 2000);
|
||
MaterialOptions.Clear();
|
||
foreach (var m in result.Records)
|
||
MaterialOptions.Add(m);
|
||
|
||
// Edit 模式:物料加载完后回填选中项
|
||
if (_pendingMaterialId != null)
|
||
{
|
||
var match = MaterialOptions.FirstOrDefault(m =>
|
||
string.Equals(m.Id, _pendingMaterialId, StringComparison.OrdinalIgnoreCase));
|
||
if (match != null)
|
||
SelectedMaterial = match;
|
||
_pendingMaterialId = null;
|
||
}
|
||
}
|
||
catch { /* 无法加载物料列表时不阻断表单 */ }
|
||
}
|
||
|
||
private async Task LoadDictOptionsAsync()
|
||
{
|
||
try
|
||
{
|
||
var testResultOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_test_result");
|
||
var testStatusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_test_status");
|
||
var printFlagOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_print_flag");
|
||
var ynOpts = await _dictSyncService.GetDictOptionsAsync("yn");
|
||
var statusOpts = await _dictSyncService.GetDictOptionsAsync("xslmes_entry_status");
|
||
|
||
PopulateOptions(TestResultOptions, testResultOpts, new[]
|
||
{
|
||
new KeyValuePair<string, string>("未检", "0"),
|
||
new KeyValuePair<string, string>("合格", "1"),
|
||
new KeyValuePair<string, string>("不合格", "2"),
|
||
});
|
||
PopulateOptions(TestStatusOptions, testStatusOpts, new[]
|
||
{
|
||
new KeyValuePair<string, string>("送样", "0"),
|
||
new KeyValuePair<string, string>("已批准", "1"),
|
||
});
|
||
PopulateOptions(PrintFlagOptions, printFlagOpts, new[]
|
||
{
|
||
new KeyValuePair<string, string>("未打印", "0"),
|
||
new KeyValuePair<string, string>("已打印", "1"),
|
||
});
|
||
PopulateOptions(StockBalanceOptions, ynOpts, new[]
|
||
{
|
||
new KeyValuePair<string, string>("否", "0"),
|
||
new KeyValuePair<string, string>("是", "1"),
|
||
});
|
||
PopulateOptions(IsSpecialAdoptionOptions, ynOpts, new[]
|
||
{
|
||
new KeyValuePair<string, string>("否", "0"),
|
||
new KeyValuePair<string, string>("是", "1"),
|
||
});
|
||
PopulateOptions(StatusOptions, statusOpts, Array.Empty<KeyValuePair<string, string>>());
|
||
ApplyDefaultEntryStatusForAdd();
|
||
ApplyHiddenFieldDefaultsForAdd();
|
||
}
|
||
catch
|
||
{
|
||
FillFallbackOptions();
|
||
ApplyDefaultEntryStatusForAdd();
|
||
ApplyHiddenFieldDefaultsForAdd();
|
||
}
|
||
}
|
||
|
||
private static void PopulateOptions(
|
||
ObservableCollection<KeyValuePair<string, string>> target,
|
||
IEnumerable<KeyValuePair<string, string>> items,
|
||
IEnumerable<KeyValuePair<string, string>> fallback)
|
||
{
|
||
target.Clear();
|
||
var list = items.ToList();
|
||
foreach (var item in list.Count > 0 ? list : fallback.ToList())
|
||
target.Add(item);
|
||
}
|
||
|
||
private void FillFallbackOptions()
|
||
{
|
||
PopulateOptions(TestResultOptions, Array.Empty<KeyValuePair<string, string>>(), new[]
|
||
{
|
||
new KeyValuePair<string, string>("未检", "0"),
|
||
new KeyValuePair<string, string>("合格", "1"),
|
||
new KeyValuePair<string, string>("不合格", "2"),
|
||
});
|
||
PopulateOptions(TestStatusOptions, Array.Empty<KeyValuePair<string, string>>(), new[]
|
||
{
|
||
new KeyValuePair<string, string>("送样", "0"),
|
||
new KeyValuePair<string, string>("已批准", "1"),
|
||
});
|
||
PopulateOptions(PrintFlagOptions, Array.Empty<KeyValuePair<string, string>>(), new[]
|
||
{
|
||
new KeyValuePair<string, string>("未打印", "0"),
|
||
new KeyValuePair<string, string>("已打印", "1"),
|
||
});
|
||
var ynDefault = new[] { new KeyValuePair<string, string>("否", "0"), new KeyValuePair<string, string>("是", "1") };
|
||
PopulateOptions(StockBalanceOptions, Array.Empty<KeyValuePair<string, string>>(), ynDefault);
|
||
PopulateOptions(IsSpecialAdoptionOptions, Array.Empty<KeyValuePair<string, string>>(), ynDefault);
|
||
}
|
||
|
||
protected async Task AutoGenerateBarcodeAsync(string materialCode)
|
||
{
|
||
IsGenerating = true;
|
||
try
|
||
{
|
||
var code = await _entryService.GenerateBarcodeAsync(materialCode);
|
||
if (!string.IsNullOrEmpty(code) && Entry != null)
|
||
{
|
||
Entry.Barcode = code;
|
||
Entry.BatchNo = code;
|
||
RaisePropertyChanged(nameof(Entry));
|
||
}
|
||
}
|
||
finally { IsGenerating = false; }
|
||
}
|
||
|
||
public virtual void InitializeForAdd()
|
||
{
|
||
_selectedMaterial = null;
|
||
RaisePropertyChanged(nameof(SelectedMaterial));
|
||
RaisePropertyChanged(nameof(SelectedMaterialDisplay));
|
||
RaisePropertyChanged(nameof(HasSelectedMaterial));
|
||
Entry = new MesXslRawMaterialEntry
|
||
{
|
||
EntryTime = DateTime.Now,
|
||
IsSpecialAdoption = "0"
|
||
};
|
||
InitializeSplitCodeDetailsFromEntry();
|
||
ApplyDefaultEntryStatusForAdd();
|
||
ApplyHiddenFieldDefaultsForAdd();
|
||
RaisePropertyChanged(nameof(IsAddMode));
|
||
RaisePropertyChanged(nameof(DialogTitle));
|
||
RaisePropertyChanged(nameof(TotalWeightInput));
|
||
RaisePropertyChanged(nameof(EntryTimeInput));
|
||
RaisePropertyChanged(nameof(PalletTareTotalDisplay));
|
||
RaisePropertyChanged(nameof(IsSpecialAdoptionValue));
|
||
RaisePropertyChanged(nameof(SplitTotalPortionsDisplay));
|
||
RaisePropertyChanged(nameof(SplitPortionWeightDisplay));
|
||
RaisePropertyChanged(nameof(SplitPortionPackagesDisplay));
|
||
RaisePropertyChanged(nameof(IsPrinted));
|
||
RaisePropertyChanged(nameof(IsTotalWeightEditable));
|
||
AddSplitDetailCommand.RaiseCanExecuteChanged();
|
||
}
|
||
|
||
public void InitializeForEdit(MesXslRawMaterialEntry entry)
|
||
{
|
||
Entry = new MesXslRawMaterialEntry
|
||
{
|
||
Id = entry.Id, Barcode = entry.Barcode, BatchNo = entry.BatchNo, EntryTime = entry.EntryTime,
|
||
WeightRecordId = entry.WeightRecordId, BillNo = entry.BillNo,
|
||
MaterialId = entry.MaterialId, MaterialCode = entry.MaterialCode, MaterialName = entry.MaterialName,
|
||
SupplyCustomer = entry.SupplyCustomer, SupplierId = entry.SupplierId, SupplierName = entry.SupplierName,
|
||
ManufacturerMaterialName = entry.ManufacturerMaterialName,
|
||
ShelfLife = entry.ShelfLife, TotalWeight = entry.TotalWeight, PalletTareTotal = entry.PalletTareTotal,
|
||
TotalPortions = entry.TotalPortions,
|
||
PortionWeight = entry.PortionWeight,
|
||
PortionPackagingTare = entry.PortionPackagingTare,
|
||
PortionPalletWeight = entry.PortionPalletWeight,
|
||
PortionTareStrategyIds = entry.PortionTareStrategyIds,
|
||
PortionPackages = entry.PortionPackages,
|
||
PortionWarehouseLocations = entry.PortionWarehouseLocations,
|
||
PortionDetailIds = entry.PortionDetailIds,
|
||
PortionCardFlags = entry.PortionCardFlags,
|
||
TestResult = entry.TestResult, TestStatus = entry.TestStatus, PrintFlag = entry.PrintFlag,
|
||
StockBalance = entry.StockBalance, WarehouseLocation = entry.WarehouseLocation,
|
||
UnloadOperator = entry.UnloadOperator, IsSpecialAdoption = entry.IsSpecialAdoption,
|
||
SpecialAdoptionOperator = entry.SpecialAdoptionOperator, SpecialAdoptionTime = entry.SpecialAdoptionTime,
|
||
SpecialAdoptionReason = entry.SpecialAdoptionReason, Status = entry.Status, Remark = entry.Remark,
|
||
TenantId = entry.TenantId,
|
||
};
|
||
InitializeSplitCodeDetailsFromEntry();
|
||
|
||
// 若物料列表已加载则直接回填,否则记录 pending 等加载完后回填
|
||
if (MaterialOptions.Count > 0)
|
||
{
|
||
_selectedMaterial = MaterialOptions.FirstOrDefault(m =>
|
||
string.Equals(m.Id, entry.MaterialId, StringComparison.OrdinalIgnoreCase));
|
||
RaisePropertyChanged(nameof(SelectedMaterial));
|
||
RaisePropertyChanged(nameof(SelectedMaterialDisplay));
|
||
RaisePropertyChanged(nameof(HasSelectedMaterial));
|
||
}
|
||
else
|
||
{
|
||
_pendingMaterialId = entry.MaterialId;
|
||
}
|
||
|
||
RaisePropertyChanged(nameof(IsAddMode));
|
||
RaisePropertyChanged(nameof(DialogTitle));
|
||
RaisePropertyChanged(nameof(TotalWeightInput));
|
||
RaisePropertyChanged(nameof(EntryTimeInput));
|
||
RaisePropertyChanged(nameof(PalletTareTotalDisplay));
|
||
RaisePropertyChanged(nameof(IsSpecialAdoptionValue));
|
||
RaisePropertyChanged(nameof(SplitTotalPortionsDisplay));
|
||
RaisePropertyChanged(nameof(SplitPortionWeightDisplay));
|
||
RaisePropertyChanged(nameof(SplitPortionPackagesDisplay));
|
||
RaisePropertyChanged(nameof(IsPrinted));
|
||
RaisePropertyChanged(nameof(IsTotalWeightEditable));
|
||
AddSplitDetailCommand.RaiseCanExecuteChanged();
|
||
}
|
||
|
||
/// <summary>校验并提交新增/编辑;不弹关闭、不执行独立页的 InitializeForAdd。成功时 Result=true。</summary>
|
||
protected async Task<bool> PersistEntryCoreAsync()
|
||
{
|
||
if (Entry == null) return false;
|
||
ApplySplitDetailsToEntry();
|
||
ApplyDefaultEntryStatusForAdd();
|
||
ApplyHiddenFieldDefaultsForAdd();
|
||
|
||
var missing = new List<string>();
|
||
if (string.IsNullOrWhiteSpace(Entry.MaterialId)) missing.Add("密炼物料");
|
||
if (string.IsNullOrWhiteSpace(Entry.BillNo)) missing.Add("榜单号");
|
||
if (string.IsNullOrWhiteSpace(Entry.UnloadOperator)) missing.Add("卸货人");
|
||
if (string.IsNullOrWhiteSpace(Entry.SupplierName)) missing.Add("供应商名称");
|
||
if (missing.Count > 0)
|
||
{
|
||
HandyControl.Controls.MessageBox.Warning($"以下必填项不能为空:{string.Join("、", missing)}");
|
||
return false;
|
||
}
|
||
|
||
bool ok;
|
||
if (IsAddMode)
|
||
{
|
||
ok = await _entryService.AddAsync(Entry);
|
||
if (!ok) { HandyControl.Controls.MessageBox.Error("新增失败!"); return false; }
|
||
}
|
||
else
|
||
{
|
||
ok = await _entryService.EditAsync(Entry);
|
||
if (!ok) { HandyControl.Controls.MessageBox.Error("编辑失败!"); return false; }
|
||
}
|
||
Result = ok;
|
||
return true;
|
||
}
|
||
|
||
protected virtual async Task SaveAsync()
|
||
{
|
||
if (Entry == null) return;
|
||
try
|
||
{
|
||
if (!await PersistEntryCoreAsync()) return;
|
||
|
||
if (IsAddMode)
|
||
{
|
||
// 非模态轻提示,约 1 秒后自动消失(与独立页「保存并打印」时的体验一致)
|
||
Growl.Success(new GrowlInfo
|
||
{
|
||
Message = "保存成功",
|
||
ShowDateTime = false,
|
||
WaitTime = 1
|
||
});
|
||
if (CloseAction == null)
|
||
{
|
||
// 独立新增页面:保存成功后自动清空表单,便于连续录入
|
||
InitializeForAdd();
|
||
return;
|
||
}
|
||
}
|
||
|
||
CloseAction?.Invoke();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
HandyControl.Controls.MessageBox.Error($"操作失败:{ex.Message}");
|
||
}
|
||
}
|
||
|
||
private async Task OpenWeightRecordPickerAsync()
|
||
{
|
||
WeightRecordPickerDialogViewModel? pickerVm = null;
|
||
bool confirmed;
|
||
try
|
||
{
|
||
confirmed = await SuspendEmbeddedPrintPreviewAirspaceWhileAsync(() =>
|
||
HandyControl.Controls.Dialog.Show<WeightRecordPickerDialogView>()
|
||
.Initialize<WeightRecordPickerDialogViewModel>(vm =>
|
||
{
|
||
pickerVm = vm;
|
||
vm.Initialize(Entry?.BillNo);
|
||
})
|
||
.GetResultAsync<bool>());
|
||
}
|
||
catch
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!confirmed || pickerVm?.SelectedRecord == null || Entry == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var selected = pickerVm.SelectedRecord;
|
||
Entry.WeightRecordId = selected.Id;
|
||
Entry.BillNo = selected.BillNo;
|
||
Entry.SupplierName = selected.SenderUnit;
|
||
Entry.SupplierId = null;
|
||
await ApplySupplierFromDisplayNameAsync(selected.SenderUnit);
|
||
// 选择榜单后,自动把「剩余可入场量 = 净重 - 已入场重量」带入「总重」,
|
||
// 用户仍可手动编辑;若净重为空则保留原值,避免误清空。
|
||
if (selected.NetWeight.HasValue)
|
||
{
|
||
var entered = selected.EnteredWeight ?? 0d;
|
||
var remaining = selected.NetWeight.Value - entered;
|
||
// 已入场重量可能因数据异常超过净重;UI 不展示负数,强制夹到 0。
|
||
Entry.TotalWeight = Math.Round(Math.Max(0d, remaining), 2);
|
||
}
|
||
RaisePropertyChanged(nameof(Entry));
|
||
RaisePropertyChanged(nameof(TotalWeightInput));
|
||
RaisePropertyChanged(nameof(EntryTimeInput));
|
||
RaisePropertyChanged(nameof(PalletTareTotalDisplay));
|
||
await RefreshAllRowsTareStrategyAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按供应商全称/简称反查 ID(榜单「发货单位」与手动填写的供应商名称均可用)。
|
||
/// </summary>
|
||
private async Task ApplySupplierFromDisplayNameAsync(string? displayName)
|
||
{
|
||
if (Entry == null || string.IsNullOrWhiteSpace(displayName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var supplier = await FindSupplierByDisplayNameAsync(displayName);
|
||
if (supplier == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Entry.SupplierId = supplier.Id;
|
||
Entry.SupplierName = supplier.SupplierName
|
||
?? supplier.SupplierShortName
|
||
?? displayName.Trim();
|
||
RaisePropertyChanged(nameof(Entry));
|
||
}
|
||
|
||
private async Task<MesXslSupplier?> FindSupplierByDisplayNameAsync(string? displayName)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(displayName))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var name = displayName.Trim();
|
||
try
|
||
{
|
||
var page = await _supplierService.PageAsync(1, 5000);
|
||
var exact = page.Records.FirstOrDefault(s =>
|
||
string.Equals(s.SupplierName?.Trim(), name, StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(s.SupplierShortName?.Trim(), name, StringComparison.OrdinalIgnoreCase));
|
||
if (exact != null)
|
||
{
|
||
return exact;
|
||
}
|
||
|
||
return page.Records.FirstOrDefault(s =>
|
||
(s.SupplierName ?? "").Contains(name, StringComparison.OrdinalIgnoreCase)
|
||
|| (s.SupplierShortName ?? "").Contains(name, StringComparison.OrdinalIgnoreCase)
|
||
|| name.Contains(s.SupplierShortName ?? "", StringComparison.OrdinalIgnoreCase)
|
||
|| name.Contains(s.SupplierName ?? "", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 皮重策略匹配前确保 SupplierId 已解析(榜单仅带出名称时补全 ID)。
|
||
/// </summary>
|
||
private async Task EnsureSupplierIdForTareMatchAsync()
|
||
{
|
||
if (Entry == null || !string.IsNullOrWhiteSpace(Entry.SupplierId))
|
||
{
|
||
return;
|
||
}
|
||
|
||
await ApplySupplierFromDisplayNameAsync(Entry.SupplierName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 弹出「库区选择」弹窗,把选中的 AreaName 写回当前明细行的 WarehouseLocation。
|
||
/// 入参 row:被点击的拆码明细行;为 null 直接 return(防御性)。
|
||
/// </summary>
|
||
private async Task OpenWarehouseAreaPickerAsync(RawMaterialSplitDetailItem? row)
|
||
{
|
||
if (row == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
WarehouseAreaPickerDialogViewModel? pickerVm = null;
|
||
bool confirmed;
|
||
try
|
||
{
|
||
confirmed = await SuspendEmbeddedPrintPreviewAirspaceWhileAsync(() =>
|
||
HandyControl.Controls.Dialog.Show<WarehouseAreaPickerDialogView>()
|
||
.Initialize<WarehouseAreaPickerDialogViewModel>(vm =>
|
||
{
|
||
pickerVm = vm;
|
||
vm.Initialize(row.WarehouseLocation);
|
||
})
|
||
.GetResultAsync<bool>());
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// 保留 Debug 输出以便排查,但不阻断流程
|
||
System.Diagnostics.Debug.WriteLine($"[库位选择] 弹窗异常: {ex}");
|
||
return;
|
||
}
|
||
|
||
if (!confirmed || pickerVm?.SelectedRecord == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 仅回填库区名称(与后端 warehouse_location 字段保持单字符串语义)。
|
||
// 如未来需要严格关联库区 ID,可在 RawMaterialSplitDetailItem 新增 WarehouseAreaId 一并保存。
|
||
row.WarehouseLocation = pickerVm.SelectedRecord.AreaName;
|
||
}
|
||
|
||
private async Task OpenMaterialPickerAsync()
|
||
{
|
||
RawMaterialEntryMaterialPickerDialogViewModel? pickerVm = null;
|
||
bool confirmed;
|
||
try
|
||
{
|
||
confirmed = await SuspendEmbeddedPrintPreviewAirspaceWhileAsync(() =>
|
||
HandyControl.Controls.Dialog.Show<RawMaterialEntryMaterialPickerDialogView>()
|
||
.Initialize<RawMaterialEntryMaterialPickerDialogViewModel>(vm =>
|
||
{
|
||
pickerVm = vm;
|
||
vm.Initialize(Entry?.MaterialCode, Entry?.MaterialName);
|
||
})
|
||
.GetResultAsync<bool>());
|
||
}
|
||
catch
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!confirmed || pickerVm?.SelectedMaterial == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
SelectedMaterial = pickerVm.SelectedMaterial;
|
||
}
|
||
|
||
private void ClearMaterialSelection()
|
||
{
|
||
_selectedMaterial = null;
|
||
RaisePropertyChanged(nameof(SelectedMaterial));
|
||
RaisePropertyChanged(nameof(SelectedMaterialDisplay));
|
||
RaisePropertyChanged(nameof(HasSelectedMaterial));
|
||
|
||
if (Entry == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Entry.MaterialId = null;
|
||
Entry.MaterialCode = null;
|
||
Entry.MaterialName = null;
|
||
Entry.ManufacturerMaterialName = null;
|
||
Entry.ShelfLife = null;
|
||
RaisePropertyChanged(nameof(Entry));
|
||
}
|
||
|
||
private void ClearWeightRecordSelection()
|
||
{
|
||
if (Entry == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Entry.WeightRecordId = null;
|
||
Entry.BillNo = null;
|
||
Entry.SupplierId = null;
|
||
Entry.SupplierName = null;
|
||
RaisePropertyChanged(nameof(Entry));
|
||
_ = RefreshAllRowsTareStrategyAsync();
|
||
}
|
||
|
||
private async Task OpenSupplierPickerAsync()
|
||
{
|
||
SupplierPickerDialogViewModel? pickerVm = null;
|
||
bool confirmed;
|
||
try
|
||
{
|
||
confirmed = await SuspendEmbeddedPrintPreviewAirspaceWhileAsync(() =>
|
||
HandyControl.Controls.Dialog.Show<SupplierPickerDialogView>()
|
||
.Initialize<SupplierPickerDialogViewModel>(vm => pickerVm = vm)
|
||
.GetResultAsync<bool>());
|
||
}
|
||
catch { return; }
|
||
|
||
if (!confirmed || pickerVm?.SelectedSupplier == null || Entry == null) return;
|
||
|
||
var selected = pickerVm.SelectedSupplier;
|
||
Entry.SupplierId = selected.Id;
|
||
Entry.SupplierName = selected.SupplierName;
|
||
RaisePropertyChanged(nameof(Entry));
|
||
await RefreshAllRowsTareStrategyAsync();
|
||
}
|
||
|
||
private void ClearSupplierSelection()
|
||
{
|
||
if (Entry == null) return;
|
||
Entry.SupplierId = null;
|
||
Entry.SupplierName = null;
|
||
RaisePropertyChanged(nameof(Entry));
|
||
_ = RefreshAllRowsTareStrategyAsync();
|
||
}
|
||
|
||
private async Task RefreshAllRowsTareStrategyAsync()
|
||
{
|
||
if (_suppressTareStrategyRefresh) return;
|
||
await EnsureSupplierIdForTareMatchAsync();
|
||
foreach (var row in SplitCodeDetails.ToList())
|
||
{
|
||
if (!row.IsManualTareStrategy)
|
||
await ApplyAutoTareStrategyToRowAsync(row);
|
||
}
|
||
}
|
||
|
||
private async Task ApplyAutoTareStrategyToRowAsync(RawMaterialSplitDetailItem row)
|
||
{
|
||
if (_suppressTareStrategyRefresh || row.IsManualTareStrategy || row.HasCard) return;
|
||
|
||
try
|
||
{
|
||
var strategies = await _tareStrategyService.GetAllForMatchAsync();
|
||
var match = MixerMaterialTareStrategyMatcher.PickBestMatch(
|
||
strategies,
|
||
Entry?.MaterialId,
|
||
Entry?.SupplierId,
|
||
Entry?.EntryTime,
|
||
row.PortionWeight);
|
||
ApplyTareStrategyToRow(row, match, manual: false);
|
||
}
|
||
catch
|
||
{
|
||
ApplyTareStrategyToRow(row, null, manual: false);
|
||
}
|
||
|
||
RaisePalletTareTotalChanged();
|
||
}
|
||
|
||
private void ApplyTareStrategyToRow(
|
||
RawMaterialSplitDetailItem row,
|
||
MesXslMixerMaterialTareStrategy? strategy,
|
||
bool manual)
|
||
{
|
||
if (strategy == null)
|
||
{
|
||
row.PackagingTare = 0d;
|
||
row.PalletWeight = 0d;
|
||
row.TareStrategyId = null;
|
||
row.TareStrategyDisplay = "未匹配(0)";
|
||
}
|
||
else
|
||
{
|
||
row.PackagingTare = strategy.TareWeight.HasValue ? (double)strategy.TareWeight.Value : 0d;
|
||
row.PalletWeight = strategy.PalletWeight.HasValue ? (double)strategy.PalletWeight.Value : 0d;
|
||
row.TareStrategyId = strategy.Id;
|
||
row.TareStrategyDisplay = BuildTareStrategyDisplay(strategy);
|
||
}
|
||
|
||
row.IsManualTareStrategy = manual && strategy != null;
|
||
}
|
||
|
||
private static string BuildTareStrategyDisplay(MesXslMixerMaterialTareStrategy strategy)
|
||
{
|
||
var spec = string.IsNullOrWhiteSpace(strategy.MaterialSpec) ? "-" : strategy.MaterialSpec;
|
||
var pkg = strategy.TareWeight?.ToString("0.##") ?? "0";
|
||
var pallet = strategy.PalletWeight?.ToString("0.##") ?? "0";
|
||
return $"规格:{spec} 包装:{pkg} 托盘:{pallet}";
|
||
}
|
||
|
||
private async Task OpenTareStrategyPickerAsync(RawMaterialSplitDetailItem? row)
|
||
{
|
||
if (row == null || row.HasCard) return;
|
||
if (string.IsNullOrWhiteSpace(Entry?.MaterialId))
|
||
{
|
||
HandyControl.Controls.MessageBox.Warning("请先选择密炼物料!");
|
||
return;
|
||
}
|
||
|
||
await EnsureSupplierIdForTareMatchAsync();
|
||
if (string.IsNullOrWhiteSpace(Entry?.SupplierId))
|
||
{
|
||
HandyControl.Controls.MessageBox.Warning("未能匹配到供应商档案,请手动选择供应商,或检查榜单发货单位是否与供应商简称/全称一致!");
|
||
return;
|
||
}
|
||
|
||
TareStrategyPickerDialogViewModel? pickerVm = null;
|
||
bool confirmed;
|
||
try
|
||
{
|
||
confirmed = await SuspendEmbeddedPrintPreviewAirspaceWhileAsync(async () =>
|
||
{
|
||
return await HandyControl.Controls.Dialog.Show<TareStrategyPickerDialogView>()
|
||
.Initialize<TareStrategyPickerDialogViewModel>(vm =>
|
||
{
|
||
pickerVm = vm;
|
||
vm.Initialize(Entry!.MaterialId, Entry.SupplierId, Entry.EntryTime, row.PortionWeight, row.TareStrategyId);
|
||
})
|
||
.GetResultAsync<bool>();
|
||
});
|
||
}
|
||
catch { return; }
|
||
|
||
if (!confirmed) return;
|
||
if (pickerVm?.SelectedStrategy == null)
|
||
{
|
||
row.IsManualTareStrategy = false;
|
||
await ApplyAutoTareStrategyToRowAsync(row);
|
||
return;
|
||
}
|
||
|
||
ApplyTareStrategyToRow(row, pickerVm.SelectedStrategy, manual: true);
|
||
RaisePalletTareTotalChanged();
|
||
}
|
||
|
||
private async Task ResetTareStrategyAsync(RawMaterialSplitDetailItem? row)
|
||
{
|
||
if (row == null || row.HasCard) return;
|
||
row.IsManualTareStrategy = false;
|
||
await ApplyAutoTareStrategyToRowAsync(row);
|
||
}
|
||
|
||
private void RaisePalletTareTotalChanged() => RaisePropertyChanged(nameof(PalletTareTotalDisplay));
|
||
|
||
private void RecalculateShelfLife(MesMixerMaterial? material)
|
||
{
|
||
if (Entry == null || material?.ShelfLifeDays == null || material.ShelfLifeDays <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
Entry.ShelfLife = DateTime.Now.Date.AddDays(material.ShelfLifeDays.Value).ToString("yyyy-MM-dd");
|
||
}
|
||
|
||
protected void ApplyDefaultEntryStatusForAdd()
|
||
{
|
||
if (!IsAddMode || Entry == null || !string.IsNullOrWhiteSpace(Entry.Status))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var pending = StatusOptions.FirstOrDefault(x =>
|
||
string.Equals(x.Key?.Trim(), "待处理", StringComparison.OrdinalIgnoreCase));
|
||
if (!string.IsNullOrWhiteSpace(pending.Value))
|
||
{
|
||
Entry.Status = pending.Value;
|
||
return;
|
||
}
|
||
|
||
// 字典未就绪时使用常见默认值
|
||
Entry.Status = "0";
|
||
}
|
||
|
||
/// <summary>
|
||
/// 新增模式下为已在前端隐藏的字段补充默认值,确保保存到后端时不为空:
|
||
/// 检测结果=未检、打印标记=未打印、入库结存=否。
|
||
/// 字典就绪时取字典 code,未就绪时回退到约定的常用 code。
|
||
/// </summary>
|
||
protected void ApplyHiddenFieldDefaultsForAdd()
|
||
{
|
||
if (!IsAddMode || Entry == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(Entry.TestResult))
|
||
{
|
||
Entry.TestResult = ResolveDefaultOptionValue(TestResultOptions, "未检", "0");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(Entry.PrintFlag))
|
||
{
|
||
Entry.PrintFlag = ResolveDefaultOptionValue(PrintFlagOptions, "未打印", "0");
|
||
}
|
||
if (string.IsNullOrWhiteSpace(Entry.StockBalance))
|
||
{
|
||
Entry.StockBalance = ResolveDefaultOptionValue(StockBalanceOptions, "否", "0");
|
||
}
|
||
|
||
RaisePropertyChanged(nameof(Entry));
|
||
}
|
||
|
||
/// <summary>在字典选项中按 Key(显示标签)查找对应 Value(实际 code),找不到则回退到 fallback。</summary>
|
||
private static string ResolveDefaultOptionValue(
|
||
ObservableCollection<KeyValuePair<string, string>> options,
|
||
string keyLabel,
|
||
string fallbackValue)
|
||
{
|
||
var match = options.FirstOrDefault(x =>
|
||
string.Equals(x.Key?.Trim(), keyLabel, StringComparison.OrdinalIgnoreCase));
|
||
return !string.IsNullOrWhiteSpace(match.Value) ? match.Value : fallbackValue;
|
||
}
|
||
|
||
protected void InitializeSplitCodeDetailsFromEntry()
|
||
{
|
||
_suppressTareStrategyRefresh = true;
|
||
try
|
||
{
|
||
SplitCodeDetails.Clear();
|
||
|
||
var portionsArr = SplitJoinedValues(Entry?.TotalPortions);
|
||
var weightArr = SplitJoinedValues(Entry?.PortionWeight);
|
||
var packagingTareArr = SplitJoinedValues(Entry?.PortionPackagingTare);
|
||
var palletWeightArr = SplitJoinedValues(Entry?.PortionPalletWeight);
|
||
var strategyIdsArr = SplitJoinedValues(Entry?.PortionTareStrategyIds);
|
||
var packagesArr = SplitJoinedValues(Entry?.PortionPackages);
|
||
var locationsArr = SplitJoinedValues(Entry?.PortionWarehouseLocations);
|
||
var idsArr = SplitJoinedValues(Entry?.PortionDetailIds);
|
||
var cardFlagsArr = SplitJoinedValues(Entry?.PortionCardFlags);
|
||
var fallbackToPrintFlag = cardFlagsArr.Length == 0
|
||
&& string.Equals(Entry?.PrintFlag, "1", StringComparison.Ordinal);
|
||
var rowCount = Math.Max(1, Math.Max(Math.Max(portionsArr.Length, weightArr.Length),
|
||
Math.Max(Math.Max(packagesArr.Length, locationsArr.Length), idsArr.Length)));
|
||
|
||
for (var i = 0; i < rowCount; i++)
|
||
{
|
||
var locationFromArr = GetAt(locationsArr, i);
|
||
var locationFallback = (i == 0 && string.IsNullOrWhiteSpace(locationFromArr))
|
||
? Entry?.WarehouseLocation
|
||
: locationFromArr;
|
||
|
||
var strategyId = GetAt(strategyIdsArr, i);
|
||
var item = new RawMaterialSplitDetailItem
|
||
{
|
||
Portions = TryParseInt(GetAt(portionsArr, i)),
|
||
PortionWeight = TryParseDouble(GetAt(weightArr, i)),
|
||
PortionPackages = TryParseInt(GetAt(packagesArr, i)),
|
||
WarehouseLocation = locationFallback,
|
||
PackagingTare = TryParseDouble(GetAt(packagingTareArr, i)) ?? 0d,
|
||
PalletWeight = TryParseDouble(GetAt(palletWeightArr, i)) ?? 0d,
|
||
TareStrategyId = strategyId,
|
||
IsManualTareStrategy = !string.IsNullOrWhiteSpace(strategyId),
|
||
};
|
||
item.TareStrategyDisplay = string.IsNullOrWhiteSpace(strategyId)
|
||
? "未匹配(0)"
|
||
: $"策略ID:{strategyId}";
|
||
|
||
var existingId = GetAt(idsArr, i);
|
||
if (!string.IsNullOrWhiteSpace(existingId))
|
||
item.Id = existingId;
|
||
|
||
var flagAt = GetAt(cardFlagsArr, i);
|
||
if (!string.IsNullOrWhiteSpace(flagAt))
|
||
item.HasCard = string.Equals(flagAt, "1", StringComparison.Ordinal);
|
||
else if (fallbackToPrintFlag && !string.IsNullOrWhiteSpace(existingId))
|
||
item.HasCard = true;
|
||
|
||
SplitCodeDetails.Add(item);
|
||
}
|
||
|
||
RaisePropertyChanged(nameof(SplitCodeTableHeight));
|
||
RaisePalletTareTotalChanged();
|
||
}
|
||
finally
|
||
{
|
||
_suppressTareStrategyRefresh = false;
|
||
}
|
||
}
|
||
|
||
private static string[] SplitJoinedValues(string? value)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(value)) return Array.Empty<string>();
|
||
return value
|
||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||
.Select(x => x.Trim())
|
||
.Where(x => x.Length > 0)
|
||
.ToArray();
|
||
}
|
||
|
||
private static string? GetAt(string[] arr, int index) => index < arr.Length ? arr[index] : null;
|
||
|
||
private static int? TryParseInt(string? text)
|
||
=> int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||
|
||
private static double? TryParseDouble(string? text)
|
||
=> double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
|
||
|
||
private void AddSplitDetailRow()
|
||
{
|
||
var row = new RawMaterialSplitDetailItem();
|
||
SplitCodeDetails.Add(row);
|
||
RaisePropertyChanged(nameof(SplitCodeTableHeight));
|
||
_ = ApplyAutoTareStrategyToRowAsync(row);
|
||
}
|
||
|
||
private void RemoveSplitDetailRow(RawMaterialSplitDetailItem? item)
|
||
{
|
||
if (item == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
SplitCodeDetails.Remove(item);
|
||
if (SplitCodeDetails.Count == 0)
|
||
{
|
||
SplitCodeDetails.Add(new RawMaterialSplitDetailItem());
|
||
}
|
||
RaisePropertyChanged(nameof(SplitCodeTableHeight));
|
||
}
|
||
|
||
private void OnSplitCodeDetailsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||
{
|
||
if (e.OldItems != null)
|
||
{
|
||
foreach (var oldItem in e.OldItems.OfType<RawMaterialSplitDetailItem>())
|
||
{
|
||
oldItem.PropertyChanged -= OnSplitDetailItemPropertyChanged;
|
||
}
|
||
}
|
||
|
||
if (e.NewItems != null)
|
||
{
|
||
foreach (var newItem in e.NewItems.OfType<RawMaterialSplitDetailItem>())
|
||
{
|
||
newItem.PropertyChanged += OnSplitDetailItemPropertyChanged;
|
||
}
|
||
}
|
||
|
||
RaiseSplitDisplayPropertyChanged();
|
||
RaisePropertyChanged(nameof(SplitCodeTableHeight));
|
||
}
|
||
|
||
private void OnSplitDetailItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||
{
|
||
// 用户修改某行的「份数」时:仅当该行「每份重量」为空才按公式自动计算
|
||
if (e.PropertyName == nameof(RawMaterialSplitDetailItem.Portions)
|
||
&& sender is RawMaterialSplitDetailItem row)
|
||
{
|
||
RecalculatePortionWeightForRow(row);
|
||
}
|
||
|
||
if (e.PropertyName == nameof(RawMaterialSplitDetailItem.PortionWeight)
|
||
&& sender is RawMaterialSplitDetailItem weightRow)
|
||
{
|
||
// 清空每份重量后,若份数已填则立即按公式重算(配合 PortionWeightText 空值绑定)
|
||
if (!weightRow.PortionWeight.HasValue)
|
||
{
|
||
RecalculatePortionWeightForRow(weightRow);
|
||
}
|
||
|
||
if (!weightRow.IsManualTareStrategy && !weightRow.HasCard)
|
||
{
|
||
_ = ApplyAutoTareStrategyToRowAsync(weightRow);
|
||
}
|
||
}
|
||
|
||
if (e.PropertyName is nameof(RawMaterialSplitDetailItem.Portions)
|
||
or nameof(RawMaterialSplitDetailItem.PortionWeight)
|
||
or nameof(RawMaterialSplitDetailItem.PortionPackages)
|
||
or nameof(RawMaterialSplitDetailItem.PackagingTare)
|
||
or nameof(RawMaterialSplitDetailItem.PalletWeight))
|
||
{
|
||
RaiseSplitDisplayPropertyChanged();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 拆码明细某行的「份数」变化时,仅当该行「每份重量」为空时按公式重算:
|
||
/// 公式:每份重量 = (总重 - 其他行 Σ份数×每份重量) / 当前行份数
|
||
/// — 已填写「每份重量」后再改份数,不覆盖用户输入;
|
||
/// — 总重为空 / ≤0、当前份数 ≤0、剩余总重 ≤0 时跳过;
|
||
/// — 结果四舍五入到两位小数。
|
||
/// </summary>
|
||
private void RecalculatePortionWeightForRow(RawMaterialSplitDetailItem row)
|
||
{
|
||
if (row.PortionWeight.HasValue)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (Entry?.TotalWeight is not { } total || total <= 0d)
|
||
{
|
||
return;
|
||
}
|
||
if (row.Portions is not { } portions || portions <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var otherSum = 0d;
|
||
foreach (var other in SplitCodeDetails)
|
||
{
|
||
if (ReferenceEquals(other, row)) continue;
|
||
if (other.Portions is not { } op || other.PortionWeight is not { } ow) continue;
|
||
otherSum += op * ow;
|
||
}
|
||
|
||
var remaining = total - otherSum;
|
||
if (remaining <= 0d)
|
||
{
|
||
return;
|
||
}
|
||
|
||
row.PortionWeight = Math.Round(remaining / portions, 2);
|
||
}
|
||
|
||
private void RaiseSplitDisplayPropertyChanged()
|
||
{
|
||
RaisePropertyChanged(nameof(SplitTotalPortionsDisplay));
|
||
RaisePropertyChanged(nameof(SplitPortionWeightDisplay));
|
||
RaisePropertyChanged(nameof(SplitPortionPackagesDisplay));
|
||
RaisePalletTareTotalChanged();
|
||
}
|
||
|
||
private string JoinSplitValue(Func<RawMaterialSplitDetailItem, string?> selector, bool withTrailingSlash)
|
||
{
|
||
var values = SplitCodeDetails
|
||
.Select(selector)
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.Select(x => x!.Trim())
|
||
.ToList();
|
||
if (values.Count == 0)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
var joined = string.Join("/", values);
|
||
return withTrailingSlash ? $"{joined}/" : joined;
|
||
}
|
||
|
||
private static string? FormatNullableDecimal(double? value)
|
||
{
|
||
if (!value.HasValue)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return value.Value.ToString("0.##", CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把 SplitCodeDetails 全部明细行的「份数 / 每份重量 / 每份包数 / 库位 / 行 ID」按 "x/y/z/" 拼接后持久化到 Entry。
|
||
/// 明细行的「库位」属于行级数据(用于后续生成原材料卡片时区分库位),与 Entry.WarehouseLocation
|
||
/// (基础资料整票级单值)独立保存,通过 portion_warehouse_locations 字段持久化。
|
||
/// 子类(如 OperationViewModel 的「重新拆码」)需要直接调用,因此声明为 protected。
|
||
/// </summary>
|
||
protected void ApplySplitDetailsToEntry()
|
||
{
|
||
if (Entry == null) return;
|
||
|
||
Entry.TotalPortions = JoinSplitValue(it => it.Portions?.ToString(CultureInfo.InvariantCulture), true);
|
||
Entry.PortionWeight = JoinSplitValue(it => FormatNullableDecimal(it.PortionWeight), true);
|
||
Entry.PortionPackagingTare = JoinSplitValue(it => FormatNullableDecimal(it.PackagingTare), true);
|
||
Entry.PortionPalletWeight = JoinSplitValue(it => FormatNullableDecimal(it.PalletWeight), true);
|
||
Entry.PortionTareStrategyIds = JoinSplitValue(
|
||
it => it.IsManualTareStrategy && !string.IsNullOrWhiteSpace(it.TareStrategyId) ? it.TareStrategyId : null,
|
||
true);
|
||
Entry.PortionPackages = JoinSplitValue(it => it.PortionPackages?.ToString(CultureInfo.InvariantCulture), true);
|
||
Entry.PortionWarehouseLocations = JoinSplitValue(it => string.IsNullOrWhiteSpace(it.WarehouseLocation) ? null : it.WarehouseLocation.Trim(), true);
|
||
// 持久化每行 GUID,行序与上方四个字段对齐;JoinSplitValue 会过滤空,但 Id 在构造时即生成不会为空。
|
||
Entry.PortionDetailIds = JoinSplitValue(it => it.Id, true);
|
||
// 持久化每行的「已生成卡片」标志(1=已生成 0=未生成),
|
||
// 用于解决「保存后新增行被误判为已打印」的问题:
|
||
// 「生成原材料卡片」只对 HasCard=false 的行加卡 → 必须把真实的 HasCard 持久化,
|
||
// 否则重新加载后无法区分「已生成」与「保存后新增的未生成行」。
|
||
Entry.PortionCardFlags = JoinSplitValue(it => it.HasCard ? "1" : "0", true);
|
||
Entry.PalletTareTotal = PalletTareTotalDisplay;
|
||
}
|
||
|
||
private double CalculateSplitCodeTableHeight()
|
||
{
|
||
// 与拆码明细 XAML 行高保持一致:表头 40px、数据行 44px
|
||
const double headerHeight = 40d;
|
||
const double rowHeight = 44d;
|
||
const double framePadding = 16d;
|
||
const double minRows = 1d;
|
||
const double maxRows = 6d;
|
||
var rowCount = Math.Clamp(SplitCodeDetails.Count, (int)minRows, (int)maxRows);
|
||
return headerHeight + rowCount * rowHeight + framePadding;
|
||
}
|
||
}
|
||
|
||
public class RawMaterialSplitDetailItem : BindableBase
|
||
{
|
||
/// <summary>
|
||
/// 拆码明细行 ID(GUID,构造时生成)。前端隐藏;
|
||
/// 「生成原材料卡片」时由桌面端写入对应 MesXslRawMaterialCard.SplitDetailId;
|
||
/// 「重新拆码」时按入场记录的 PortionDetailIds 反查批删关联卡片。
|
||
/// 编辑回填时若 portion_detail_ids 已有该位置的值,会覆盖默认值,确保跨次保持稳定。
|
||
/// </summary>
|
||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||
|
||
private int? _portions;
|
||
public int? Portions
|
||
{
|
||
get => _portions;
|
||
set => SetProperty(ref _portions, value);
|
||
}
|
||
|
||
private double? _portionWeight;
|
||
public double? PortionWeight
|
||
{
|
||
get => _portionWeight;
|
||
set
|
||
{
|
||
if (SetProperty(ref _portionWeight, value))
|
||
{
|
||
RaisePropertyChanged(nameof(PortionWeightText));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 每份重量文本(供 TextBox 绑定;空字符串表示未填写,避免 double? 直接绑定无法清空)。
|
||
/// </summary>
|
||
public string PortionWeightText
|
||
{
|
||
get => _portionWeight.HasValue
|
||
? _portionWeight.Value.ToString("0.##", CultureInfo.InvariantCulture)
|
||
: string.Empty;
|
||
set
|
||
{
|
||
var text = value?.Trim() ?? string.Empty;
|
||
if (string.IsNullOrEmpty(text))
|
||
{
|
||
PortionWeight = null;
|
||
return;
|
||
}
|
||
|
||
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||
{
|
||
PortionWeight = parsed;
|
||
}
|
||
}
|
||
}
|
||
|
||
private int? _portionPackages;
|
||
public int? PortionPackages
|
||
{
|
||
get => _portionPackages;
|
||
set => SetProperty(ref _portionPackages, value);
|
||
}
|
||
|
||
private string? _warehouseLocation;
|
||
public string? WarehouseLocation
|
||
{
|
||
get => _warehouseLocation;
|
||
set => SetProperty(ref _warehouseLocation, value);
|
||
}
|
||
|
||
private double? _packagingTare;
|
||
public double? PackagingTare
|
||
{
|
||
get => _packagingTare;
|
||
set => SetProperty(ref _packagingTare, value);
|
||
}
|
||
|
||
private double? _palletWeight;
|
||
public double? PalletWeight
|
||
{
|
||
get => _palletWeight;
|
||
set => SetProperty(ref _palletWeight, value);
|
||
}
|
||
|
||
private string? _tareStrategyId;
|
||
public string? TareStrategyId
|
||
{
|
||
get => _tareStrategyId;
|
||
set => SetProperty(ref _tareStrategyId, value);
|
||
}
|
||
|
||
private string? _tareStrategyDisplay = "未匹配(0)";
|
||
public string? TareStrategyDisplay
|
||
{
|
||
get => _tareStrategyDisplay;
|
||
set => SetProperty(ref _tareStrategyDisplay, value);
|
||
}
|
||
|
||
private bool _isManualTareStrategy;
|
||
/// <summary>用户手动选择皮重策略后为 true,自动匹配不再覆盖。</summary>
|
||
public bool IsManualTareStrategy
|
||
{
|
||
get => _isManualTareStrategy;
|
||
set => SetProperty(ref _isManualTareStrategy, value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 该行是否已生成原材料卡片(运行时状态,不直接持久化)。
|
||
/// 加载时根据 Entry.PrintFlag + Id 是否来自持久化的 PortionDetailIds 推断;
|
||
/// 「生成原材料卡片」成功后由 VM 置为 true;「重新拆码」清空集合时随之失效。
|
||
/// true → 行内输入项 / 删除 全部锁定,避免与已生成卡片数据脱节;
|
||
/// false → 该行参与下次「生成原材料卡片」(继续拆码 + 续号生成)。
|
||
/// </summary>
|
||
private bool _hasCard;
|
||
public bool HasCard
|
||
{
|
||
get => _hasCard;
|
||
set => SetProperty(ref _hasCard, value);
|
||
}
|
||
}
|