新增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

@@ -31,6 +31,20 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
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
{
@@ -67,12 +81,20 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
}
}
/// <summary>仅当入场记录已保存(有 Id且存在拆码明细时允许生成原材料卡片。</summary>
public bool CanGenerateCards => !string.IsNullOrWhiteSpace(Entry?.Id) && SplitCodeDetails.Count > 0;
/// <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 RawMaterialEntryOperationViewModel(
IRawMaterialEntryService entryService,
@@ -88,7 +110,52 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
ToggleRightPanelCommand = new DelegateCommand(() => IsRightPanelExpanded = !IsRightPanelExpanded);
RefreshTodayEntriesCommand = new DelegateCommand(async () => await LoadTodayEntriesAsync());
GenerateRawMaterialCardsCommand = new DelegateCommand(async () => await GenerateRawMaterialCardsAsync());
SplitCodeDetails.CollectionChanged += (_, _) => RaisePropertyChanged(nameof(CanGenerateCards));
ResplitCommand = new DelegateCommand(async () => await ResplitAsync(), () => CanResplit)
.ObservesProperty(() => Entry);
// 集合变化:批量重订阅 item.PropertyChanged 监听 HasCard/Portions并同步刷新两个 Can*。
SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChangedForCanFlags;
}
/// <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()
@@ -117,9 +184,15 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
{
IsLoading = true;
var result = await EntryService.PageAsync(1, TodayListFetchSize);
var today = DateTime.Today;
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 => IsTodayEntry(e, today))
.Where(e => IsInRange(e, threshold))
.OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue)
.ToList();
TodayEntries.Clear();
@@ -136,11 +209,14 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
}
}
private static bool IsTodayEntry(MesXslRawMaterialEntry e, DateTime today)
private static bool IsInRange(MesXslRawMaterialEntry e, DateTime? threshold)
{
var byEntry = e.EntryTime?.Date == today;
var byCreate = e.CreateTime?.Date == today;
return byEntry || byCreate;
if (threshold == null)
{
var today = DateTime.Today;
return (e.EntryTime?.Date == today) || (e.CreateTime?.Date == today);
}
return (e.EntryTime >= threshold) || (e.CreateTime >= threshold);
}
/// <summary>
@@ -158,20 +234,118 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
await LoadMaterialOptionsAsync();
}
/// <summary>编辑保存成功后:刷新右侧今日列表并切回新增态,避免连续误改同一条。</summary>
/// <summary>
/// 保存成功后:无论新增还是编辑,都立即刷新右侧「今日入场」列表。
/// 编辑成功额外把表单切回「新增态」,避免用户连续误改同一条;新增成功后由基类负责清空表单。
/// </summary>
protected override async Task SaveAsync()
{
var wasEdit = !IsAddMode;
await base.SaveAsync();
RaisePropertyChanged(nameof(CanGenerateCards));
if (wasEdit && Result && CloseAction == null)
if (!Result || CloseAction != null)
{
return;
}
await LoadTodayEntriesAsync();
if (wasEdit)
{
HandyControl.Controls.MessageBox.Success("编辑成功!");
await LoadTodayEntriesAsync();
InitializeForAdd();
}
}
/// <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 (Entry == null || string.IsNullOrWhiteSpace(Entry.Id))
@@ -179,26 +353,73 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
HandyControl.Controls.MessageBox.Warning("请先保存入场记录再生成原材料卡片!");
return;
}
if (SplitCodeDetails.Count == 0)
// 只处理未生成卡片的行 + 份数>0已生成行直接跳过避免重复加卡和条码冲突
var pendingRows = SplitCodeDetails
.Where(d => !d.HasCard && (d.Portions ?? 0) > 0)
.ToList();
if (pendingRows.Count == 0)
{
HandyControl.Controls.MessageBox.Warning("当前入场记录无拆分明细,无法生成原材料卡片!");
HandyControl.Controls.MessageBox.Warning("当前没有待生成卡片的拆码明细(已生成的行不会重复处理)。请先「新增明细」再生成。");
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;
}
}
try
{
IsLoading = true;
var globalIndex = 1;
// 续号起点:已有卡片数 = Σ HasCard==true 行的 Portions新增卡片从其后续编
var alreadyGenerated = SplitCodeDetails
.Where(d => d.HasCard)
.Sum(d => d.Portions ?? 0);
var globalIndex = alreadyGenerated + 1;
var baseBarcode = Entry.Barcode ?? "";
var failCount = 0;
var newCardCount = 0;
// 按行收集成功标记:只要该行所有份都加卡成功,行 HasCard=true
// 中途任一份失败则保留 HasCard=false下次再点「生成」时会重试该行
var rowSuccessMap = new Dictionary<RawMaterialSplitDetailItem, bool>();
foreach (var detail in SplitCodeDetails)
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,
@@ -219,21 +440,39 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
TenantId = Entry.TenantId
};
var ok = await _rawMaterialCardService.AddAsync(card);
if (!ok) failCount++;
if (ok) newCardCount++;
else { failCount++; rowAllOk = false; }
globalIndex++;
}
rowSuccessMap[detail] = rowAllOk;
}
// 更新打印状态为「已打印」
Entry.PrintFlag = "1";
await EntryService.EditAsync(Entry);
RaisePropertyChanged(nameof(Entry));
// 把整行加卡都成功的标记 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));
var total = globalIndex - 1;
if (failCount == 0)
HandyControl.Controls.MessageBox.Success($"已生成 {total} 张原材料卡片,打印状态已更新为「已打印」!");
HandyControl.Controls.MessageBox.Success($"已生成 {newCardCount} 张原材料卡片(累计 {alreadyGenerated + newCardCount} 张),打印状态已更新为「已打印」!");
else
HandyControl.Controls.MessageBox.Warning($"共生成 {total} 张,其中 {failCount} 张失败,请检查网络后重试");
HandyControl.Controls.MessageBox.Warning($"本次共尝试生成 {newCardCount + failCount} 张,成功 {newCardCount} 张,失败 {failCount} 张失败的行未标记为「已打印」,可检查网络后再次点击「生成原材料卡片」重试");
}
catch (Exception ex)
{