851 lines
41 KiB
C#
851 lines
41 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|