feat: add ERP agent pet bridge and startup guide

This commit is contained in:
郎速科技
2026-08-14 14:28:28 +08:00
parent a803070819
commit 4c08f4c948
252 changed files with 134752 additions and 72 deletions
@@ -0,0 +1,850 @@
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();
}
}
}