新增MES库区管理功能,包含免密接口、数据处理逻辑及相关控制器、服务和实体的实现。支持库区的增删改查操作,优化用户体验并增强系统的实时数据同步能力。

This commit is contained in:
geht
2026-05-12 14:06:07 +08:00
parent cffe32d896
commit b737dddb2a
74 changed files with 4937 additions and 174 deletions

View File

@@ -63,6 +63,9 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
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}";
@@ -130,6 +133,10 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
public DelegateCommand ClearWeightRecordCommand { get; }
public DelegateCommand OpenSupplierPickerCommand { get; }
public DelegateCommand ClearSupplierCommand { get; }
/// <summary>
/// 拆码明细 - 库位选择命令弹出「库区选择」弹窗单选。CommandParameter 为当前行 RawMaterialSplitDetailItem。
/// </summary>
public DelegateCommand<RawMaterialSplitDetailItem> OpenWarehouseAreaPickerCommand { get; }
public RawMaterialEntryEditDialogViewModel(
IRawMaterialEntryService entryService,
@@ -144,7 +151,7 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
SaveCommand = new DelegateCommand(async () => await SaveAsync());
CancelCommand = new DelegateCommand(() => CloseAction?.Invoke());
ResetCommand = new DelegateCommand(InitializeForAdd);
AddSplitDetailCommand = new DelegateCommand(AddSplitDetailRow);
AddSplitDetailCommand = new DelegateCommand(AddSplitDetailRow, () => !IsPrinted);
RemoveSplitDetailCommand = new DelegateCommand<RawMaterialSplitDetailItem>(RemoveSplitDetailRow);
OpenMaterialPickerCommand = new DelegateCommand(async () => await OpenMaterialPickerAsync());
ClearMaterialCommand = new DelegateCommand(ClearMaterialSelection);
@@ -152,6 +159,7 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
ClearWeightRecordCommand = new DelegateCommand(ClearWeightRecordSelection);
OpenSupplierPickerCommand = new DelegateCommand(async () => await OpenSupplierPickerAsync());
ClearSupplierCommand = new DelegateCommand(ClearSupplierSelection);
OpenWarehouseAreaPickerCommand = new DelegateCommand<RawMaterialSplitDetailItem>(async row => await OpenWarehouseAreaPickerAsync(row));
SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChanged;
_ = LoadAllAsync();
}
@@ -304,6 +312,9 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
RaisePropertyChanged(nameof(SplitTotalPortionsDisplay));
RaisePropertyChanged(nameof(SplitPortionWeightDisplay));
RaisePropertyChanged(nameof(SplitPortionPackagesDisplay));
RaisePropertyChanged(nameof(IsPrinted));
RaisePropertyChanged(nameof(IsTotalWeightEditable));
AddSplitDetailCommand.RaiseCanExecuteChanged();
}
public void InitializeForEdit(MesXslRawMaterialEntry entry)
@@ -317,6 +328,9 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
ManufacturerMaterialName = entry.ManufacturerMaterialName,
ShelfLife = entry.ShelfLife, TotalWeight = entry.TotalWeight, TotalPortions = entry.TotalPortions,
PortionWeight = entry.PortionWeight, 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,
@@ -347,6 +361,9 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
RaisePropertyChanged(nameof(SplitTotalPortionsDisplay));
RaisePropertyChanged(nameof(SplitPortionWeightDisplay));
RaisePropertyChanged(nameof(SplitPortionPackagesDisplay));
RaisePropertyChanged(nameof(IsPrinted));
RaisePropertyChanged(nameof(IsTotalWeightEditable));
AddSplitDetailCommand.RaiseCanExecuteChanged();
}
protected virtual async Task SaveAsync()
@@ -357,6 +374,18 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
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;
}
bool ok;
if (IsAddMode)
{
@@ -414,7 +443,57 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
Entry.BillNo = selected.BillNo;
Entry.SupplierName = selected.SenderUnit;
Entry.SupplierId = null;
// 选择榜单后,自动把「剩余可入场量 = 净重 - 已入场重量」带入「总重」,
// 用户仍可手动编辑;若净重为空则保留原值,避免误清空。
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));
}
/// <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 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()
@@ -537,7 +616,7 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
/// <summary>
/// 新增模式下为已在前端隐藏的字段补充默认值,确保保存到后端时不为空:
/// 检测结果=未检、检测状态=送样、打印标记=未打印、入库结存=否。
/// 检测结果=未检、打印标记=未打印、入库结存=否。
/// 字典就绪时取字典 code未就绪时回退到约定的常用 code。
/// </summary>
protected void ApplyHiddenFieldDefaultsForAdd()
@@ -551,10 +630,7 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
{
Entry.TestResult = ResolveDefaultOptionValue(TestResultOptions, "未检", "0");
}
if (string.IsNullOrWhiteSpace(Entry.TestStatus))
{
Entry.TestStatus = ResolveDefaultOptionValue(TestStatusOptions, "送样", "0");
}
if (string.IsNullOrWhiteSpace(Entry.PrintFlag))
{
Entry.PrintFlag = ResolveDefaultOptionValue(PrintFlagOptions, "未打印", "0");
@@ -582,22 +658,58 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
{
SplitCodeDetails.Clear();
// 个字段是按拆码明细多行拼接的字符串(末尾带 /),解析回明细列表
// 个字段是按拆码明细多行拼接的字符串(末尾带 /),解析回明细列表
var portionsArr = SplitJoinedValues(Entry?.TotalPortions);
var weightArr = SplitJoinedValues(Entry?.PortionWeight);
var packagesArr = SplitJoinedValues(Entry?.PortionPackages);
var rowCount = Math.Max(1, Math.Max(portionsArr.Length, Math.Max(weightArr.Length, packagesArr.Length)));
var locationsArr = SplitJoinedValues(Entry?.PortionWarehouseLocations);
var idsArr = SplitJoinedValues(Entry?.PortionDetailIds);
var cardFlagsArr = SplitJoinedValues(Entry?.PortionCardFlags);
// 历史记录可能没存 PortionCardFlags用 print_flag 推断(与原行为兼容)
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++)
{
SplitCodeDetails.Add(new RawMaterialSplitDetailItem
var locationFromArr = GetAt(locationsArr, i);
// 兼容历史数据:明细库位拼接字段为空时,首行回退到 Entry.WarehouseLocation
// (早期版本里整票级库位曾被反写过,避免老记录打开后明细全空)
var locationFallback = (i == 0 && string.IsNullOrWhiteSpace(locationFromArr))
? Entry?.WarehouseLocation
: locationFromArr;
var item = new RawMaterialSplitDetailItem
{
Portions = TryParseInt(GetAt(portionsArr, i)),
PortionWeight = TryParseDouble(GetAt(weightArr, i)),
PortionPackages = TryParseInt(GetAt(packagesArr, i)),
// 库位是单值字段,仅赋给首行
WarehouseLocation = i == 0 ? Entry?.WarehouseLocation : null,
});
WarehouseLocation = locationFallback,
};
// 仅当后端已持久化了该行 ID 时才覆盖默认值,确保跨次保持稳定;
// 历史记录无 ID 字段时使用构造期生成的新 GUID之后保存会回填
var existingId = GetAt(idsArr, i);
if (!string.IsNullOrWhiteSpace(existingId))
{
item.Id = existingId;
}
// HasCard 行级解析(优先级 1从 PortionCardFlags 按位读取,"1"=已生成。
// 这是「保存后新增未生成行不被误判为已打印」的关键 — 新增行保存时 HasCard=false 持久化为 "0"
// 重新加载时回填 false「生成原材料卡片」就能正确把它列入待生成清单。
var flagAt = GetAt(cardFlagsArr, i);
if (!string.IsNullOrWhiteSpace(flagAt))
{
item.HasCard = string.Equals(flagAt, "1", StringComparison.Ordinal);
}
else if (fallbackToPrintFlag && !string.IsNullOrWhiteSpace(existingId))
{
// 优先级 2兼容历史记录未存 PortionCardFlags 时,沿用旧逻辑——
// 持久化 ID 存在 + 整票已打印 ⇒ 视为已生成卡片。
item.HasCard = true;
}
// 否则保持构造默认值 false新增态、未打印整票等场景
SplitCodeDetails.Add(item);
}
RaisePropertyChanged(nameof(SplitCodeTableHeight));
@@ -666,6 +778,14 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
private void OnSplitDetailItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// 用户填写或修改某行的「份数」时,按公式自动算出该行「每份重量」:
// 每份重量 = (总重 - Σ其他行的份数×每份重量) / 当前行份数
if (e.PropertyName == nameof(RawMaterialSplitDetailItem.Portions)
&& sender is RawMaterialSplitDetailItem row)
{
RecalculatePortionWeightForRow(row);
}
if (e.PropertyName is nameof(RawMaterialSplitDetailItem.Portions)
or nameof(RawMaterialSplitDetailItem.PortionWeight)
or nameof(RawMaterialSplitDetailItem.PortionPackages))
@@ -674,6 +794,42 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
}
}
/// <summary>
/// 拆码明细某行的「份数」变化时,按公式重算该行「每份重量」:
/// 公式:每份重量 = (总重 - 其他行 Σ份数×每份重量) / 当前行份数
/// — 用户单独手改「每份重量」不触发;
/// — 总重为空 / ≤0、当前份数 ≤0、剩余总重 ≤0 时跳过;
/// — 结果四舍五入到两位小数;
/// — 写入 row.PortionWeight 会触发 PropertyChanged(PortionWeight)不会再次进入本方法PropertyName 已不是 Portions不会形成循环。
/// </summary>
private void RecalculatePortionWeightForRow(RawMaterialSplitDetailItem row)
{
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));
@@ -708,21 +864,26 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
}
/// <summary>
/// 把 SplitCodeDetails 全部明细行的「份数 / 每份重量 / 每份包数」按 "x/y/z/" 拼接后持久化到 Entry
/// 库位仍取首行(业务上库位是单值)。与 SplitTotalPortionsDisplay 等只读展示属性同规则。
/// 把 SplitCodeDetails 全部明细行的「份数 / 每份重量 / 每份包数 / 库位 / 行 ID」按 "x/y/z/" 拼接后持久化到 Entry
/// 明细行的「库位」属于行级数据(用于后续生成原材料卡片时区分库位),与 Entry.WarehouseLocation
/// (基础资料整票级单值)独立保存,通过 portion_warehouse_locations 字段持久化。
/// 子类(如 OperationViewModel 的「重新拆码」)需要直接调用,因此声明为 protected。
/// </summary>
private void ApplySplitDetailsToEntry()
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.PortionPackages = JoinSplitValue(it => it.PortionPackages?.ToString(CultureInfo.InvariantCulture), true);
if (SplitCodeDetails.Count > 0)
{
Entry.WarehouseLocation = SplitCodeDetails[0].WarehouseLocation;
}
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);
}
private double CalculateSplitCodeTableHeight()
@@ -740,6 +901,14 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
public class RawMaterialSplitDetailItem : BindableBase
{
/// <summary>
/// 拆码明细行 IDGUID构造时生成。前端隐藏
/// 「生成原材料卡片」时由桌面端写入对应 MesXslRawMaterialCard.SplitDetailId
/// 「重新拆码」时按入场记录的 PortionDetailIds 反查批删关联卡片。
/// 编辑回填时若 portion_detail_ids 已有该位置的值,会覆盖默认值,确保跨次保持稳定。
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString("N");
private int? _portions;
public int? Portions
{
@@ -767,4 +936,18 @@ public class RawMaterialSplitDetailItem : BindableBase
get => _warehouseLocation;
set => SetProperty(ref _warehouseLocation, value);
}
/// <summary>
/// 该行是否已生成原材料卡片(运行时状态,不直接持久化)。
/// 加载时根据 Entry.PrintFlag + Id 是否来自持久化的 PortionDetailIds 推断;
/// 「生成原材料卡片」成功后由 VM 置为 true「重新拆码」清空集合时随之失效。
/// true → 行内输入项 / 删除 全部锁定,避免与已生成卡片数据脱节;
/// false → 该行参与下次「生成原材料卡片」(继续拆码 + 续号生成)。
/// </summary>
private bool _hasCard;
public bool HasCard
{
get => _hasCard;
set => SetProperty(ref _hasCard, value);
}
}