Files
lserp_cs_6.0/插件库/Lskj.CommandKernel/BusinessWorkflowCommands.cs
T
2026-08-14 14:28:28 +08:00

851 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, object>(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<string, object> 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<PurchaseSourceAllocation>();
}
public PurchaseInvoiceDraft Draft { get; set; }
public IList<PurchaseSourceAllocation> Allocations { get; private set; }
}
/// <summary>
/// 客户采购模块适配器。实现必须使用参数化查询,并在一个数据库事务内
/// 锁定/复核来源数量、登记业务幂等键、保存主从表和提交审计结果。
/// </summary>
public interface IPurchaseInvoiceWorkflowAdapter : IBusinessWorkflowAdapterReadiness
{
string ModuleCode { get; }
bool InvoiceNumberExists(string supplierCode, string invoiceNumber, CommandExecutionContext context);
IList<PurchaseSourceLine> 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<string, object> input, CommandExecutionContext context)
{
BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context);
object proofValue;
string resolutionProof = input != null
&& input.TryGetValue("resolutionProof", out proofValue)
? Convert.ToString(proofValue)
: null;
Dictionary<string, object> draftInput = input == null
? new Dictionary<string, object>()
: new Dictionary<string, object>(input, StringComparer.OrdinalIgnoreCase);
draftInput.Remove("resolutionProof");
PurchaseInvoiceDraft draft =
CommandInput.Convert<PurchaseInvoiceDraft>(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<string, object>
{
{ "供应商", 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<PurchaseSourceLine> sources = CanQuerySources(draft)
? (_adapter.QueryOpenSourceLines(draft, context) ?? new List<PurchaseSourceLine>())
: new List<PurchaseSourceLine>();
PurchaseInvoiceMatchPlan match = PurchaseInvoiceMatcher.Match(draft, sources, _options);
List<PurchaseSourceAllocation> 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<string, object>
{
{ "供应商", 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<PurchaseInvoiceDraft>(DraftKey);
string resolutionProof = plan.GetServerData<string>(ResolutionProofKey);
if (!_proofs.Validate(resolutionProof, draft, context))
throw new CommandKernelException(
"purchase_resolution_proof_expired",
"采购主数据解析凭证已过期、被篡改或 ERP 会话已变化,请重新解析。",
6);
List<PurchaseSourceAllocation> expected =
plan.GetServerData<List<PurchaseSourceAllocation>>(AllocationKey);
if (_adapter.InvoiceNumberExists(draft.SupplierCode, draft.InvoiceNumber, context))
throw new CommandKernelException("duplicate_invoice", "该供应商的发票号码已经存在,已阻止重复创建。", 6);
IList<PurchaseSourceLine> currentSources = _adapter.QueryOpenSourceLines(draft, context)
?? new List<PurchaseSourceLine>();
PurchaseInvoiceMatchPlan current = PurchaseInvoiceMatcher.Match(draft, currentSources, _options);
List<PurchaseSourceAllocation> 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<string> 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<PurchaseSourceAllocation> 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<PurchaseSourceAllocation> expected,
IEnumerable<PurchaseSourceAllocation> 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<string, object> 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<string, object> input, CommandExecutionContext context)
{
BusinessAdapterRegistrationGate.EnsureRuntimeReady(_adapter, context);
object proofValue;
string resolutionProof = input != null
&& input.TryGetValue("resolutionProof", out proofValue)
? Convert.ToString(proofValue)
: null;
Dictionary<string, object> draftInput = input == null
? new Dictionary<string, object>()
: new Dictionary<string, object>(input, StringComparer.OrdinalIgnoreCase);
draftInput.Remove("resolutionProof");
LeaveRequestDraft draft = CommandInput.Convert<LeaveRequestDraft>(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<string, object>
{
{ "员工", 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<string, object>
{
{ "员工", 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<LeaveRequestDraft>(DraftKey);
string resolutionProof = plan.GetServerData<string>(ResolutionProofKey);
if (!_proofs.Validate(resolutionProof, draft, context))
throw new CommandKernelException(
"leave_resolution_proof_expired",
"请假解析凭证已过期、被篡改或 ERP 会话已变化,请重新解析。",
6);
decimal expectedCalculatedHours =
plan.GetServerData<decimal>(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<LeaveRequestDraft>(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<string, object> 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<string, object>
{
{ "申请编号", 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<string>(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<T>(IDictionary<string, object> input) where T : class
{
try
{
T value = JObject.FromObject(input ?? new Dictionary<string, object>()).ToObject<T>();
if (value == null) throw new InvalidOperationException();
return value;
}
catch
{
throw new CommandKernelException("invalid_input", "命令输入字段缺失或类型无效。", 2);
}
}
public static string RequiredString(IDictionary<string, object> 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();
}
}
}