using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Security.Cryptography; using System.Text; using Newtonsoft.Json.Linq; namespace Lskj.CommandKernel { public sealed class BusinessWriteResult { public BusinessWriteResult() { Data = new Dictionary(StringComparer.OrdinalIgnoreCase); } public bool Success { get; set; } public string Code { get; set; } public string Message { get; set; } public string RecordId { get; set; } public bool NeedsUi { get; set; } public bool IdempotencyReplayed { get; set; } public string AppliedIdempotencyKey { get; set; } public string AppliedInputFingerprint { get; set; } public string TransactionEvidenceId { get; set; } public string BusinessAuditId { get; set; } public IDictionary Data { get; private set; } } public sealed class PurchaseSourceAllocation { public string InvoiceLineId { get; set; } public string SourceOrderId { get; set; } public string SourceOrderNumber { get; set; } public string SourceLineId { get; set; } public string SourceUnit { get; set; } public decimal Quantity { get; set; } public decimal SourceRemainingQuantity { get; set; } public decimal SourceUnitPrice { get; set; } public decimal SourceTaxRate { get; set; } public decimal SourceExchangeRate { get; set; } } public sealed class PurchaseInvoiceCreateRequest { public PurchaseInvoiceCreateRequest() { Allocations = new List(); } public PurchaseInvoiceDraft Draft { get; set; } public IList Allocations { get; private set; } } /// /// 客户采购模块适配器。实现必须使用参数化查询,并在一个数据库事务内 /// 锁定/复核来源数量、登记业务幂等键、保存主从表和提交审计结果。 /// public interface IPurchaseInvoiceWorkflowAdapter : IBusinessWorkflowAdapterReadiness { string ModuleCode { get; } bool InvoiceNumberExists(string supplierCode, string invoiceNumber, CommandExecutionContext context); IList QueryOpenSourceLines(PurchaseInvoiceDraft draft, CommandExecutionContext context); BusinessWriteResult CreatePurchaseDocument( PurchaseInvoiceCreateRequest request, CommandExecutionContext context, string idempotencyKey, string inputFingerprint); } public sealed class PurchaseInvoiceCreateCommandHandler : ICommandHandler, ICommandExecutionFollowupProvider { private const string DraftKey = "purchase.invoice.draft"; private const string AllocationKey = "purchase.invoice.allocations"; private const string ResolutionProofKey = "purchase.invoice.resolution-proof"; private readonly IPurchaseInvoiceWorkflowAdapter _adapter; private readonly PurchaseInvoiceMatchOptions _options; private readonly IPurchaseResolutionProofService _proofs; public PurchaseInvoiceCreateCommandHandler( IPurchaseInvoiceWorkflowAdapter adapter, PurchaseInvoiceMatchOptions options, IPurchaseResolutionProofService proofs) { if (adapter == null) throw new ArgumentNullException("adapter"); if (proofs == null) throw new ArgumentNullException("proofs"); if (string.IsNullOrWhiteSpace(adapter.ModuleCode)) throw new ArgumentException("采购适配器必须声明模块编号。", "adapter"); BusinessAdapterRegistrationGate.EnsureRuntimeReady(adapter); _adapter = adapter; _options = options ?? new PurchaseInvoiceMatchOptions(); _proofs = proofs; Descriptor = new CommandDescriptor { Name = "purchase.invoice.create", Version = "1.4", SchemaVersion = "1.4", InputSchema = CommandInputSchemas.PurchaseInvoiceCreate(), Description = "根据已解析发票和采购来源创建采购业务单据", RequiredPermission = "module.edit:" + adapter.ModuleCode, Risk = CommandRisk.Write, RequiresConfirmation = true, RequiresIdempotencyKey = true }; } public CommandDescriptor Descriptor { get; private set; } public CommandPlan Plan(IDictionary input, CommandExecutionContext context) { BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context); object proofValue; string resolutionProof = input != null && input.TryGetValue("resolutionProof", out proofValue) ? Convert.ToString(proofValue) : null; Dictionary draftInput = input == null ? new Dictionary() : new Dictionary(input, StringComparer.OrdinalIgnoreCase); draftInput.Remove("resolutionProof"); PurchaseInvoiceDraft draft = CommandInput.Convert(draftInput); if (!_proofs.Validate(resolutionProof, draft, context)) { CommandPlan rejected = new CommandPlan { ModuleCode = _adapter.ModuleCode, Valid = false }; rejected.Data["outcomeCode"] = "purchase_match_invalid"; rejected.Data["title"] = "采购发票创建预览"; rejected.Data["preview"] = new Dictionary { { "供应商", draft.SupplierCode }, { "发票号码", draft.InvoiceNumber }, { "状态", "解析凭证无效或已过期" } }; rejected.Warnings.Add( "请先重新运行 purchase.invoice.resolve,并把服务器返回的 resolvedInput 原样用于创建预览。"); return rejected; } bool duplicate = !string.IsNullOrWhiteSpace(draft.SupplierCode) && !string.IsNullOrWhiteSpace(draft.InvoiceNumber) && _adapter.InvoiceNumberExists(draft.SupplierCode, draft.InvoiceNumber, context); IList sources = CanQuerySources(draft) ? (_adapter.QueryOpenSourceLines(draft, context) ?? new List()) : new List(); PurchaseInvoiceMatchPlan match = PurchaseInvoiceMatcher.Match(draft, sources, _options); List allocations = ToAllocations(match); CommandPlan plan = new CommandPlan { ModuleCode = _adapter.ModuleCode, Valid = match.Executable && !duplicate }; plan.SetServerData(DraftKey, draft); plan.SetServerData(AllocationKey, allocations); plan.SetServerData(ResolutionProofKey, resolutionProof); plan.Data["outcomeCode"] = plan.Valid ? "purchase_create_ready" : "purchase_match_invalid"; plan.Data["title"] = "采购发票创建预览"; string[] sourceDocumentHashes = SourceDocumentHashes(draft); plan.Data["sourceDocumentCount"] = sourceDocumentHashes.Length; plan.Data["sourceDocumentSetSha256"] = SourceDocumentSetSha256( sourceDocumentHashes); plan.Data["preview"] = new Dictionary { { "供应商", draft.SupplierCode }, { "发票号码", draft.InvoiceNumber }, { "发票日期", draft.InvoiceDate == DateTime.MinValue ? null : draft.InvoiceDate.ToString("yyyy-MM-dd") }, { "币种", draft.CurrencyCode }, { "不含税金额", draft.TotalWithoutTax }, { "税额", draft.TaxAmount }, { "价税合计", draft.TotalWithTax }, { "来源附件", draft.SourceDocuments == null ? new string[0] : draft.SourceDocuments.Where(item => item != null && !string.IsNullOrWhiteSpace(item.Sha256) && item.Sha256.Length >= 12).Select(item => (item.Filename ?? "attachment") + " (" + item.Sha256.Substring(0, 12) + "…)" ).ToArray() }, { "明细汇总不含税", match.CalculatedTotalWithoutTax }, { "明细汇总税额", match.CalculatedTaxAmount }, { "明细汇总价税", match.CalculatedTotalWithTax }, { "发票行数", draft.Lines == null ? 0 : draft.Lines.Count }, { "确定匹配行数", allocations.Count }, { "来源采购单", allocations.Select(item => item.SourceOrderNumber).Distinct(StringComparer.OrdinalIgnoreCase).ToArray() }, { "来源汇率", allocations.Select(item => item.SourceExchangeRate).Distinct().ToArray() }, { "重复发票", duplicate } }; plan.Data["lineMatches"] = match.Lines.Select(item => (object)new { invoiceLineId = item.InvoiceLine == null ? null : item.InvoiceLine.LineId, materialCode = item.InvoiceLine == null ? null : item.InvoiceLine.MaterialCode, invoiceUnit = item.InvoiceLine == null ? null : item.InvoiceLine.Unit, invoiceQuantity = item.InvoiceLine == null ? 0 : item.InvoiceLine.Quantity, invoiceUnitPrice = item.InvoiceLine == null ? 0 : item.InvoiceLine.UnitPrice, invoiceTaxRate = item.InvoiceLine == null ? 0 : item.InvoiceLine.TaxRate, invoiceTaxAmount = item.InvoiceLine == null ? 0 : item.InvoiceLine.TaxAmount, invoiceLineAmount = item.InvoiceLine == null ? 0 : item.InvoiceLine.LineAmount, status = item.Status.ToString().ToLowerInvariant(), sourceOrderNumber = item.SelectedSource == null ? null : item.SelectedSource.SourceOrderNumber, sourceLineId = item.SelectedSource == null ? null : item.SelectedSource.SourceLineId, unit = item.SelectedSource == null ? null : item.SelectedSource.Unit, remainingQuantity = item.SelectedSource == null ? 0 : item.SelectedSource.RemainingQuantity, unitPrice = item.SelectedSource == null ? 0 : item.SelectedSource.UnitPrice, taxRate = item.SelectedSource == null ? 0 : item.SelectedSource.TaxRate, exchangeRate = item.SelectedSource == null ? 0 : item.SelectedSource.ExchangeRate, sourceUnit = item.SelectedSource == null ? null : item.SelectedSource.Unit, sourceRemainingQuantity = item.SelectedSource == null ? 0 : item.SelectedSource.RemainingQuantity, sourceUnitPrice = item.SelectedSource == null ? 0 : item.SelectedSource.UnitPrice, sourceTaxRate = item.SelectedSource == null ? 0 : item.SelectedSource.TaxRate, sourceExchangeRate = item.SelectedSource == null ? 0 : item.SelectedSource.ExchangeRate, candidateCount = item.Candidates.Count, issues = item.Issues }).ToList(); if (duplicate) plan.Warnings.Add("同一供应商下已存在相同发票号码,禁止重复创建。"); foreach (string issue in match.Issues) plan.Warnings.Add(issue); foreach (PurchaseInvoiceLineMatch line in match.Lines) foreach (string issue in line.Issues) plan.Warnings.Add((line.InvoiceLine == null ? "明细" : line.InvoiceLine.LineId) + ":" + issue); return plan; } public CommandResult Execute(CommandPlan plan, CommandExecutionContext context) { BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context); PurchaseInvoiceDraft draft = plan.GetServerData(DraftKey); string resolutionProof = plan.GetServerData(ResolutionProofKey); if (!_proofs.Validate(resolutionProof, draft, context)) throw new CommandKernelException( "purchase_resolution_proof_expired", "采购主数据解析凭证已过期、被篡改或 ERP 会话已变化,请重新解析。", 6); List expected = plan.GetServerData>(AllocationKey); if (_adapter.InvoiceNumberExists(draft.SupplierCode, draft.InvoiceNumber, context)) throw new CommandKernelException("duplicate_invoice", "该供应商的发票号码已经存在,已阻止重复创建。", 6); IList currentSources = _adapter.QueryOpenSourceLines(draft, context) ?? new List(); PurchaseInvoiceMatchPlan current = PurchaseInvoiceMatcher.Match(draft, currentSources, _options); List actual = ToAllocations(current); if (!current.Executable || !SameAllocations(expected, actual)) { throw new CommandKernelException( "purchase_source_changed", "采购来源、剩余数量、价格或税率在确认后发生变化,请重新生成预览。", 6); } PurchaseInvoiceCreateRequest request = new PurchaseInvoiceCreateRequest { Draft = draft }; foreach (PurchaseSourceAllocation allocation in actual) request.Allocations.Add(allocation); BusinessWriteResult written = _adapter.CreatePurchaseDocument( request, context, context.IdempotencyKey, plan.InputFingerprint); return ToCommandResult( written, "purchase_document_created", "采购业务单据已创建。", context.IdempotencyKey, plan.InputFingerprint); } public bool TryCreateFollowup( CommandPlan completedPlan, CommandResult completedResult, CommandExecutionContext context, out CommandFollowupRequest followup) { return TryCreateNeedsUiNavigation( completedPlan, completedResult, out followup); } internal static bool TryCreateNeedsUiNavigation( CommandPlan completedPlan, CommandResult completedResult, out CommandFollowupRequest followup) { followup = null; object rawNeedsUi; if (completedPlan == null || completedResult == null || !completedResult.Success || string.IsNullOrWhiteSpace(completedResult.RecordId) || string.IsNullOrWhiteSpace(completedPlan.ModuleCode) || !completedResult.Data.TryGetValue("needsUi", out rawNeedsUi) || !(rawNeedsUi is bool) || !(bool)rawNeedsUi) return false; followup = new CommandFollowupRequest { CommandName = "module.navigate" }; followup.Input["moduleCode"] = completedPlan.ModuleCode; return true; } private static string[] SourceDocumentHashes(PurchaseInvoiceDraft draft) { if (draft == null || draft.SourceDocuments == null) return new string[0]; return draft.SourceDocuments .Where(item => item != null && IsLowerSha256(item.Sha256)) .Select(item => item.Sha256) .Distinct(StringComparer.Ordinal) .OrderBy(item => item, StringComparer.Ordinal) .ToArray(); } private static string SourceDocumentSetSha256(IEnumerable hashes) { string canonical = string.Join("\n", hashes ?? new string[0]); using (SHA256 sha = SHA256.Create()) { byte[] digest = sha.ComputeHash(Encoding.ASCII.GetBytes(canonical)); StringBuilder result = new StringBuilder(digest.Length * 2); foreach (byte item in digest) result.Append(item.ToString("x2")); return result.ToString(); } } private static bool IsLowerSha256(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length != 64) return false; foreach (char item in value) if (!((item >= '0' && item <= '9') || (item >= 'a' && item <= 'f'))) return false; return true; } private static List ToAllocations(PurchaseInvoiceMatchPlan match) { return match.Lines.Where(item => item.Status == InvoiceLineMatchStatus.Exact && item.InvoiceLine != null && item.SelectedSource != null) .Select(item => new PurchaseSourceAllocation { InvoiceLineId = item.InvoiceLine.LineId, SourceOrderId = item.SelectedSource.SourceOrderId, SourceOrderNumber = item.SelectedSource.SourceOrderNumber, SourceLineId = item.SelectedSource.SourceLineId, SourceUnit = item.SelectedSource.Unit, Quantity = item.InvoiceLine.Quantity, SourceRemainingQuantity = item.SelectedSource.RemainingQuantity, SourceUnitPrice = item.SelectedSource.UnitPrice, SourceTaxRate = item.SelectedSource.TaxRate, SourceExchangeRate = item.SelectedSource.ExchangeRate }).ToList(); } private static bool CanQuerySources(PurchaseInvoiceDraft draft) { return draft != null && !string.IsNullOrWhiteSpace(draft.SupplierCode) && !string.IsNullOrWhiteSpace(draft.CurrencyCode) && !string.IsNullOrWhiteSpace(draft.InvoiceNumber) && draft.InvoiceDate != DateTime.MinValue && draft.InvoiceDate.Kind == DateTimeKind.Unspecified && draft.InvoiceDate.TimeOfDay == TimeSpan.Zero && draft.Lines != null && draft.Lines.Count > 0 && draft.Lines.All(item => item != null && !string.IsNullOrWhiteSpace(item.LineId) && !string.IsNullOrWhiteSpace(item.MaterialCode) && !string.IsNullOrWhiteSpace(item.Unit) && item.Quantity > 0) && !draft.Lines.GroupBy( item => item.LineId.Trim(), StringComparer.OrdinalIgnoreCase) .Any(group => group.Count() > 1); } private static bool SameAllocations( IEnumerable expected, IEnumerable actual) { string[] left = expected.Select(AllocationIdentity).OrderBy(item => item, StringComparer.Ordinal).ToArray(); string[] right = actual.Select(AllocationIdentity).OrderBy(item => item, StringComparer.Ordinal).ToArray(); return left.SequenceEqual(right, StringComparer.Ordinal); } private static string AllocationIdentity(PurchaseSourceAllocation item) { return (item.InvoiceLineId ?? string.Empty) + "\u001f" + (item.SourceOrderId ?? string.Empty) + "\u001f" + (item.SourceOrderNumber ?? string.Empty) + "\u001f" + (item.SourceLineId ?? string.Empty) + "\u001f" + (item.SourceUnit ?? string.Empty) + "\u001f" + item.Quantity.ToString(System.Globalization.CultureInfo.InvariantCulture) + "\u001f" + item.SourceRemainingQuantity.ToString(System.Globalization.CultureInfo.InvariantCulture) + "\u001f" + item.SourceUnitPrice.ToString(System.Globalization.CultureInfo.InvariantCulture) + "\u001f" + item.SourceTaxRate.ToString(System.Globalization.CultureInfo.InvariantCulture) + "\u001f" + item.SourceExchangeRate.ToString(System.Globalization.CultureInfo.InvariantCulture); } internal static CommandResult ToCommandResult( BusinessWriteResult written, string defaultCode, string defaultMessage, string expectedIdempotencyKey, string expectedInputFingerprint) { if (written == null) throw new CommandKernelException("adapter_result_missing", "业务适配器没有返回结果。", 6); if (written.Success) { if (string.IsNullOrWhiteSpace(written.RecordId) || !SafeEvidenceId(written.TransactionEvidenceId) || !SafeEvidenceId(written.BusinessAuditId) || string.IsNullOrWhiteSpace(expectedIdempotencyKey) || !CommandInputFingerprint.IsValid(expectedInputFingerprint) || !string.Equals( written.AppliedIdempotencyKey, expectedIdempotencyKey, StringComparison.Ordinal) || !string.Equals( written.AppliedInputFingerprint, expectedInputFingerprint, StringComparison.OrdinalIgnoreCase)) { throw new CommandKernelException( "adapter_commit_evidence_missing", "业务适配器未返回完整的事务、审计和幂等证据,结果不能视为成功。", 6); } } CommandResult result = new CommandResult { Success = written.Success, Code = string.IsNullOrWhiteSpace(written.Code) ? defaultCode : written.Code, Message = string.IsNullOrWhiteSpace(written.Message) ? defaultMessage : written.Message, RecordId = written.RecordId, Replayed = written.IdempotencyReplayed, TransactionEvidenceId = written.TransactionEvidenceId, BusinessAuditId = written.BusinessAuditId }; foreach (KeyValuePair item in written.Data) result.Data[item.Key] = item.Value; // needsUi 是固定写契约列,不允许适配器的扩展数据覆盖该控制语义。 result.Data["needsUi"] = written.NeedsUi; return result; } private static bool SafeEvidenceId(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length > 128) return false; foreach (char item in value) { if (!char.IsLetterOrDigit(item) && item != '-' && item != '_' && item != '.' && item != ':') return false; } return true; } } public interface ILeaveWorkflowAdapter : IWorkingTimeCalculator, ILeaveConflictProvider, IBusinessWorkflowAdapterReadiness { string ModuleCode { get; } string GetCurrentEmployeeId(CommandExecutionContext context); bool CanApplyForOthers(CommandExecutionContext context); bool IsLeaveTypeEnabled(string leaveTypeCode, CommandExecutionContext context); bool IsLeaveFlowTypeEnabled(string flowTypeCode, CommandExecutionContext context); DateTime GetCurrentLocalTime(CommandExecutionContext context); BusinessWriteResult CreateLeaveDraft( LeaveRequestDraft draft, CommandExecutionContext context, string idempotencyKey, string inputFingerprint); bool CanSubmitLeave(string recordId, CommandExecutionContext context, out string reason); BusinessWriteResult SubmitLeave( string recordId, CommandExecutionContext context, string idempotencyKey, string inputFingerprint); } public interface IContextualLeaveValidationProvider { decimal CalculateHours( string employeeId, DateTime startLocal, DateTime endLocal, CommandExecutionContext context); bool HasConflict( string employeeId, DateTime startLocal, DateTime endLocal, CommandExecutionContext context); } public sealed class LeaveCreateCommandHandler : ICommandHandler, ICommandExecutionFollowupProvider { private const string DraftKey = "hr.leave.draft"; private const string ResolutionProofKey = "hr.leave.resolution-proof"; private const string CalculatedHoursKey = "hr.leave.calculated-hours"; private readonly ILeaveWorkflowAdapter _adapter; private readonly LeaveValidationOptions _defaults; private readonly ILeaveResolutionProofService _proofs; public LeaveCreateCommandHandler( ILeaveWorkflowAdapter adapter, LeaveValidationOptions defaults, ILeaveResolutionProofService proofs) { if (adapter == null) throw new ArgumentNullException("adapter"); if (proofs == null) throw new ArgumentNullException("proofs"); if (string.IsNullOrWhiteSpace(adapter.ModuleCode)) throw new ArgumentException("请假适配器必须声明模块编号。", "adapter"); BusinessAdapterRegistrationGate.EnsureRuntimeReady(adapter); _adapter = adapter; _defaults = defaults ?? new LeaveValidationOptions(); _proofs = proofs; Descriptor = new CommandDescriptor { Name = "hr.leave.create", Version = "1.2", SchemaVersion = "1.2", InputSchema = CommandInputSchemas.LeaveCreate(), Description = "创建请假申请草稿", RequiredPermission = "module.edit:" + adapter.ModuleCode, Risk = CommandRisk.Write, RequiresConfirmation = true, RequiresIdempotencyKey = true }; } public CommandDescriptor Descriptor { get; private set; } public CommandPlan Plan(IDictionary input, CommandExecutionContext context) { BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context); object proofValue; string resolutionProof = input != null && input.TryGetValue("resolutionProof", out proofValue) ? Convert.ToString(proofValue) : null; Dictionary draftInput = input == null ? new Dictionary() : new Dictionary(input, StringComparer.OrdinalIgnoreCase); draftInput.Remove("resolutionProof"); LeaveRequestDraft draft = CommandInput.Convert(draftInput); if (!_proofs.Validate(resolutionProof, draft, context)) { CommandPlan rejected = new CommandPlan { ModuleCode = _adapter.ModuleCode, Valid = false }; rejected.Data["outcomeCode"] = "leave_resolution_invalid"; rejected.Data["title"] = "请假申请创建预览"; rejected.Data["preview"] = new Dictionary { { "员工", draft.EmployeeId }, { "请假类型", draft.LeaveTypeCode }, { "流转类别", draft.FlowTypeCode }, { "状态", "解析凭证无效或已过期" } }; rejected.Warnings.Add( "请先重新运行 hr.leave.resolve,并把服务器返回的 resolvedInput 原样用于创建预览。"); return rejected; } LeaveValidationResult shape = LeaveRequestValidator.ValidateShape( draft, _defaults); LeaveValidationResult validation = shape.Valid ? Validate(draft, context) : shape; bool typeEnabled = shape.Valid && _adapter.IsLeaveTypeEnabled(draft.LeaveTypeCode, context); bool flowTypeEnabled = shape.Valid && _adapter.IsLeaveFlowTypeEnabled(draft.FlowTypeCode, context); CommandPlan plan = new CommandPlan { ModuleCode = _adapter.ModuleCode, Valid = validation.Valid && typeEnabled && flowTypeEnabled }; plan.SetServerData(DraftKey, draft); plan.SetServerData(ResolutionProofKey, resolutionProof); plan.SetServerData(CalculatedHoursKey, validation.CalculatedHours); plan.Data["outcomeCode"] = plan.Valid ? "leave_create_ready" : "leave_request_invalid"; plan.Data["title"] = "请假申请创建预览"; plan.Data["preview"] = new Dictionary { { "员工", draft.EmployeeId }, { "请假类型", draft.LeaveTypeCode }, { "流转类别", draft.FlowTypeCode }, { "开始时间", draft.StartLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) }, { "结束时间", draft.EndLocal.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture) }, { "核算工时", validation.CalculatedHours }, { "原因", draft.Reason }, { "创建后提交", false } }; if (!typeEnabled) plan.Warnings.Add("请假类型不存在、已停用或当前用户不可用。"); if (!flowTypeEnabled) plan.Warnings.Add("请假流转类别不存在、已停用或不属于当前模块。"); foreach (string issue in validation.Issues) plan.Warnings.Add(issue); if (draft.SubmitAfterSave) plan.Warnings.Add("创建和提交是两个独立审计动作;本命令只创建草稿,创建成功后需另行确认提交。"); return plan; } public CommandResult Execute(CommandPlan plan, CommandExecutionContext context) { BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context); LeaveRequestDraft draft = plan.GetServerData(DraftKey); string resolutionProof = plan.GetServerData(ResolutionProofKey); if (!_proofs.Validate(resolutionProof, draft, context)) throw new CommandKernelException( "leave_resolution_proof_expired", "请假解析凭证已过期、被篡改或 ERP 会话已变化,请重新解析。", 6); decimal expectedCalculatedHours = plan.GetServerData(CalculatedHoursKey); LeaveValidationResult shape = LeaveRequestValidator.ValidateShape( draft, _defaults); if (!shape.Valid) throw new CommandKernelException( "leave_request_changed", "请假输入格式已失效,请重新生成预览。", 6); LeaveValidationResult validation = Validate(draft, context); if (!validation.Valid || validation.CalculatedHours != expectedCalculatedHours || !_adapter.IsLeaveTypeEnabled(draft.LeaveTypeCode, context) || !_adapter.IsLeaveFlowTypeEnabled(draft.FlowTypeCode, context)) throw new CommandKernelException("leave_request_changed", "请假规则、日历、冲突记录、假别或流转类别配置已变化,请重新生成预览。", 6); BusinessWriteResult written = _adapter.CreateLeaveDraft( draft, context, context.IdempotencyKey, plan.InputFingerprint); return PurchaseInvoiceCreateCommandHandler.ToCommandResult( written, "leave_draft_created", "请假申请草稿已创建。", context.IdempotencyKey, plan.InputFingerprint); } public bool TryCreateFollowup( CommandPlan completedPlan, CommandResult completedResult, CommandExecutionContext context, out CommandFollowupRequest followup) { followup = null; if (completedResult == null || !completedResult.Success || string.IsNullOrWhiteSpace(completedResult.RecordId)) return false; if (PurchaseInvoiceCreateCommandHandler.TryCreateNeedsUiNavigation( completedPlan, completedResult, out followup)) return true; LeaveRequestDraft draft = completedPlan.GetServerData(DraftKey); if (draft == null || !draft.SubmitAfterSave) return false; followup = new CommandFollowupRequest { CommandName = "hr.leave.submit" }; followup.Input["recordId"] = completedResult.RecordId; return true; } private LeaveValidationResult Validate(LeaveRequestDraft draft, CommandExecutionContext context) { LeaveValidationOptions options = new LeaveValidationOptions { CurrentEmployeeId = _adapter.GetCurrentEmployeeId(context), CanApplyForOthers = _adapter.CanApplyForOthers(context), AllowPastStart = _defaults.AllowPastStart, MaximumCalendarDays = _defaults.MaximumCalendarDays, MinimumReasonLength = _defaults.MinimumReasonLength, HoursTolerance = _defaults.HoursTolerance }; IWorkingTimeCalculator workingTime = _adapter; ILeaveConflictProvider conflicts = _adapter; IContextualLeaveValidationProvider contextual = _adapter as IContextualLeaveValidationProvider; if (contextual != null) { workingTime = new ContextualWorkingTime(contextual, context); conflicts = new ContextualConflicts(contextual, context); } return LeaveRequestValidator.Validate( draft, options, workingTime, conflicts, _adapter.GetCurrentLocalTime(context)); } private sealed class ContextualWorkingTime : IWorkingTimeCalculator { private readonly IContextualLeaveValidationProvider _provider; private readonly CommandExecutionContext _context; public ContextualWorkingTime( IContextualLeaveValidationProvider provider, CommandExecutionContext context) { _provider = provider; _context = context; } public decimal CalculateHours(string employeeId, DateTime startLocal, DateTime endLocal) { return _provider.CalculateHours(employeeId, startLocal, endLocal, _context); } } private sealed class ContextualConflicts : ILeaveConflictProvider { private readonly IContextualLeaveValidationProvider _provider; private readonly CommandExecutionContext _context; public ContextualConflicts( IContextualLeaveValidationProvider provider, CommandExecutionContext context) { _provider = provider; _context = context; } public bool HasConflict(string employeeId, DateTime startLocal, DateTime endLocal) { return _provider.HasConflict(employeeId, startLocal, endLocal, _context); } } } public sealed class LeaveSubmitCommandHandler : ICommandHandler { private const string RecordKey = "hr.leave.record"; private readonly ILeaveWorkflowAdapter _adapter; public LeaveSubmitCommandHandler(ILeaveWorkflowAdapter adapter) { if (adapter == null) throw new ArgumentNullException("adapter"); BusinessAdapterRegistrationGate.EnsureRuntimeReady(adapter); _adapter = adapter; Descriptor = new CommandDescriptor { Name = "hr.leave.submit", Version = "1.0", SchemaVersion = "1.0", InputSchema = CommandInputSchemas.LeaveSubmit(), Description = "提交已创建的请假申请进入审批流", RequiredPermission = "module.edit:" + adapter.ModuleCode, Risk = CommandRisk.Write, RequiresConfirmation = true, RequiresIdempotencyKey = true }; } public CommandDescriptor Descriptor { get; private set; } public CommandPlan Plan(IDictionary input, CommandExecutionContext context) { BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context); string recordId = CommandInput.RequiredString(input, "recordId"); bool safeRecordId = LeaveRequestValidator.SafeText( recordId, 1, 128, false); string reason = null; bool canSubmit = safeRecordId && _adapter.CanSubmitLeave(recordId, context, out reason); if (!safeRecordId) reason = "请假申请编号过长或包含控制字符。"; else if (!canSubmit) reason = "当前申请不可提交;请检查审批状态、权限或流程配置。"; CommandPlan plan = new CommandPlan { ModuleCode = _adapter.ModuleCode, Valid = canSubmit }; plan.SetServerData(RecordKey, recordId); plan.Data["outcomeCode"] = plan.Valid ? "leave_submit_ready" : "leave_submit_not_ready"; plan.Data["title"] = "请假申请提交预览"; plan.Data["preview"] = new Dictionary { { "申请编号", recordId }, { "动作", "提交审批" } }; if (!canSubmit) plan.Warnings.Add(string.IsNullOrWhiteSpace(reason) ? "当前申请不可提交。" : reason); return plan; } public CommandResult Execute(CommandPlan plan, CommandExecutionContext context) { BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context); string recordId = plan.GetServerData(RecordKey); if (!LeaveRequestValidator.SafeText(recordId, 1, 128, false)) throw new CommandKernelException( "leave_submit_changed", "请假申请编号格式已失效,请重新生成预览。", 6); string reason; if (!_adapter.CanSubmitLeave(recordId, context, out reason)) throw new CommandKernelException( "leave_submit_changed", "当前申请不可提交;请检查审批状态、权限或流程配置。", 6); BusinessWriteResult written = _adapter.SubmitLeave( recordId, context, context.IdempotencyKey, plan.InputFingerprint); return PurchaseInvoiceCreateCommandHandler.ToCommandResult( written, "leave_submitted", "请假申请已提交审批。", context.IdempotencyKey, plan.InputFingerprint); } } internal static class CommandInput { public static T Convert(IDictionary input) where T : class { try { T value = JObject.FromObject(input ?? new Dictionary()).ToObject(); if (value == null) throw new InvalidOperationException(); return value; } catch { throw new CommandKernelException("invalid_input", "命令输入字段缺失或类型无效。", 2); } } public static string RequiredString(IDictionary input, string name) { object raw; string value = input != null && input.TryGetValue(name, out raw) ? System.Convert.ToString(raw) : null; if (string.IsNullOrWhiteSpace(value)) throw new CommandKernelException("invalid_input", "缺少字段:" + name, 2); return value.Trim(); } } }