桌面端原材料入场记录完善

This commit is contained in:
2026-07-31 14:27:46 +08:00
parent 67f3acfd43
commit ef28b93909
13 changed files with 531 additions and 75 deletions

View File

@@ -266,8 +266,43 @@ const (
printQueuePollInterval = 500 * time.Millisecond
printQueueAppearTimeout = 120 * time.Second
printQueueCompleteTimeout = 5 * time.Minute
// 虚拟打印机Print to PDF 等)常弹「另存为」对话框,且不一定进入 Get-PrintJob 队列
virtualPrinterSumatraTimeout = 10 * time.Minute
)
// isVirtualWindowsPrinter 判断是否为虚拟/文件类打印机(队列跟踪不可靠)。
func isVirtualWindowsPrinter(printerName string) bool {
n := strings.ToLower(strings.TrimSpace(printerName))
if n == "" {
return false
}
keywords := []string{
"microsoft print to pdf",
"microsoft xps document writer",
"onenote",
"fax",
"adobe pdf",
"pdf24",
"foxit pdf",
"foxit reader pdf",
"bullzip",
"dopdf",
"cutepdf",
"novapdf",
"pdfcreator",
"print to file",
"print to pdf",
"pdf",
"xps",
}
for _, k := range keywords {
if strings.Contains(n, k) {
return true
}
}
return false
}
func getPrintJobs(printerName string) ([]windowsPrintJob, error) {
printerName = strings.TrimSpace(printerName)
if printerName == "" {
@@ -319,6 +354,11 @@ func getPrintJobIDs(printerName string) (map[int]bool, error) {
}
func waitForWindowsPrintCompletion(printerName string, existingIDs map[int]bool, cmdDone <-chan error) error {
// 虚拟打印机:不要求进入 spooler 队列,只等 Sumatra 退出(用户完成另存为)
if isVirtualWindowsPrinter(printerName) {
return waitForVirtualPrinterPrint(cmdDone)
}
appearDeadline := time.Now().Add(printQueueAppearTimeout)
completeDeadline := time.Now().Add(printQueueCompleteTimeout)
@@ -345,7 +385,8 @@ func waitForWindowsPrintCompletion(printerName string, existingIDs map[int]bool,
return nil
}
if !queued && now.After(appearDeadline) {
return fmt.Errorf("print job not queued within %s", printQueueAppearTimeout)
// 超时仍未入队:虚拟打印机另存为、或部分驱动不经队列;不再硬失败(避免误报 Printed 0/1
return nil
}
if queued && now.After(completeDeadline) {
return fmt.Errorf("print job not completed within %s", printQueueCompleteTimeout)
@@ -384,6 +425,29 @@ func waitForWindowsPrintCompletion(printerName string, existingIDs map[int]bool,
}
}
// waitForVirtualPrinterPrint 虚拟打印机专用:跳过 Get-PrintJob 队列检测。
// Microsoft Print to PDF / XPS 等会弹对话框,任务常不进队列,原逻辑会误报 2 分钟超时。
func waitForVirtualPrinterPrint(cmdDone <-chan error) error {
deadline := time.Now().Add(virtualPrinterSumatraTimeout)
ticker := time.NewTicker(printQueuePollInterval)
defer ticker.Stop()
for {
select {
case err := <-cmdDone:
if err != nil {
return fmt.Errorf("sumatra print failed: %v", err)
}
return nil
case <-ticker.C:
if time.Now().After(deadline) {
// 对话框可能仍打开或进程未退出;任务多半已交给系统,按成功返回避免业务误报失败
return nil
}
}
}
}
func normalizeJobStatus(status interface{}) []string {
switch v := status.(type) {
case string:

View File

@@ -188,9 +188,11 @@ The server returns the result of each print:
```
**Windows queue tracking note:**
On Windows, the server waits for the print job to appear in the printer queue and complete before returning `success`.
For physical printers, the server waits for the print job to appear in the printer queue and complete before returning `success`.
If the job does not appear within 120 seconds, or does not complete within 5 minutes, it returns `error` with a timeout message.
For virtual printers (e.g. `Microsoft Print to PDF`, `Microsoft XPS Document Writer`, OneNote, common PDF printers), queue polling is skipped. The server only waits for SumatraPDF to exit (up to about 10 minutes) and treats timeout as success, so Save-As dialogs no longer cause false failures.
### 3.3 Client Code Example
```javascript

View File

@@ -192,8 +192,10 @@ wails dev
```
**Windows 队列跟踪说明:**
Windows 下服务端会等待打印任务进入打印队列并完成后才返回 `success`
120 秒内未入队或 5 分钟内未完成,将返回 `error` 并给出超时提示
Windows 下对实体打印机,服务端会等待打印任务进入打印队列并完成后才返回 `success`
任务最终完成则返回成功;若长时间未入队,现已改为按成功处理(兼容虚拟打印机另存为、部分驱动不经队列),避免误报 `print job not queued`
对虚拟打印机(如 `Microsoft Print to PDF``Microsoft XPS Document Writer`、OneNote、名称含 pdf/xps 的打印机等)会跳过队列检测,仅等待 Sumatra 退出(最长约 10 分钟)。
---

View File

@@ -1401,3 +1401,23 @@ jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecordList.vue
jeecgboot-vue3/src/views/xslmes/approval/mesXslApprovalRecord/MesXslApprovalRecord.api.ts
-- author:GHT---date:20260730--for: <20><>XSLMES-20260730-DING-PULL<4C><4C><EFBFBD><EFBFBD><EFBFBD><EFBFBD>̨<EFBFBD><CCA8><EFBFBD>ֶ<EFBFBD><D6B6><EFBFBD>ȡ<EFBFBD><C8A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ǿ<EFBFBD><C7BF><EFBFBD><EFBFBD><EFBFBD>ܼ<EFBFBD><DCBC><EFBFBD> ---
-- author:jiangxh---date:20260731--for: <20><>ԭ<EFBFBD><D4AD><EFBFBD><EFBFBD><EBB3A1>anon<6F><6E><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>˱<EFBFBD><CBB1><EFBFBD><E6B2A2>ӡ<EFBFBD><D3A1>дId<49><64><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ---
jeecg-boot/.../MesXslDesktopAnonController.java
yy-admin-master/.../RawMaterialEntryService.cs
yy-admin-master/.../RawMaterialEntryOperationViewModel.cs
yy-admin-master/.../PrintDotService.cs
yy-admin-master/.../HtmlToPdfRenderer.cs
-- author:jiangxh---date:20260731--for: <20><>ԭ<EFBFBD><D4AD><EFBFBD><EFBFBD><EBB3A1><EFBFBD><EFBFBD><EFBFBD><EFBFBD><E6B2A2>ӡʧ<D3A1>ܻع<DCBB><D8B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ؿ<EFBFBD><D8BF><EFBFBD><EFBFBD><EFBFBD>ֹ<EFBFBD><D6B9><EFBFBD><EFBFBD><EFBFBD><EFBFBD> ---
yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs
yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs
yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryOperationView.xaml
-- author:jiangxh---date:20260731--for: <20><>ԭ<EFBFBD><D4AD><EFBFBD><EFBFBD><EBB3A1><EFBFBD><EFBFBD><E0BCAD>Ϊ<EFBFBD><CEAA><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͬ<EFBFBD>IJ<EFBFBD><C4B2><EFBFBD>ҳ ---
yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs
yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs
yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryOperationView.xaml.cs
-- author:jiangxh---date:20260731--for: <20><>ԭ<EFBFBD><D4AD><EFBFBD><EFBFBD><EBB3A1><EFBFBD>Ѵ<EFBFBD>ӡ<EFBFBD><D3A1>Ƭ<EFBFBD><C6AC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ؽ<EFBFBD>ֹ<EFBFBD><D6B9><EFBFBD><EFBFBD><EFBFBD>ɿ<EFBFBD>Ƭ ---
yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs

View File

@@ -518,7 +518,10 @@ public class MesXslDesktopAnonController {
}
rawMaterialEntryService.save(entity);
stompNotify.publishRawMaterialEntryChanged("add", entity.getId());
return Result.OK("添加成功!");
//update-begin---author:jiangxh ---date:20260731 for【原料入场】anon新增返回主键供桌面端保存并打印-----------
// result 带回主键,桌面端保存并打印时无需再按条码回查
return Result.OK("添加成功!", entity.getId());
//update-end---author:jiangxh ---date:20260731 for【原料入场】anon新增返回主键供桌面端保存并打印-----------
}
@Operation(summary = "原料入场记录-免密编辑")

View File

@@ -57,11 +57,18 @@ public class PrintDotService : IPrintDotService
public async Task PrintAsync(string printerName, string pdfBase64, string jobName = "QH-MES", int copies = 1, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(printerName))
throw new InvalidOperationException("打印机名称不能为空");
if (string.IsNullOrWhiteSpace(pdfBase64))
throw new InvalidOperationException("打印内容为空PDF 生成失败");
// 去掉 data: 前缀
var content = pdfBase64.Trim();
var comma = content.IndexOf(',');
if (content.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
content = content[(comma + 1)..];
if (string.IsNullOrWhiteSpace(content))
throw new InvalidOperationException("打印内容为空PDF 生成失败");
var payload = new
{
@@ -87,14 +94,46 @@ public class PrintDotService : IPrintDotService
{
var response = await ReceiveTextAsync(ws, cts.Token);
var resDoc = JsonNode.Parse(response);
var status = resDoc?["status"]?.GetValue<string>();
if (status == null) continue;
if (status == "success") return;
var rawMsg = resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败";
var status = TryReadJsonString(resDoc?["status"]);
if (string.IsNullOrWhiteSpace(status)) continue;
if (string.Equals(status, "success", StringComparison.OrdinalIgnoreCase)) return;
var rawMsg = TryReadJsonString(resDoc?["message"]) ?? "PrintDot 打印失败";
//update-begin---author:jiangxh ---date:20260731 for【PrintDot】队列未入队超时按软成功虚拟打印机-----------
if (IsPrintQueueAppearSoftSuccess(rawMsg))
{
return;
}
//update-end---author:jiangxh ---date:20260731 for【PrintDot】队列未入队超时按软成功虚拟打印机-----------
throw new InvalidOperationException(EnhanceErrorMessage(rawMsg));
}
}
private static string? TryReadJsonString(JsonNode? node)
{
if (node == null) return null;
try
{
if (node is JsonValue jv)
{
if (jv.TryGetValue<string>(out var s)) return s;
if (jv.TryGetValue<bool>(out var b)) return b ? "true" : "false";
return jv.ToJsonString().Trim('"');
}
return node.ToJsonString();
}
catch
{
return null;
}
}
/// <summary>PrintDot 队列入队超时:视为已提交(兼容旧版桥接器)。</summary>
private static bool IsPrintQueueAppearSoftSuccess(string? rawMsg)
{
if (string.IsNullOrWhiteSpace(rawMsg)) return false;
return rawMsg.Contains("print job not queued within", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 将 PrintDot 返回的部分英文错误转换为带本地处理步骤的中文提示。
/// 与 web 端 printDotBridge.ts::enhancePrintDotErrorMessage 行为一致,方便桌面端用户自助排查。

View File

@@ -159,7 +159,14 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen
try
{
var ok = await RemoteAddAsync(local, ct).ConfigureAwait(false);
if (ok) { UpsertLocalCache(local); return true; }
if (ok)
{
//update-begin---author:jiangxh ---date:20260731 for【原料入场】新增后回写Id/条码,避免保存并打印空引用-----------
CopyGeneratedFieldsToEntry(entry, local);
UpsertLocalCache(local);
return true;
//update-end---author:jiangxh ---date:20260731 for【原料入场】新增后回写Id/条码,避免保存并打印空引用-----------
}
return false;
}
catch (Exception ex)
@@ -169,8 +176,21 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen
}
EnqueuePendingOperation(new EntryPendingOperation { OpType = EntryOpType.Add, EntryId = local.Id, Entry = local, CreatedAt = DateTime.UtcNow });
//update-begin---author:jiangxh ---date:20260731 for【原料入场】新增后回写Id/条码,避免保存并打印空引用-----------
CopyGeneratedFieldsToEntry(entry, local);
UpsertLocalCache(local);
return true;
//update-end---author:jiangxh ---date:20260731 for【原料入场】新增后回写Id/条码,避免保存并打印空引用-----------
}
/// <summary>把 Clone 上生成的主键/条码/批次写回调用方 Entry供保存并打印继续使用。</summary>
private static void CopyGeneratedFieldsToEntry(MesXslRawMaterialEntry entry, MesXslRawMaterialEntry local)
{
if (entry == null || local == null) return;
entry.Id = local.Id;
entry.Barcode = local.Barcode;
entry.BatchNo = local.BatchNo;
entry.TenantId = local.TenantId;
}
public async Task<bool> EditAsync(MesXslRawMaterialEntry entry, CancellationToken ct = default)
@@ -586,7 +606,51 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/add?tenantId={DefaultTenantId}";
var payload = Clone(entry);
if (IsLocalTempId(payload.Id)) payload.Id = null;
return await PostJsonAsync(url, payload, ct).ConfigureAwait(false);
//update-begin---author:jiangxh ---date:20260731 for【原料入场】新增后回写服务端主键-----------
var (ok, serverId) = await PostJsonForAddAsync(url, payload, ct).ConfigureAwait(false);
if (ok && !string.IsNullOrWhiteSpace(serverId))
{
entry.Id = serverId.Trim();
}
return ok;
//update-end---author:jiangxh ---date:20260731 for【原料入场】新增后回写服务端主键-----------
}
/// <summary>新增接口:解析 Result.OK(msg, id) 的 result 主键。</summary>
private async Task<(bool Ok, string? ServerId)> PostJsonForAddAsync(string url, object body, CancellationToken ct)
{
var content = new StringContent(JsonSerializer.Serialize(body, _jsonOpts), Encoding.UTF8, "application/json");
using var client = CreateClient();
var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) return (false, null);
try
{
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var codeOk = true;
if (root.TryGetProperty("code", out var codeEl))
codeOk = codeEl.GetInt32() == 200;
else if (root.TryGetProperty("success", out var successEl))
codeOk = successEl.GetBoolean();
if (!codeOk) return (false, null);
string? serverId = null;
if (root.TryGetProperty("result", out var resultEl))
{
serverId = resultEl.ValueKind switch
{
JsonValueKind.String => resultEl.GetString(),
JsonValueKind.Object when resultEl.TryGetProperty("id", out var idEl) => idEl.GetString(),
_ => resultEl.ToString()
};
}
return (true, string.IsNullOrWhiteSpace(serverId) ? null : serverId);
}
catch
{
return (true, null);
}
}
private async Task<(bool Ok, bool IsVersionConflict)> RemoteEditAsync(MesXslRawMaterialEntry entry, CancellationToken ct)

View File

@@ -17,7 +17,7 @@ public static class HtmlToPdfRenderer
{
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
Application.Current.Dispatcher.InvokeAsync(async () =>
Application.Current?.Dispatcher.InvokeAsync(async () =>
{
try
{
@@ -30,6 +30,11 @@ public static class HtmlToPdfRenderer
}
});
if (Application.Current == null)
{
tcs.TrySetException(new InvalidOperationException("WPF Application 未初始化,无法生成 PDF"));
}
return tcs.Task;
}

View File

@@ -8,6 +8,7 @@ using System.Globalization;
using YY.Admin.Core;
using YY.Admin.Core.Entity;
using YY.Admin.Core.Services;
using YY.Admin.Core.Util;
using YY.Admin.Services.Service;
using YY.Admin.Services.Service.MixerMaterialTareStrategy;
using YY.Admin.Views.RawMaterialEntry;
@@ -25,6 +26,7 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
private readonly IMixerMaterialService _mixerMaterialService;
private readonly IMixerMaterialTareStrategyService _tareStrategyService;
private readonly ISupplierService _supplierService;
private readonly IWeightRecordService _weightRecordService;
private bool _suppressTareStrategyRefresh;
protected string? _pendingMaterialId;
@@ -212,6 +214,7 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
IMixerMaterialService mixerMaterialService,
IMixerMaterialTareStrategyService tareStrategyService,
ISupplierService supplierService,
IWeightRecordService weightRecordService,
IContainerExtension container,
IRegionManager regionManager) : base(container, regionManager)
{
@@ -220,6 +223,7 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
_mixerMaterialService = mixerMaterialService;
_tareStrategyService = tareStrategyService;
_supplierService = supplierService;
_weightRecordService = weightRecordService;
SaveCommand = new DelegateCommand(async () => await SaveAsync());
CancelCommand = new DelegateCommand(() => CloseAction?.Invoke());
ResetCommand = new DelegateCommand(InitializeForAdd);
@@ -468,6 +472,10 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
return false;
}
//update-begin---author:jiangxh ---date:20260731 for【原料入场】新增/加量前校验磅单剩余净重-----------
if (!await ValidateBillRemainingWeightAsync()) return false;
//update-end---author:jiangxh ---date:20260731 for【原料入场】新增/加量前校验磅单剩余净重-----------
bool ok;
if (IsAddMode)
{
@@ -483,6 +491,69 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta
return true;
}
/// <summary>
/// 校验关联磅单剩余可入场量:剩余 = 净重 - 其他已存在入场记录累计(份数×每份重量)。
/// 剩余 ≤ 0 或本次申请量超过剩余时禁止保存。编辑时排除当前记录自身。
/// </summary>
protected async Task<bool> ValidateBillRemainingWeightAsync()
{
if (Entry == null || string.IsNullOrWhiteSpace(Entry.BillNo)) return true;
// 先刷新入场缓存,保证已入场累计与远端一致
await _entryService.PageAsync(1, 1, billNo: Entry.BillNo);
MesXslWeightRecord? weightRecord = null;
if (!string.IsNullOrWhiteSpace(Entry.WeightRecordId))
{
weightRecord = await _weightRecordService.GetByIdAsync(Entry.WeightRecordId);
}
if (weightRecord == null)
{
var page = await _weightRecordService.PageAsync(1, 5, filterBillNo: Entry.BillNo);
weightRecord = page.Records.FirstOrDefault(r =>
string.Equals(r.BillNo, Entry.BillNo, StringComparison.OrdinalIgnoreCase));
}
if (weightRecord?.NetWeight is not { } netWeight)
{
HandyControl.Controls.MessageBox.Warning("关联磅单缺少净重,无法校验剩余可入场量,禁止保存。");
return false;
}
var others = _entryService.GetCachedSnapshot()
.Where(e => !string.IsNullOrWhiteSpace(e.BillNo)
&& string.Equals(e.BillNo, Entry.BillNo, StringComparison.OrdinalIgnoreCase)
&& (string.IsNullOrWhiteSpace(Entry.Id)
|| !string.Equals(e.Id, Entry.Id, StringComparison.OrdinalIgnoreCase)))
.ToList();
var enteredMap = EnteredWeightCalculator.SumByBillNos(others, new[] { Entry.BillNo });
var entered = enteredMap.TryGetValue(Entry.BillNo, out var acc) ? acc : 0d;
var remaining = Math.Round(netWeight - entered, 2);
var requested = EnteredWeightCalculator.SumOneEntry(Entry.TotalPortions, Entry.PortionWeight);
if (requested <= 0d && Entry.TotalWeight is { } tw && tw > 0d)
{
requested = tw;
}
requested = Math.Round(requested, 2);
if (remaining <= 0d)
{
HandyControl.Controls.MessageBox.Warning(
$"关联磅单「{Entry.BillNo}」净重已被已有入场记录扣完(净重 {netWeight:0.##},已入场 {entered:0.##}),不能再生成入场记录。");
return false;
}
if (requested > remaining + 0.009d)
{
HandyControl.Controls.MessageBox.Warning(
$"本次入场重量 {requested:0.##} 超过磅单剩余可入场量 {remaining:0.##}(净重 {netWeight:0.##},已入场 {entered:0.##}),请调整后再保存。");
return false;
}
return true;
}
protected virtual async Task SaveAsync()
{
if (Entry == null) return;

View File

@@ -1,6 +1,6 @@
using HandyControl.Controls;
using HandyControl.Tools.Extension;
using Prism.Events;
using Prism.Navigation;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
@@ -12,7 +12,6 @@ using YY.Admin.Core.Services;
using YY.Admin.Event;
using YY.Admin.Services.Service;
using YY.Admin.Module;
using YY.Admin.Views.RawMaterialEntry;
namespace YY.Admin.ViewModels.RawMaterialEntry;
@@ -80,7 +79,9 @@ public class RawMaterialEntryListViewModel : BaseViewModel
await LoadAsync();
});
AddCommand = new DelegateCommand(OpenAddPage);
EditCommand = new DelegateCommand<MesXslRawMaterialEntry>(async e => await ShowEditDialogAsync(e));
//update-begin---author:jiangxh ---date:20260731 for【原料入场】编辑改为打开与新增相同的操作页-----------
EditCommand = new DelegateCommand<MesXslRawMaterialEntry>(OpenEditPage);
//update-end---author:jiangxh ---date:20260731 for【原料入场】编辑改为打开与新增相同的操作页-----------
DeleteCommand = new DelegateCommand<MesXslRawMaterialEntry>(async e => await DeleteAsync(e));
PrevPageCommand = new DelegateCommand(async () => { if (PageNo > 1) { PageNo--; await LoadAsync(); } });
NextPageCommand = new DelegateCommand(async () => { if ((long)PageNo * PageSize < Total) { PageNo++; await LoadAsync(); } });
@@ -161,18 +162,21 @@ public class RawMaterialEntryListViewModel : BaseViewModel
});
}
private async Task ShowEditDialogAsync(MesXslRawMaterialEntry entry)
//update-begin---author:jiangxh ---date:20260731 for【原料入场】编辑改为打开与新增相同的操作页-----------
private void OpenEditPage(MesXslRawMaterialEntry? entry)
{
if (entry == null) return;
try
if (entry == null || string.IsNullOrWhiteSpace(entry.Id)) return;
var label = string.IsNullOrWhiteSpace(entry.Barcode) ? entry.Id : entry.Barcode;
_eventAggregator.GetEvent<TabSourceSelectedEvent>().Publish(new TabSource
{
var result = await HandyControl.Controls.Dialog.Show<RawMaterialEntryEditDialogView>()
.Initialize<RawMaterialEntryEditDialogViewModel>(vm => vm.InitializeForEdit(entry))
.GetResultAsync<bool>();
if (result) await LoadAsync();
}
catch (Exception ex) { Growl.Error($"打开编辑对话框失败:{ex.Message}"); }
Name = $"编辑原料入场记录 {label}",
Icon = "\ue7ce",
ViewName = "RawMaterialEntryOperationView",
NavigationParameter = new NavigationParameters { { "editEntryId", entry.Id } }
});
}
//update-end---author:jiangxh ---date:20260731 for【原料入场】编辑改为打开与新增相同的操作页-----------
private async Task DeleteAsync(MesXslRawMaterialEntry entry)
{

View File

@@ -1,6 +1,7 @@
using HandyControl.Controls;
using HandyControl.Data;
using Prism.Commands;
using Prism.Navigation;
using System.Collections.ObjectModel;
using System.IO;
using System.Net;
@@ -18,9 +19,9 @@ using YY.Admin.Views.RawMaterialEntry;
namespace YY.Admin.ViewModels.RawMaterialEntry;
/// <summary>
/// 「新增原料入场记录独立页面:左侧表单逻辑继承编辑 VM,右侧展示当日入场简要列表并支持选中回填模板
/// 原料入场记录独立操作页(新增/编辑共用):左侧表单,右侧当日入场列表与打印预览
/// </summary>
public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogViewModel
public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogViewModel, INavigationAware
{
private const int TodayListFetchSize = 5000;
private const string RawMaterialEntryBizCode = "1900000000000000530";
@@ -30,6 +31,11 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
private readonly IPrintDotService _printDotService;
private readonly IPrintBizTemplateBindService _printBizTemplateBindService;
private readonly IPrintTemplateService _printTemplateService;
//update-begin---author:jiangxh ---date:20260731 for【原料入场】操作页支持导航进入编辑态-----------
private string? _editingEntryId;
/// <summary>是否已由 INavigationAware 处理过导航参数(避免 Loaded 兜底把编辑态冲掉)。</summary>
public bool HasAppliedNavigation { get; private set; }
//update-end---author:jiangxh ---date:20260731 for【原料入场】操作页支持导航进入编辑态-----------
private static readonly JsonSerializerOptions LayoutJsonOpts = new()
{
@@ -98,12 +104,28 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
/// <summary>
/// 「生成原材料卡片」按钮可用条件:
/// 入场记录已保存(有 Id且至少存在一行「未生成卡片 + 份数>0」的明细
/// 已打印态下用户「继续拆码」新增了行,按钮自动重新可用;全部行都已生成卡片时不可用
/// 入场记录已保存(有 Id且至少存在一行「未生成卡片 + 份数>0」的明细
/// 且入场总重尚未被已打印卡片扣完
/// </summary>
public bool CanGenerateCards =>
!string.IsNullOrWhiteSpace(Entry?.Id)
&& SplitCodeDetails.Any(d => !d.HasCard && (d.Portions ?? 0) > 0);
&& SplitCodeDetails.Any(d => !d.HasCard && (d.Portions ?? 0) > 0)
//update-begin---author:jiangxh ---date:20260731 for【原料入场】已打印卡片扣完总重后禁用生成-----------
&& GetEntryRemainingWeightForCards() > 0.009d;
//update-end---author:jiangxh ---date:20260731 for【原料入场】已打印卡片扣完总重后禁用生成-----------
/// <summary>已打印卡片累计重量 = Σ(HasCard 行 份数×每份重量)。</summary>
private double GetPrintedCardsWeight() =>
SplitCodeDetails
.Where(d => d.HasCard)
.Sum(d => (d.PortionWeight ?? 0d) * (d.Portions ?? 0));
/// <summary>入场总重减去已打印卡片累计后的剩余可打印重量。</summary>
private double GetEntryRemainingWeightForCards()
{
var total = Entry?.TotalWeight ?? 0d;
return Math.Round(total - GetPrintedCardsWeight(), 2);
}
public DelegateCommand ToggleRightPanelCommand { get; }
public DelegateCommand RefreshTodayEntriesCommand { get; }
@@ -244,13 +266,14 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
IMixerMaterialService mixerMaterialService,
IMixerMaterialTareStrategyService tareStrategyService,
ISupplierService supplierService,
IWeightRecordService weightRecordService,
IRawMaterialCardService rawMaterialCardService,
IPrintDotService printDotService,
IPrintBizTemplateBindService printBizTemplateBindService,
IPrintTemplateService printTemplateService,
IContainerExtension container,
IRegionManager regionManager)
: base(entryService, dictSyncService, mixerMaterialService, tareStrategyService, supplierService, container, regionManager)
: base(entryService, dictSyncService, mixerMaterialService, tareStrategyService, supplierService, weightRecordService, container, regionManager)
{
_rawMaterialCardService = rawMaterialCardService;
_printDotService = printDotService;
@@ -314,7 +337,10 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
private void OnSplitDetailHasCardChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(RawMaterialSplitDetailItem.HasCard)
or nameof(RawMaterialSplitDetailItem.Portions))
or nameof(RawMaterialSplitDetailItem.Portions)
//update-begin---author:jiangxh ---date:20260731 for【原料入场】每份重量变化同步刷新可生成状态-----------
or nameof(RawMaterialSplitDetailItem.PortionWeight))
//update-end---author:jiangxh ---date:20260731 for【原料入场】每份重量变化同步刷新可生成状态-----------
{
RaisePropertyChanged(nameof(CanGenerateCards));
RaisePropertyChanged(nameof(CanResplit));
@@ -329,55 +355,156 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
_selectedTodayEntry = null;
RaisePropertyChanged(nameof(SelectedTodayEntry));
_suppressTodaySelectionReaction = false;
//update-begin---author:jiangxh ---date:20260731 for【原料入场】切回新增态时清除编辑导航标记-----------
_editingEntryId = null;
//update-end---author:jiangxh ---date:20260731 for【原料入场】切回新增态时清除编辑导航标记-----------
base.InitializeForAdd();
RaisePropertyChanged(nameof(CanGenerateCards));
}
//update-begin---author:jiangxh ---date:20260731 for【原料入场】操作页支持导航进入编辑态-----------
public void OnNavigatedTo(NavigationContext navigationContext)
{
HasAppliedNavigation = true;
_ = ApplyNavigationAsync(navigationContext.Parameters);
}
public bool IsNavigationTarget(NavigationContext navigationContext)
{
var parameters = navigationContext.Parameters;
if (parameters.TryGetValue<string>("editEntryId", out var editId) && !string.IsNullOrWhiteSpace(editId))
return string.Equals(_editingEntryId, editId, StringComparison.OrdinalIgnoreCase);
return string.IsNullOrWhiteSpace(_editingEntryId) && IsAddMode;
}
public void OnNavigatedFrom(NavigationContext navigationContext) { }
private async Task ApplyNavigationAsync(INavigationParameters parameters)
{
if (parameters.TryGetValue<string>("editEntryId", out var editId) && !string.IsNullOrWhiteSpace(editId))
{
_editingEntryId = editId;
try
{
IsLoading = true;
var entry = await EntryService.GetByIdAsync(editId);
if (entry == null)
{
Growl.Warning($"未找到入场记录Id={editId}),已切换为新增。");
InitializeForAdd();
return;
}
await ApplyTodayRowToFormAsync(entry);
}
catch (Exception ex)
{
Growl.Error($"加载入场记录失败:{ex.Message}");
InitializeForAdd();
}
finally
{
IsLoading = false;
}
return;
}
_editingEntryId = null;
InitializeForAdd();
}
//update-end---author:jiangxh ---date:20260731 for【原料入场】操作页支持导航进入编辑态-----------
/// <summary>保存后按业务打印绑定拉取模板与 printData直接发送到 PrintDot 打印机(不弹预览窗)。</summary>
private async Task SaveAndPrintAsync()
{
if (IsActionBusy) return;
if (Entry == null) return;
var wasEdit = !IsAddMode;
var barcode = Entry.Barcode;
BeginActionBusy("保存并打印中...");
// 新增模式下:打印失败必须回滚删除刚生成的入场记录,避免“未打印成功却已落库”
var createdInThisAttempt = IsAddMode;
string? createdEntryId = null;
try
{
IsLoading = true;
if (!await PersistEntryCoreAsync()) return;
string? entryId = Entry.Id;
// Persist 后条码/主键可能已回写,再取一次
var barcode = Entry?.Barcode;
string? entryId = Entry?.Id;
if (string.IsNullOrWhiteSpace(entryId) && !string.IsNullOrWhiteSpace(barcode))
{
var page = await EntryService.PageAsync(1, 50, barcode: barcode);
entryId = page.Records
entryId = page.Records?
.FirstOrDefault(e => string.Equals(e.Barcode, barcode, StringComparison.OrdinalIgnoreCase))?.Id
?? page.Records
?? page.Records?
.OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue)
.FirstOrDefault()?.Id;
if (!string.IsNullOrWhiteSpace(entryId) && Entry != null)
Entry.Id = entryId;
}
if (createdInThisAttempt)
createdEntryId = entryId;
async Task RollbackCreatedAsync(string reason)
{
if (!createdInThisAttempt || string.IsNullOrWhiteSpace(createdEntryId))
{
HandyControl.Controls.MessageBox.Error(reason);
return;
}
try
{
await EntryService.DeleteAsync(createdEntryId);
if (Entry != null)
{
Entry.Id = null;
Entry.Barcode = null;
Entry.BatchNo = null;
}
Result = false;
await LoadTodayEntriesAsync();
}
catch (Exception delEx)
{
HandyControl.Controls.MessageBox.Error(
$"{reason}\n同时回滚删除入场记录失败{delEx.Message}\n请手动删除刚生成的记录。");
return;
}
HandyControl.Controls.MessageBox.Error($"{reason}\n已撤销本次入场记录未成功打印不会保留数据。");
}
if (string.IsNullOrWhiteSpace(entryId))
{
HandyControl.Controls.MessageBox.Warning("保存成功,但未能解析记录主键,无法打印。请从右侧列表选中该条后再试。");
await RollbackCreatedAsync("保存未能解析记录主键,无法打印。");
return;
}
else
{
var (templateJson, printDataJson, err) = await EntryService.PrepareNativePrintAsync(entryId);
if (!string.IsNullOrWhiteSpace(err))
{
HandyControl.Controls.MessageBox.Error($"保存成功,但打印准备失败:{err}");
await RollbackCreatedAsync($"打印准备失败:{err}");
return;
}
else
if (string.IsNullOrWhiteSpace(templateJson))
{
await RollbackCreatedAsync("打印模板为空,请先配置业务打印绑定。");
return;
}
var selectedPrinterName = SelectedPrinter?.Name?.Trim();
if (string.IsNullOrWhiteSpace(selectedPrinterName))
{
HandyControl.Controls.MessageBox.Warning("保存成功,但未选择打印机请先选择打印机后再试。");
await RollbackCreatedAsync("未选择打印机请先选择打印机后再试。");
return;
}
else
{
JsonObject? dataObj = JsonNode.Parse(printDataJson) as JsonObject;
JsonObject? dataObj = null;
if (!string.IsNullOrWhiteSpace(printDataJson))
dataObj = JsonNode.Parse(printDataJson) as JsonObject;
dataObj ??= new JsonObject();
var html = NativePrintRenderService.RenderToHtml(templateJson, dataObj);
var tpl = BuildPrintTemplateForEntry(templateJson);
@@ -385,15 +512,18 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
html,
tpl.PaperWidthMm ?? 210d,
tpl.PaperHeightMm ?? 297d);
if (string.IsNullOrWhiteSpace(pdfBase64))
{
await RollbackCreatedAsync("PDF 生成结果为空,打印失败。");
return;
}
var jobName = string.IsNullOrWhiteSpace(barcode) ? "原料入场记录" : $"原料入场记录-{barcode}";
await _printDotService.PrintAsync(selectedPrinterName, pdfBase64, jobName, 1);
}
}
}
Growl.Success(new GrowlInfo
{
Message = "保存成功",
Message = "保存并打印成功",
ShowDateTime = false,
WaitTime = 1
});
@@ -404,9 +534,35 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
InitializeForAdd();
}
catch (Exception ex)
{
//update-begin---author:jiangxh ---date:20260731 for【原料入场】保存并打印失败时回滚新增记录-----------
if (createdInThisAttempt && !string.IsNullOrWhiteSpace(createdEntryId))
{
try
{
await EntryService.DeleteAsync(createdEntryId);
if (Entry != null)
{
Entry.Id = null;
Entry.Barcode = null;
Entry.BatchNo = null;
}
Result = false;
await LoadTodayEntriesAsync();
HandyControl.Controls.MessageBox.Error($"打印失败:{ex.Message}\n已撤销本次入场记录未成功打印不会保留数据。");
}
catch (Exception delEx)
{
HandyControl.Controls.MessageBox.Error(
$"打印失败:{ex.Message}\n同时回滚删除入场记录失败{delEx.Message}\n请手动删除刚生成的记录。");
}
}
else
{
HandyControl.Controls.MessageBox.Error($"保存或打印失败:{ex.Message}");
}
//update-end---author:jiangxh ---date:20260731 for【原料入场】保存并打印失败时回滚新增记录-----------
}
finally
{
IsLoading = false;
@@ -536,6 +692,9 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
private async Task ApplyTodayRowToFormAsync(MesXslRawMaterialEntry src)
{
// 复用基类 InitializeForEdit保留 Id/Barcode/BatchNo/状态等所有字段,标题自动切到「编辑原料入场记录」
//update-begin---author:jiangxh ---date:20260731 for【原料入场】今日列表选中同步编辑导航标记-----------
_editingEntryId = src.Id;
//update-end---author:jiangxh ---date:20260731 for【原料入场】今日列表选中同步编辑导航标记-----------
base.InitializeForEdit(src);
RaisePropertyChanged(nameof(CanGenerateCards));
@@ -706,17 +865,36 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
}
}
// 总重核对:拆码明细合计 vs 基础资料总重
//update-begin---author:jiangxh ---date:20260731 for【原料入场】已打印卡片扣完总重禁止再打印-----------
{
var entryTotal = Entry.TotalWeight ?? 0d;
var printedWeight = Math.Round(GetPrintedCardsWeight(), 2);
var remaining = Math.Round(entryTotal - printedWeight, 2);
if (remaining <= 0d)
{
HandyControl.Controls.MessageBox.Warning(
$"入场总重 {entryTotal:0.##} 已被已打印原材料卡片总重 {printedWeight:0.##} 扣完,不能再打印原材料卡片。");
return;
}
var pendingWeight = Math.Round(
pendingRows.Sum(d => (d.PortionWeight ?? 0d) * (d.Portions ?? 0)), 2);
if (pendingWeight > remaining + 0.009d)
{
HandyControl.Controls.MessageBox.Warning(
$"待打印卡片总重 {pendingWeight:0.##} 超过剩余可打印重量 {remaining:0.##}(入场总重 {entryTotal:0.##},已打印 {printedWeight:0.##}),请调整拆码明细后再生成。");
return;
}
}
//update-end---author:jiangxh ---date:20260731 for【原料入场】已打印卡片扣完总重禁止再打印-----------
// 总重核对:拆码明细合计 vs 基础资料总重(剩余不足时上面已硬拦截;此处仅提示拆码合计偏少)
{
var splitTotal = SplitCodeDetails.Sum(d => (d.PortionWeight ?? 0d) * (d.Portions ?? 0));
var basicTotal = Entry.TotalWeight ?? 0d;
if (Math.Abs(splitTotal - basicTotal) > 0.01d)
if (basicTotal - splitTotal > 0.01d)
{
string hint;
if (splitTotal > basicTotal)
hint = $"本次打印拆码总重 {splitTotal:0.##},超出基础资料总重 {basicTotal:0.##},超出部分是否需要核对?";
else
hint = $"本次打印拆码总重 {splitTotal:0.##},少于基础资料总重 {basicTotal:0.##},剩余部分将无法继续拆码,是否继续?";
var hint = $"本次打印拆码总重 {splitTotal:0.##},少于基础资料总重 {basicTotal:0.##},剩余部分将无法继续拆码,是否继续?";
var confirm = System.Windows.MessageBox.Show(
hint,
"总重核对",

View File

@@ -1232,7 +1232,7 @@
Style="{StaticResource ButtonPrimary}"
IsEnabled="{Binding IsNotActionBusy}"
Width="168" Margin="0,0,15,0"
ToolTip="保存成功后直接发送到已选打印机(不弹预览窗口)"/>
ToolTip="仅打印成功后保留入场记录;打印失败会自动撤销本次新增"/>
<Button Content="保存"
Command="{Binding SaveCommand}"
Style="{StaticResource ButtonDefault}"

View File

@@ -84,7 +84,11 @@ public partial class RawMaterialEntryOperationView : UserControl
if (DataContext is RawMaterialEntryOperationViewModel vm && !_initialized)
{
//update-begin---author:jiangxh ---date:20260731 for【原料入场】Loaded 兜底不覆盖导航编辑态-----------
// 有导航参数时由 INavigationAware.OnNavigatedTo 初始化;此处仅无参兜底为新增。
if (!vm.HasAppliedNavigation)
vm.InitializeForAdd();
//update-end---author:jiangxh ---date:20260731 for【原料入场】Loaded 兜底不覆盖导航编辑态-----------
_initialized = true;
_ = vm.LoadTodayEntriesOnFirstShowAsync();
}