feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user