951 lines
39 KiB
C#
951 lines
39 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using Lskj.CommandKernel;
|
|
|
|
namespace Lskj.AgentBridge
|
|
{
|
|
public sealed class WorkflowContractProbeRequest
|
|
{
|
|
public string SchemaVersion { get; set; }
|
|
public string Workflow { get; set; }
|
|
public string ModuleCode { get; set; }
|
|
public PurchaseContractProbe Purchase { get; set; }
|
|
public LeaveContractProbe Leave { get; set; }
|
|
}
|
|
|
|
public sealed class PurchaseContractProbe
|
|
{
|
|
public PurchaseContractProbe()
|
|
{
|
|
MatchOptions = new PurchaseInvoiceMatchOptions();
|
|
}
|
|
|
|
public PurchaseInvoiceDraft Draft { get; set; }
|
|
public PurchaseInvoiceMatchOptions MatchOptions { get; set; }
|
|
}
|
|
|
|
public sealed class LeaveContractProbe
|
|
{
|
|
public string EmployeeReference { get; set; }
|
|
public string LeaveTypeText { get; set; }
|
|
public string FlowTypeText { get; set; }
|
|
public string DateExpression { get; set; }
|
|
public decimal RequestedHours { get; set; }
|
|
public string Reason { get; set; }
|
|
public string ExistingRecordId { get; set; }
|
|
}
|
|
|
|
public sealed class WorkflowContractCheck
|
|
{
|
|
public WorkflowContractCheck()
|
|
{
|
|
Metrics = new Dictionary<string, object>(StringComparer.Ordinal);
|
|
}
|
|
|
|
public string Code { get; set; }
|
|
public string Action { get; set; }
|
|
public bool Passed { get; set; }
|
|
public string ErrorCode { get; set; }
|
|
public string Message { get; set; }
|
|
public IDictionary<string, object> Metrics { get; private set; }
|
|
}
|
|
|
|
public sealed class WorkflowContractVerificationResult
|
|
{
|
|
public WorkflowContractVerificationResult()
|
|
{
|
|
SchemaVersion = "1.0";
|
|
ReadOnly = true;
|
|
RegistrationReady = false;
|
|
Checks = new List<WorkflowContractCheck>();
|
|
RequiredRemainingEvidence = new List<string>
|
|
{
|
|
"customer_configuration",
|
|
"parameterized_read_queries",
|
|
"transactional_write",
|
|
"persistent_idempotency",
|
|
"permission_recheck",
|
|
"windows_integration",
|
|
"signed_acceptance_manifest",
|
|
"scoped_v2_readiness"
|
|
};
|
|
}
|
|
|
|
public string SchemaVersion { get; set; }
|
|
public string Workflow { get; set; }
|
|
public string ModuleCode { get; set; }
|
|
public DateTime CompletedAtUtc { get; set; }
|
|
public bool ReadOnly { get; set; }
|
|
public bool Verified { get; set; }
|
|
public bool RegistrationReady { get; set; }
|
|
public IList<WorkflowContractCheck> Checks { get; private set; }
|
|
public IList<string> RequiredRemainingEvidence { get; private set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 对客户固定白名单只读过程做行为与返回契约验证。该服务永远不调用 Write,
|
|
/// 也不会把探针中的业务值复制到结果或日志。
|
|
/// </summary>
|
|
public sealed class WorkflowContractVerifier
|
|
{
|
|
private static readonly Regex SafeModule = new Regex(
|
|
@"^[A-Za-z0-9_.:\-]{1,64}$",
|
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private readonly IWorkflowProcedureGateway _gateway;
|
|
private readonly ISystemClock _clock;
|
|
|
|
public WorkflowContractVerifier(
|
|
IWorkflowProcedureGateway gateway,
|
|
ISystemClock clock)
|
|
{
|
|
if (gateway == null) throw new ArgumentNullException("gateway");
|
|
if (clock == null) throw new ArgumentNullException("clock");
|
|
_gateway = gateway;
|
|
_clock = clock;
|
|
}
|
|
|
|
public WorkflowContractVerificationResult Verify(
|
|
WorkflowContractProbeRequest request,
|
|
CommandExecutionContext context)
|
|
{
|
|
ValidateRequest(request, context);
|
|
WorkflowContractVerificationResult result =
|
|
new WorkflowContractVerificationResult
|
|
{
|
|
Workflow = request.Workflow,
|
|
ModuleCode = request.ModuleCode,
|
|
CompletedAtUtc = _clock.UtcNow
|
|
};
|
|
if (request.Workflow == "purchase")
|
|
VerifyPurchase(request, context, result);
|
|
else
|
|
VerifyLeave(request, context, result);
|
|
result.Verified = result.Checks.Count > 0
|
|
&& result.Checks.All(item => item.Passed);
|
|
return result;
|
|
}
|
|
|
|
private void VerifyPurchase(
|
|
WorkflowContractProbeRequest request,
|
|
CommandExecutionContext context,
|
|
WorkflowContractVerificationResult result)
|
|
{
|
|
ProcedurePurchaseWorkflowAdapter adapter =
|
|
new ProcedurePurchaseWorkflowAdapter(request.ModuleCode, _gateway);
|
|
PurchaseInvoiceDraft draft = request.Purchase.Draft;
|
|
ProbeOutcome<IList<PurchaseSupplierCandidate>> suppliers = Probe(
|
|
result,
|
|
"purchase_resolve_supplier_contract",
|
|
"purchase.resolve_supplier",
|
|
delegate
|
|
{
|
|
return adapter.ResolveSuppliers(
|
|
draft.SupplierCode,
|
|
null,
|
|
context);
|
|
});
|
|
if (suppliers.Success)
|
|
{
|
|
IList<PurchaseSupplierCandidate> candidates = suppliers.Value
|
|
?? new List<PurchaseSupplierCandidate>();
|
|
bool unique = candidates.Count == 1
|
|
&& ValidSupplierCandidate(candidates[0])
|
|
&& string.Equals(
|
|
candidates[0].Code,
|
|
draft.SupplierCode,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Add(
|
|
result,
|
|
"purchase_probe_supplier_is_unique",
|
|
"purchase.resolve_supplier",
|
|
unique,
|
|
unique ? null : "probe_supplier_not_unique",
|
|
unique
|
|
? "验收探针供应商编码被唯一解析。"
|
|
: "验收探针供应商没有唯一解析回相同 ERP 编码。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "candidateCount", candidates.Count }
|
|
});
|
|
}
|
|
|
|
ProbeOutcome<IList<PurchaseCurrencyCandidate>> currencies = Probe(
|
|
result,
|
|
"purchase_resolve_currency_contract",
|
|
"purchase.resolve_currency",
|
|
delegate
|
|
{
|
|
return adapter.ResolveCurrencies(draft.CurrencyCode, context);
|
|
});
|
|
if (currencies.Success)
|
|
{
|
|
IList<PurchaseCurrencyCandidate> candidates = currencies.Value
|
|
?? new List<PurchaseCurrencyCandidate>();
|
|
bool unique = candidates.Count == 1
|
|
&& ValidCurrencyCandidate(candidates[0])
|
|
&& string.Equals(
|
|
candidates[0].Code,
|
|
draft.CurrencyCode,
|
|
StringComparison.OrdinalIgnoreCase);
|
|
Add(
|
|
result,
|
|
"purchase_probe_currency_is_unique",
|
|
"purchase.resolve_currency",
|
|
unique,
|
|
unique ? null : "probe_currency_not_unique",
|
|
unique
|
|
? "验收探针币种编码被唯一解析。"
|
|
: "验收探针币种没有唯一解析回相同 ERP 编码。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "candidateCount", candidates.Count }
|
|
});
|
|
}
|
|
|
|
ProbeOutcome<List<IList<PurchaseMaterialCandidate>>> materials = Probe(
|
|
result,
|
|
"purchase_resolve_material_contract",
|
|
"purchase.resolve_material",
|
|
delegate
|
|
{
|
|
List<IList<PurchaseMaterialCandidate>> values =
|
|
new List<IList<PurchaseMaterialCandidate>>();
|
|
foreach (PurchaseInvoiceLine line in draft.Lines)
|
|
{
|
|
values.Add(adapter.ResolveMaterials(
|
|
new PurchaseInvoiceIntentLine
|
|
{
|
|
LineId = line.LineId,
|
|
MaterialReference = line.MaterialCode,
|
|
SourceOrderHint = line.SourceOrderHint,
|
|
Quantity = line.Quantity,
|
|
UnitPrice = line.UnitPrice,
|
|
TaxRate = line.TaxRate,
|
|
LineAmount = line.LineAmount
|
|
},
|
|
draft.SupplierCode,
|
|
context));
|
|
}
|
|
return values;
|
|
});
|
|
if (materials.Success)
|
|
{
|
|
int uniqueCount = 0;
|
|
IList<IList<PurchaseMaterialCandidate>> candidateGroups =
|
|
materials.Value ?? new List<IList<PurchaseMaterialCandidate>>();
|
|
for (int index = 0; index < candidateGroups.Count
|
|
&& index < draft.Lines.Count; index += 1)
|
|
{
|
|
IList<PurchaseMaterialCandidate> candidates =
|
|
candidateGroups[index] ?? new List<PurchaseMaterialCandidate>();
|
|
if (candidates.Count == 1
|
|
&& ValidMaterialCandidate(candidates[0])
|
|
&& string.Equals(
|
|
candidates[0].Code,
|
|
draft.Lines[index].MaterialCode,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
uniqueCount += 1;
|
|
}
|
|
bool allUnique = candidateGroups.Count == draft.Lines.Count
|
|
&& uniqueCount == draft.Lines.Count;
|
|
Add(
|
|
result,
|
|
"purchase_probe_materials_are_unique",
|
|
"purchase.resolve_material",
|
|
allUnique,
|
|
allUnique ? null : "probe_material_not_unique",
|
|
allUnique
|
|
? "验收探针所有物料编码均被唯一解析。"
|
|
: "验收探针存在没有唯一解析回相同 ERP 编码的物料。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "lineCount", draft.Lines.Count },
|
|
{ "uniqueMaterialCount", uniqueCount }
|
|
});
|
|
}
|
|
|
|
ProbeOutcome<bool> invoiceExists = Probe(
|
|
result,
|
|
"purchase_invoice_exists_contract",
|
|
"purchase.invoice_exists",
|
|
delegate
|
|
{
|
|
return adapter.InvoiceNumberExists(
|
|
draft.SupplierCode,
|
|
draft.InvoiceNumber,
|
|
context);
|
|
});
|
|
if (invoiceExists.Success)
|
|
Add(
|
|
result,
|
|
"purchase_probe_invoice_must_be_new",
|
|
"purchase.invoice_exists",
|
|
!invoiceExists.Value,
|
|
invoiceExists.Value ? "probe_invoice_already_exists" : null,
|
|
invoiceExists.Value
|
|
? "验收探针发票号已经存在,请换用不会落库的新探针号码。"
|
|
: "验收探针发票号当前不存在。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "invoiceExists", invoiceExists.Value }
|
|
});
|
|
|
|
ProbeOutcome<IList<PurchaseSourceLine>> sources = Probe(
|
|
result,
|
|
"purchase_open_sources_contract",
|
|
"purchase.open_sources",
|
|
delegate { return adapter.QueryOpenSourceLines(draft, context); });
|
|
if (!sources.Success) return;
|
|
int sourceCount = sources.Value == null ? 0 : sources.Value.Count;
|
|
Add(
|
|
result,
|
|
"purchase_probe_has_open_source",
|
|
"purchase.open_sources",
|
|
sourceCount > 0,
|
|
sourceCount > 0 ? null : "probe_source_not_found",
|
|
sourceCount > 0
|
|
? "验收探针查询到开放采购来源。"
|
|
: "验收探针没有查询到开放采购来源。",
|
|
new Dictionary<string, object> { { "sourceCount", sourceCount } });
|
|
|
|
ProbeOutcome<PurchaseInvoiceMatchPlan> match = Probe(
|
|
result,
|
|
"purchase_deterministic_match_contract",
|
|
"purchase.source_match",
|
|
delegate
|
|
{
|
|
return PurchaseInvoiceMatcher.Match(
|
|
draft,
|
|
sources.Value ?? new List<PurchaseSourceLine>(),
|
|
request.Purchase.MatchOptions ?? new PurchaseInvoiceMatchOptions());
|
|
});
|
|
if (!match.Success) return;
|
|
int exact = match.Value.Lines.Count(item =>
|
|
item.Status == InvoiceLineMatchStatus.Exact);
|
|
int ambiguous = match.Value.Lines.Count(item =>
|
|
item.Status == InvoiceLineMatchStatus.Ambiguous);
|
|
Add(
|
|
result,
|
|
"purchase_probe_match_is_unique",
|
|
"purchase.source_match",
|
|
match.Value.Executable,
|
|
match.Value.Executable ? null : "probe_source_match_not_unique",
|
|
match.Value.Executable
|
|
? "验收探针所有明细均唯一匹配。"
|
|
: "验收探针存在未匹配、歧义或来源数量冲突。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "invoiceLineCount", draft.Lines.Count },
|
|
{ "exactMatchCount", exact },
|
|
{ "ambiguousMatchCount", ambiguous }
|
|
});
|
|
}
|
|
|
|
private void VerifyLeave(
|
|
WorkflowContractProbeRequest request,
|
|
CommandExecutionContext context,
|
|
WorkflowContractVerificationResult result)
|
|
{
|
|
ProcedureLeaveWorkflowAdapter adapter =
|
|
new ProcedureLeaveWorkflowAdapter(request.ModuleCode, _gateway);
|
|
LeaveContractProbe probe = request.Leave;
|
|
ProbeOutcome<string> employee = Probe(
|
|
result,
|
|
"leave_context_employee_contract",
|
|
"leave.context",
|
|
delegate { return adapter.GetCurrentEmployeeId(context); });
|
|
ProbeOutcome<DateTime> nowLocal = Probe(
|
|
result,
|
|
"leave_context_time_contract",
|
|
"leave.context",
|
|
delegate { return adapter.GetCurrentLocalTime(context); });
|
|
if (employee.Success)
|
|
{
|
|
bool current = IsCurrentEmployee(
|
|
probe.EmployeeReference,
|
|
employee.Value);
|
|
Add(
|
|
result,
|
|
"leave_probe_uses_current_employee",
|
|
"leave.context",
|
|
current,
|
|
current ? null : "probe_employee_not_current",
|
|
current
|
|
? "验收探针绑定当前 ERP 员工。"
|
|
: "验收探针员工表达不是当前 ERP 员工。",
|
|
null);
|
|
}
|
|
|
|
LeaveDateExpressionResult date = nowLocal.Success
|
|
? LeaveDateExpressionParser.Parse(probe.DateExpression, nowLocal.Value)
|
|
: new LeaveDateExpressionResult
|
|
{
|
|
Valid = false,
|
|
IssueCode = "leave_context_unavailable",
|
|
Message = "当前 ERP 本地时间不可用。"
|
|
};
|
|
Add(
|
|
result,
|
|
"leave_probe_date_expression",
|
|
"leave.date_expression",
|
|
date.Valid,
|
|
date.Valid ? null : date.IssueCode,
|
|
date.Valid
|
|
? "自然语言日期和时段已确定性解析。"
|
|
: "自然语言日期或时段无法确定性解析。",
|
|
date.Valid
|
|
? new Dictionary<string, object>
|
|
{
|
|
{ "dayPart", date.DayPart.ToString().ToLowerInvariant() }
|
|
}
|
|
: null);
|
|
|
|
ProbeOutcome<IList<LeaveTypeCandidate>> types = Probe(
|
|
result,
|
|
"leave_resolve_type_contract",
|
|
"leave.resolve_type",
|
|
delegate { return adapter.ResolveLeaveTypes(probe.LeaveTypeText, context); });
|
|
LeaveTypeCandidate selectedType = null;
|
|
if (types.Success)
|
|
{
|
|
IList<LeaveTypeCandidate> candidates = types.Value
|
|
?? new List<LeaveTypeCandidate>();
|
|
bool validCandidates = candidates.Count <= 10
|
|
&& candidates.All(ValidCandidate)
|
|
&& candidates.Select(item => item.Code)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase).Count()
|
|
== candidates.Count;
|
|
Add(
|
|
result,
|
|
"leave_type_candidates_well_formed",
|
|
"leave.resolve_type",
|
|
validCandidates,
|
|
validCandidates ? null : "leave_type_candidate_invalid",
|
|
validCandidates
|
|
? "假别候选结构有效。"
|
|
: "假别候选为空字段、重复或格式无效。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "candidateCount", candidates.Count }
|
|
});
|
|
bool unique = validCandidates && candidates.Count == 1;
|
|
Add(
|
|
result,
|
|
"leave_probe_type_is_unique",
|
|
"leave.resolve_type",
|
|
unique,
|
|
unique ? null : "probe_leave_type_not_unique",
|
|
unique
|
|
? "验收探针唯一解析为一个已配置假别候选。"
|
|
: "验收探针假别没有唯一解析。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "candidateCount", candidates.Count }
|
|
});
|
|
if (unique) selectedType = candidates[0];
|
|
}
|
|
|
|
if (selectedType != null)
|
|
{
|
|
ProbeOutcome<bool> enabled = Probe(
|
|
result,
|
|
"leave_type_enabled_contract",
|
|
"leave.type_enabled",
|
|
delegate
|
|
{
|
|
return adapter.IsLeaveTypeEnabled(selectedType.Code, context);
|
|
});
|
|
if (enabled.Success)
|
|
Add(
|
|
result,
|
|
"leave_probe_type_is_enabled",
|
|
"leave.type_enabled",
|
|
enabled.Value,
|
|
enabled.Value ? null : "probe_leave_type_disabled",
|
|
enabled.Value
|
|
? "验收探针假别对当前员工可用。"
|
|
: "验收探针假别已停用或对当前员工不可用。",
|
|
null);
|
|
}
|
|
|
|
LeaveCalendarRange range = null;
|
|
if (employee.Success && date.Valid)
|
|
{
|
|
ProbeOutcome<LeaveCalendarRange> calendar = Probe(
|
|
result,
|
|
"leave_calendar_range_contract",
|
|
"leave.resolve_calendar_range",
|
|
delegate
|
|
{
|
|
return adapter.ResolveCalendarRange(
|
|
employee.Value,
|
|
date.LocalDate,
|
|
date.DayPart,
|
|
context);
|
|
});
|
|
if (calendar.Success)
|
|
{
|
|
range = calendar.Value;
|
|
bool validRange = ValidRange(range, date.LocalDate);
|
|
Add(
|
|
result,
|
|
"leave_probe_calendar_is_available",
|
|
"leave.resolve_calendar_range",
|
|
validRange,
|
|
validRange ? null : "probe_calendar_range_unavailable",
|
|
validRange
|
|
? "员工日历返回可申请的明确本地时间范围。"
|
|
: "员工日历没有返回可申请且结构有效的时间范围。",
|
|
range == null ? null : new Dictionary<string, object>
|
|
{
|
|
{ "available", range.Available },
|
|
{ "hours", range.Hours },
|
|
{ "hasTimeZone", !string.IsNullOrWhiteSpace(range.TimeZoneId) }
|
|
});
|
|
if (!validRange) range = null;
|
|
}
|
|
}
|
|
|
|
LeaveFlowTypeCandidate selectedFlowType = null;
|
|
if (employee.Success && range != null)
|
|
{
|
|
ProbeOutcome<IList<LeaveFlowTypeCandidate>> flowTypes = Probe(
|
|
result,
|
|
"leave_resolve_flow_type_contract",
|
|
"leave.resolve_flow_type",
|
|
delegate
|
|
{
|
|
return adapter.ResolveLeaveFlowTypes(
|
|
employee.Value,
|
|
range.Hours,
|
|
probe.FlowTypeText,
|
|
context);
|
|
});
|
|
if (flowTypes.Success)
|
|
{
|
|
IList<LeaveFlowTypeCandidate> candidates = flowTypes.Value
|
|
?? new List<LeaveFlowTypeCandidate>();
|
|
bool validCandidates = candidates.Count <= 20
|
|
&& candidates.All(ValidFlowTypeCandidate)
|
|
&& candidates.Select(item => item.Code)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase).Count()
|
|
== candidates.Count;
|
|
Add(
|
|
result,
|
|
"leave_flow_type_candidates_well_formed",
|
|
"leave.resolve_flow_type",
|
|
validCandidates,
|
|
validCandidates ? null : "leave_flow_type_candidate_invalid",
|
|
validCandidates
|
|
? "流转类别候选结构有效。"
|
|
: "流转类别候选为空字段、重复或格式无效。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "candidateCount", candidates.Count }
|
|
});
|
|
bool unique = validCandidates && candidates.Count == 1;
|
|
Add(
|
|
result,
|
|
"leave_probe_flow_type_is_unique",
|
|
"leave.resolve_flow_type",
|
|
unique,
|
|
unique ? null : "probe_leave_flow_type_not_unique",
|
|
unique
|
|
? "验收探针唯一解析为一个已配置流转类别。"
|
|
: "验收探针流转类别没有唯一解析。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "candidateCount", candidates.Count }
|
|
});
|
|
if (unique) selectedFlowType = candidates[0];
|
|
}
|
|
}
|
|
|
|
if (selectedFlowType != null)
|
|
{
|
|
ProbeOutcome<bool> enabled = Probe(
|
|
result,
|
|
"leave_flow_type_enabled_contract",
|
|
"leave.flow_type_enabled",
|
|
delegate
|
|
{
|
|
return adapter.IsLeaveFlowTypeEnabled(
|
|
selectedFlowType.Code,
|
|
context);
|
|
});
|
|
if (enabled.Success)
|
|
Add(
|
|
result,
|
|
"leave_probe_flow_type_is_enabled",
|
|
"leave.flow_type_enabled",
|
|
enabled.Value,
|
|
enabled.Value ? null : "probe_leave_flow_type_disabled",
|
|
enabled.Value
|
|
? "验收探针流转类别属于当前请假模块且已启用。"
|
|
: "验收探针流转类别已停用或不属于当前请假模块。",
|
|
null);
|
|
}
|
|
|
|
if (employee.Success && range != null)
|
|
{
|
|
ProbeOutcome<decimal> hours = Probe(
|
|
result,
|
|
"leave_calculate_hours_contract",
|
|
"leave.calculate_hours",
|
|
delegate
|
|
{
|
|
return adapter.CalculateHours(
|
|
employee.Value,
|
|
range.StartLocal,
|
|
range.EndLocal,
|
|
context);
|
|
});
|
|
if (hours.Success)
|
|
{
|
|
bool same = Math.Abs(hours.Value - range.Hours) <= 0.01m;
|
|
bool requestedMatches = probe.RequestedHours <= 0
|
|
|| Math.Abs(probe.RequestedHours - hours.Value) <= 0.01m;
|
|
Add(
|
|
result,
|
|
"leave_probe_hours_are_consistent",
|
|
"leave.calculate_hours",
|
|
same && requestedMatches,
|
|
same && requestedMatches ? null : "probe_leave_hours_mismatch",
|
|
same && requestedMatches
|
|
? "日历范围、核算工时和探针工时一致。"
|
|
: "日历范围、核算工时或探针工时不一致。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "calendarHours", range.Hours },
|
|
{ "calculatedHours", hours.Value }
|
|
});
|
|
}
|
|
|
|
ProbeOutcome<bool> conflict = Probe(
|
|
result,
|
|
"leave_has_conflict_contract",
|
|
"leave.has_conflict",
|
|
delegate
|
|
{
|
|
return adapter.HasConflict(
|
|
employee.Value,
|
|
range.StartLocal,
|
|
range.EndLocal,
|
|
context);
|
|
});
|
|
if (conflict.Success)
|
|
Add(
|
|
result,
|
|
"leave_probe_has_no_conflict",
|
|
"leave.has_conflict",
|
|
!conflict.Value,
|
|
conflict.Value ? "probe_leave_conflict" : null,
|
|
conflict.Value
|
|
? "验收探针时间段存在请假冲突。"
|
|
: "验收探针时间段没有请假冲突。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "hasConflict", conflict.Value }
|
|
});
|
|
}
|
|
|
|
ProbeOutcome<SubmitEligibility> submit = Probe(
|
|
result,
|
|
"leave_can_submit_contract",
|
|
"leave.can_submit",
|
|
delegate
|
|
{
|
|
string reason;
|
|
bool allowed = adapter.CanSubmitLeave(
|
|
probe.ExistingRecordId,
|
|
context,
|
|
out reason);
|
|
return new SubmitEligibility
|
|
{
|
|
Allowed = allowed,
|
|
HasReason = !string.IsNullOrWhiteSpace(reason)
|
|
};
|
|
});
|
|
if (submit.Success)
|
|
Add(
|
|
result,
|
|
"leave_probe_record_is_submittable",
|
|
"leave.can_submit",
|
|
submit.Value.Allowed,
|
|
submit.Value.Allowed ? null : "probe_leave_record_not_submittable",
|
|
submit.Value.Allowed
|
|
? "验收草稿当前可以进入审批流。"
|
|
: "验收草稿当前不可提交,请准备可提交的测试草稿。",
|
|
new Dictionary<string, object>
|
|
{
|
|
{ "canSubmit", submit.Value.Allowed },
|
|
{ "hasReason", submit.Value.HasReason }
|
|
});
|
|
}
|
|
|
|
private static ProbeOutcome<T> Probe<T>(
|
|
WorkflowContractVerificationResult result,
|
|
string code,
|
|
string action,
|
|
Func<T> operation)
|
|
{
|
|
try
|
|
{
|
|
T value = operation();
|
|
Add(result, code, action, true, null, "只读过程返回契约有效。", null);
|
|
return new ProbeOutcome<T> { Success = true, Value = value };
|
|
}
|
|
catch (CommandKernelException error)
|
|
{
|
|
Add(
|
|
result,
|
|
code,
|
|
action,
|
|
false,
|
|
error.Code,
|
|
"只读过程未通过契约验证。",
|
|
null);
|
|
}
|
|
catch
|
|
{
|
|
Add(
|
|
result,
|
|
code,
|
|
action,
|
|
false,
|
|
"adapter_contract_unexpected_error",
|
|
"只读过程验证发生未分类错误。",
|
|
null);
|
|
}
|
|
return new ProbeOutcome<T>();
|
|
}
|
|
|
|
private static void Add(
|
|
WorkflowContractVerificationResult result,
|
|
string code,
|
|
string action,
|
|
bool passed,
|
|
string errorCode,
|
|
string message,
|
|
IDictionary<string, object> metrics)
|
|
{
|
|
WorkflowContractCheck check = new WorkflowContractCheck
|
|
{
|
|
Code = code,
|
|
Action = action,
|
|
Passed = passed,
|
|
ErrorCode = errorCode,
|
|
Message = message
|
|
};
|
|
if (metrics != null)
|
|
{
|
|
foreach (KeyValuePair<string, object> item in metrics)
|
|
check.Metrics[item.Key] = item.Value;
|
|
}
|
|
result.Checks.Add(check);
|
|
}
|
|
|
|
private static void ValidateRequest(
|
|
WorkflowContractProbeRequest request,
|
|
CommandExecutionContext context)
|
|
{
|
|
if (request == null
|
|
|| request.SchemaVersion != "1.0"
|
|
|| (request.Workflow != "purchase" && request.Workflow != "leave")
|
|
|| string.IsNullOrWhiteSpace(request.ModuleCode)
|
|
|| !SafeModule.IsMatch(request.ModuleCode)
|
|
|| context == null
|
|
|| string.IsNullOrWhiteSpace(context.UserId)
|
|
|| string.IsNullOrWhiteSpace(context.AccountBook)
|
|
|| string.IsNullOrWhiteSpace(context.SubSystemId))
|
|
throw Invalid("探针版本、工作流、模块或 ERP 会话范围无效。");
|
|
if (request.Workflow == "purchase")
|
|
{
|
|
if (request.Purchase == null || request.Leave != null)
|
|
throw Invalid("采购探针必须且只能包含 purchase。");
|
|
ValidatePurchaseProbe(request.Purchase);
|
|
}
|
|
else
|
|
{
|
|
if (request.Leave == null || request.Purchase != null)
|
|
throw Invalid("请假探针必须且只能包含 leave。");
|
|
ValidateLeaveProbe(request.Leave);
|
|
}
|
|
}
|
|
|
|
private static void ValidatePurchaseProbe(PurchaseContractProbe probe)
|
|
{
|
|
PurchaseInvoiceDraft draft = probe == null ? null : probe.Draft;
|
|
if (draft == null
|
|
|| !SafeText(draft.SupplierCode, 128)
|
|
|| !SafeText(draft.CurrencyCode, 32)
|
|
|| !SafeText(draft.InvoiceNumber, 128)
|
|
|| draft.InvoiceDate.Kind != DateTimeKind.Unspecified
|
|
|| draft.InvoiceDate.TimeOfDay != TimeSpan.Zero
|
|
|| draft.InvoiceDate < new DateTime(1900, 1, 1)
|
|
|| draft.InvoiceDate > new DateTime(2100, 12, 31)
|
|
|| draft.TotalWithoutTax < 0
|
|
|| draft.TaxAmount < 0
|
|
|| draft.TotalWithTax <= 0
|
|
|| draft.Lines == null
|
|
|| draft.Lines.Count == 0
|
|
|| draft.Lines.Count > 200)
|
|
throw Invalid("采购探针主信息或明细数量无效。");
|
|
foreach (PurchaseInvoiceLine line in draft.Lines)
|
|
{
|
|
if (line == null
|
|
|| !SafeText(line.LineId, 128)
|
|
|| !SafeText(line.MaterialCode, 128)
|
|
|| (!string.IsNullOrWhiteSpace(line.SourceOrderHint)
|
|
&& !SafeText(line.SourceOrderHint, 128))
|
|
|| line.Quantity <= 0
|
|
|| line.UnitPrice < 0
|
|
|| line.TaxRate < 0
|
|
|| line.TaxRate > 1
|
|
|| line.TaxAmount < 0
|
|
|| line.LineAmount < 0)
|
|
throw Invalid("采购探针明细字段或数值无效。");
|
|
}
|
|
if (draft.Lines.Select(item => item.LineId.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase).Count() != draft.Lines.Count)
|
|
throw Invalid("采购探针明细行 ID 必须唯一。");
|
|
if (probe.MatchOptions == null
|
|
|| probe.MatchOptions.LineAmountMode == InvoiceLineAmountMode.None)
|
|
throw Invalid("采购探针必须明确含税或不含税金额模式。");
|
|
try
|
|
{
|
|
PurchaseInvoiceMatcher.Match(
|
|
draft,
|
|
new List<PurchaseSourceLine>(),
|
|
probe.MatchOptions ?? new PurchaseInvoiceMatchOptions());
|
|
}
|
|
catch
|
|
{
|
|
throw Invalid("采购探针匹配容差无效。");
|
|
}
|
|
}
|
|
|
|
private static void ValidateLeaveProbe(LeaveContractProbe probe)
|
|
{
|
|
if (probe == null
|
|
|| (!string.IsNullOrWhiteSpace(probe.EmployeeReference)
|
|
&& !SafeText(probe.EmployeeReference, 64))
|
|
|| !SafeText(probe.LeaveTypeText, 128)
|
|
|| !SafeText(probe.FlowTypeText, 128)
|
|
|| !SafeText(probe.DateExpression, 64)
|
|
|| !SafeText(probe.Reason, 500)
|
|
|| !SafeText(probe.ExistingRecordId, 128)
|
|
|| probe.RequestedHours < 0
|
|
|| probe.RequestedHours > 24)
|
|
throw Invalid("请假探针字段或工时无效。");
|
|
}
|
|
|
|
private static bool ValidCandidate(LeaveTypeCandidate item)
|
|
{
|
|
return item != null
|
|
&& SafeCode(item.Code, 64)
|
|
&& SafeText(item.Name, 128);
|
|
}
|
|
|
|
private static bool ValidFlowTypeCandidate(LeaveFlowTypeCandidate item)
|
|
{
|
|
return item != null
|
|
&& SafeCode(item.Code, 64)
|
|
&& SafeText(item.Name, 128);
|
|
}
|
|
|
|
private static bool ValidSupplierCandidate(PurchaseSupplierCandidate item)
|
|
{
|
|
return item != null
|
|
&& SafeCode(item.Code, 128)
|
|
&& SafeText(item.Name, 256)
|
|
&& (string.IsNullOrWhiteSpace(item.TaxId)
|
|
|| SafeText(item.TaxId, 64));
|
|
}
|
|
|
|
private static bool ValidCurrencyCandidate(PurchaseCurrencyCandidate item)
|
|
{
|
|
return item != null
|
|
&& SafeCode(item.Code, 32)
|
|
&& SafeText(item.Name, 128);
|
|
}
|
|
|
|
private static bool ValidMaterialCandidate(PurchaseMaterialCandidate item)
|
|
{
|
|
return item != null
|
|
&& SafeCode(item.Code, 128)
|
|
&& SafeText(item.Name, 256)
|
|
&& (string.IsNullOrWhiteSpace(item.Specification)
|
|
|| SafeText(item.Specification, 256))
|
|
&& (string.IsNullOrWhiteSpace(item.Unit)
|
|
|| SafeText(item.Unit, 64));
|
|
}
|
|
|
|
private static bool ValidRange(LeaveCalendarRange range, DateTime date)
|
|
{
|
|
return range != null
|
|
&& range.Available
|
|
&& SafeCode(range.ReasonCode, 64)
|
|
&& range.StartLocal.Date == date.Date
|
|
&& range.EndLocal > range.StartLocal
|
|
&& range.EndLocal <= date.Date.AddDays(2)
|
|
&& range.Hours > 0
|
|
&& range.Hours <= 24
|
|
&& SafeText(range.TimeZoneId, 128);
|
|
}
|
|
|
|
private static bool IsCurrentEmployee(string reference, string employeeId)
|
|
{
|
|
if (!SafeCode(employeeId, 64)) return false;
|
|
string value = (reference ?? string.Empty).Trim();
|
|
return value.Length == 0
|
|
|| value == "我"
|
|
|| value == "本人"
|
|
|| value == "自己"
|
|
|| value.Equals(employeeId.Trim(), StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static bool SafeCode(string value, int maximum)
|
|
{
|
|
if (!SafeText(value, maximum)) return false;
|
|
foreach (char item in value.Trim())
|
|
{
|
|
if (!char.IsLetterOrDigit(item)
|
|
&& item != '_' && item != '-' && item != '.' && item != ':')
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool SafeText(string value, int maximum)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)
|
|
|| value.Trim().Length > maximum)
|
|
return false;
|
|
return !value.Any(char.IsControl);
|
|
}
|
|
|
|
private static CommandKernelException Invalid(string message)
|
|
{
|
|
return new CommandKernelException(
|
|
"adapter_contract_probe_invalid",
|
|
message,
|
|
2);
|
|
}
|
|
|
|
private sealed class ProbeOutcome<T>
|
|
{
|
|
public bool Success;
|
|
public T Value;
|
|
}
|
|
|
|
private sealed class SubmitEligibility
|
|
{
|
|
public bool Allowed;
|
|
public bool HasReason;
|
|
}
|
|
}
|
|
}
|