From ef28b93909d9b1825f991b6a231b812a5dfe07a1 Mon Sep 17 00:00:00 2001 From: jiangxh <1217934998@qq.com> Date: Fri, 31 Jul 2026 14:27:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=AB=AF=E5=8E=9F=E6=9D=90?= =?UTF-8?q?=E6=96=99=E5=85=A5=E5=9C=BA=E8=AE=B0=E5=BD=95=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- XSLPrintDot/backend_windows.go | 66 ++++- XSLPrintDot/docs/usage_guide_en.md | 4 +- XSLPrintDot/docs/usage_guide_zh.md | 6 +- .../jeecg-module-xslmes/doc/代码修改日志 | 20 ++ .../MesXslDesktopAnonController.java | 5 +- .../Service/Print/PrintDotService.cs | 47 ++- .../RawMaterialEntryService.cs | 68 ++++- .../Infrastructure/Print/HtmlToPdfRenderer.cs | 7 +- .../RawMaterialEntryEditDialogViewModel.cs | 71 +++++ .../RawMaterialEntryListViewModel.cs | 28 +- .../RawMaterialEntryOperationViewModel.cs | 276 ++++++++++++++---- .../RawMaterialEntryOperationView.xaml | 2 +- .../RawMaterialEntryOperationView.xaml.cs | 6 +- 13 files changed, 531 insertions(+), 75 deletions(-) diff --git a/XSLPrintDot/backend_windows.go b/XSLPrintDot/backend_windows.go index efd21d6e..dd08a016 100644 --- a/XSLPrintDot/backend_windows.go +++ b/XSLPrintDot/backend_windows.go @@ -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: diff --git a/XSLPrintDot/docs/usage_guide_en.md b/XSLPrintDot/docs/usage_guide_en.md index 3c116888..dce43e87 100644 --- a/XSLPrintDot/docs/usage_guide_en.md +++ b/XSLPrintDot/docs/usage_guide_en.md @@ -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 diff --git a/XSLPrintDot/docs/usage_guide_zh.md b/XSLPrintDot/docs/usage_guide_zh.md index ec81c39c..07d8c02a 100644 --- a/XSLPrintDot/docs/usage_guide_zh.md +++ b/XSLPrintDot/docs/usage_guide_zh.md @@ -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 分钟)。 --- diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index bcef0e90..2cc5388a 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -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: ��XSLMES-20260730-DING-PULL������̨���ֶ���ȡ���������ǿ�����ܼ��� --- + +-- author:jiangxh---date:20260731--for: ԭ볡anon˱沢ӡдId --- +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: ԭ볡沢ӡʧܻعؿֹ --- +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: ԭ볡༭ΪͬIJҳ --- +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: ԭ볡ѴӡƬؽֹɿƬ --- +yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java index 7171e29d..ba1c17c7 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java @@ -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 = "原料入场记录-免密编辑") diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotService.cs b/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotService.cs index f3ec8627..1e1ed47d 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotService.cs @@ -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(); - if (status == null) continue; - if (status == "success") return; - var rawMsg = resDoc?["message"]?.GetValue() ?? "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(out var s)) return s; + if (jv.TryGetValue(out var b)) return b ? "true" : "false"; + return jv.ToJsonString().Trim('"'); + } + return node.ToJsonString(); + } + catch + { + return null; + } + } + + /// PrintDot 队列入队超时:视为已提交(兼容旧版桥接器)。 + private static bool IsPrintQueueAppearSoftSuccess(string? rawMsg) + { + if (string.IsNullOrWhiteSpace(rawMsg)) return false; + return rawMsg.Contains("print job not queued within", StringComparison.OrdinalIgnoreCase); + } + /// /// 将 PrintDot 返回的部分英文错误转换为带本地处理步骤的中文提示。 /// 与 web 端 printDotBridge.ts::enhancePrintDotErrorMessage 行为一致,方便桌面端用户自助排查。 diff --git a/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs index 8f56beee..27fe42b8 100644 --- a/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs @@ -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/条码,避免保存并打印空引用----------- + } + + /// 把 Clone 上生成的主键/条码/批次写回调用方 Entry,供保存并打印继续使用。 + 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 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:【原料入场】新增后回写服务端主键----------- + } + + /// 新增接口:解析 Result.OK(msg, id) 的 result 主键。 + 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) diff --git a/yy-admin-master/YY.Admin/Infrastructure/Print/HtmlToPdfRenderer.cs b/yy-admin-master/YY.Admin/Infrastructure/Print/HtmlToPdfRenderer.cs index 25cd11cb..bb22cba6 100644 --- a/yy-admin-master/YY.Admin/Infrastructure/Print/HtmlToPdfRenderer.cs +++ b/yy-admin-master/YY.Admin/Infrastructure/Print/HtmlToPdfRenderer.cs @@ -17,7 +17,7 @@ public static class HtmlToPdfRenderer { var tcs = new TaskCompletionSource(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; } diff --git a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs index 77007198..2235e057 100644 --- a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs @@ -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; } + /// + /// 校验关联磅单剩余可入场量:剩余 = 净重 - 其他已存在入场记录累计(份数×每份重量)。 + /// 剩余 ≤ 0 或本次申请量超过剩余时禁止保存。编辑时排除当前记录自身。 + /// + protected async Task 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; diff --git a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs index a91f980e..9920638c 100644 --- a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryListViewModel.cs @@ -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(async e => await ShowEditDialogAsync(e)); + //update-begin---author:jiangxh ---date:20260731 for:【原料入场】编辑改为打开与新增相同的操作页----------- + EditCommand = new DelegateCommand(OpenEditPage); + //update-end---author:jiangxh ---date:20260731 for:【原料入场】编辑改为打开与新增相同的操作页----------- DeleteCommand = new DelegateCommand(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().Publish(new TabSource { - var result = await HandyControl.Controls.Dialog.Show() - .Initialize(vm => vm.InitializeForEdit(entry)) - .GetResultAsync(); - 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) { diff --git a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs index 4347680f..674738c5 100644 --- a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs @@ -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; /// -/// 「新增原料入场记录」独立页面:左侧表单逻辑继承编辑 VM,右侧展示当日入场简要列表并支持选中回填模板。 +/// 原料入场记录独立操作页(新增/编辑共用):左侧表单,右侧当日入场列表与打印预览。 /// -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; + /// 是否已由 INavigationAware 处理过导航参数(避免 Loaded 兜底把编辑态冲掉)。 + 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 /// /// 「生成原材料卡片」按钮可用条件: - /// 入场记录已保存(有 Id)且至少存在一行「未生成卡片 + 份数>0」的明细。 - /// 已打印态下用户「继续拆码」新增了行,按钮自动重新可用;全部行都已生成卡片时不可用。 + /// 入场记录已保存(有 Id)且至少存在一行「未生成卡片 + 份数>0」的明细, + /// 且入场总重尚未被已打印卡片扣完。 /// 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:【原料入场】已打印卡片扣完总重后禁用生成----------- + + /// 已打印卡片累计重量 = Σ(HasCard 行 份数×每份重量)。 + private double GetPrintedCardsWeight() => + SplitCodeDetails + .Where(d => d.HasCard) + .Sum(d => (d.PortionWeight ?? 0d) * (d.Portions ?? 0)); + + /// 入场总重减去已打印卡片累计后的剩余可打印重量。 + 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,71 +355,175 @@ 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("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("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:【原料入场】操作页支持导航进入编辑态----------- + /// 保存后按业务打印绑定拉取模板与 printData,直接发送到 PrintDot 打印机(不弹预览窗)。 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)) { - var (templateJson, printDataJson, err) = await EntryService.PrepareNativePrintAsync(entryId); - if (!string.IsNullOrWhiteSpace(err)) - { - HandyControl.Controls.MessageBox.Error($"保存成功,但打印准备失败:{err}"); - } - else - { - var selectedPrinterName = SelectedPrinter?.Name?.Trim(); - if (string.IsNullOrWhiteSpace(selectedPrinterName)) - { - HandyControl.Controls.MessageBox.Warning("保存成功,但未选择打印机。请先选择打印机后再试。"); - } - else - { - JsonObject? dataObj = JsonNode.Parse(printDataJson) as JsonObject; - dataObj ??= new JsonObject(); - var html = NativePrintRenderService.RenderToHtml(templateJson, dataObj); - var tpl = BuildPrintTemplateForEntry(templateJson); - var pdfBase64 = await HtmlToPdfRenderer.RenderAsync( - html, - tpl.PaperWidthMm ?? 210d, - tpl.PaperHeightMm ?? 297d); - var jobName = string.IsNullOrWhiteSpace(barcode) ? "原料入场记录" : $"原料入场记录-{barcode}"; - await _printDotService.PrintAsync(selectedPrinterName, pdfBase64, jobName, 1); - } - } + await RollbackCreatedAsync($"打印准备失败:{err}"); + return; } + if (string.IsNullOrWhiteSpace(templateJson)) + { + await RollbackCreatedAsync("打印模板为空,请先配置业务打印绑定。"); + return; + } + + var selectedPrinterName = SelectedPrinter?.Name?.Trim(); + if (string.IsNullOrWhiteSpace(selectedPrinterName)) + { + await RollbackCreatedAsync("未选择打印机,请先选择打印机后再试。"); + return; + } + + 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); + var pdfBase64 = await HtmlToPdfRenderer.RenderAsync( + 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 }); @@ -405,7 +535,33 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView } catch (Exception ex) { - HandyControl.Controls.MessageBox.Error($"保存或打印失败:{ex.Message}"); + //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 { @@ -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, "总重核对", diff --git a/yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryOperationView.xaml b/yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryOperationView.xaml index 3f39ae71..671b1efb 100644 --- a/yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryOperationView.xaml +++ b/yy-admin-master/YY.Admin/Views/RawMaterialEntry/RawMaterialEntryOperationView.xaml @@ -1232,7 +1232,7 @@ Style="{StaticResource ButtonPrimary}" IsEnabled="{Binding IsNotActionBusy}" Width="168" Margin="0,0,15,0" - ToolTip="保存成功后直接发送到已选打印机(不弹预览窗口)"/> + ToolTip="仅打印成功后保留入场记录;打印失败会自动撤销本次新增"/>