feat: add ERP agent pet bridge and startup guide
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Lskj.CommandKernel;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Lskj.AgentBridge
|
||||
{
|
||||
public sealed class BusinessAdapterConfiguration
|
||||
{
|
||||
private const int MaximumBytes = 64 * 1024;
|
||||
|
||||
public string SchemaVersion { get; set; }
|
||||
public string SourceSha256 { get; private set; }
|
||||
public string CustomerProfilePath { get; set; }
|
||||
public PurchaseAdapterConfiguration Purchase { get; set; }
|
||||
public LeaveAdapterConfiguration Leave { get; set; }
|
||||
|
||||
public static BusinessAdapterConfiguration Load(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw Invalid("未配置业务适配器文件路径。");
|
||||
try
|
||||
{
|
||||
string fullPath;
|
||||
byte[] sourceBytes = ReadRegularFile(path, out fullPath);
|
||||
string source = new UTF8Encoding(false, true).GetString(sourceBytes);
|
||||
if (!StrictRuntimeJsonSyntax.IsStandard(source))
|
||||
throw Invalid("业务适配器配置必须使用无 BOM、无注释、无尾逗号的标准 JSON。");
|
||||
JObject root = JObject.Parse(
|
||||
source,
|
||||
new JsonLoadSettings
|
||||
{
|
||||
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
||||
CommentHandling = CommentHandling.Ignore,
|
||||
LineInfoHandling = LineInfoHandling.Ignore
|
||||
});
|
||||
EnsureOnly(root,
|
||||
"schemaVersion", "customerProfilePath", "purchase", "leave");
|
||||
EnsureObject(root, "purchase", "enabled", "acceptanceEvidencePath", "fields", "matchOptions");
|
||||
EnsureObject(root, "leave", "enabled", "acceptanceEvidencePath", "fields", "validationOptions");
|
||||
EnsureNestedOnly(root, "purchase", "fields",
|
||||
"moduleCode", "supplierCode", "invoiceNumber", "invoiceDate", "currencyCode",
|
||||
"materialCode", "unit", "quantity", "unitPrice", "taxRate", "exchangeRate", "lineAmount",
|
||||
"sourceOrderId", "sourceLineId");
|
||||
EnsureNestedOnly(root, "purchase", "matchOptions",
|
||||
"quantityTolerance", "unitPriceAbsoluteTolerance", "unitPriceRelativeTolerance",
|
||||
"taxRateTolerance", "lineAmountTolerance", "headerAmountTolerance",
|
||||
"currencyScale", "lineAmountMode");
|
||||
EnsureNestedOnly(root, "leave", "fields",
|
||||
"moduleCode", "employeeId", "leaveTypeCode", "flowTypeCode",
|
||||
"startLocal", "endLocal", "requestedHours", "reason");
|
||||
EnsureNestedOnly(root, "leave", "validationOptions",
|
||||
"allowPastStart", "maximumCalendarDays", "minimumReasonLength", "hoursTolerance");
|
||||
|
||||
BusinessAdapterConfiguration result = root.ToObject<BusinessAdapterConfiguration>();
|
||||
if (result == null || result.SchemaVersion != "1.1")
|
||||
throw Invalid("业务适配器 schemaVersion 必须为 1.1。");
|
||||
if (result.Purchase == null) result.Purchase = new PurchaseAdapterConfiguration();
|
||||
if (result.Leave == null) result.Leave = new LeaveAdapterConfiguration();
|
||||
result.SourceSha256 = Sha256(sourceBytes);
|
||||
string baseDirectory = Path.GetDirectoryName(fullPath);
|
||||
if (result.Purchase.Enabled || result.Leave.Enabled)
|
||||
result.CustomerProfilePath = NormalizeProfilePath(
|
||||
result.CustomerProfilePath,
|
||||
baseDirectory);
|
||||
result.Purchase.Normalize(baseDirectory);
|
||||
result.Leave.Normalize(baseDirectory);
|
||||
return result;
|
||||
}
|
||||
catch (CommandKernelException) { throw; }
|
||||
catch
|
||||
{
|
||||
throw Invalid("业务适配器配置不是有效的严格 UTF-8 JSON。");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ReadRegularFile(string path, out string fullPath)
|
||||
{
|
||||
fullPath = Path.GetFullPath(path);
|
||||
FileInfo file = new FileInfo(fullPath);
|
||||
if (!file.Exists)
|
||||
throw Invalid("业务适配器配置文件不存在。");
|
||||
FileAttributes attributes = file.Attributes;
|
||||
if ((attributes & (FileAttributes.Directory
|
||||
| FileAttributes.Device
|
||||
| FileAttributes.ReparsePoint)) != 0)
|
||||
{
|
||||
throw Invalid("业务适配器配置必须是普通文件,不能是目录、设备或链接。");
|
||||
}
|
||||
|
||||
using (FileStream stream = new FileStream(
|
||||
fullPath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read))
|
||||
{
|
||||
if (stream.Length <= 0 || stream.Length > MaximumBytes)
|
||||
throw Invalid("业务适配器配置为空或超过大小上限。");
|
||||
byte[] result = new byte[(int)stream.Length];
|
||||
int offset = 0;
|
||||
while (offset < result.Length)
|
||||
{
|
||||
int count = stream.Read(result, offset, result.Length - offset);
|
||||
if (count <= 0)
|
||||
throw Invalid("业务适配器配置读取不完整。");
|
||||
offset += count;
|
||||
}
|
||||
if (stream.ReadByte() != -1)
|
||||
throw Invalid("业务适配器配置读取期间发生变化。");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureObject(JObject root, string name, params string[] allowed)
|
||||
{
|
||||
JToken token = root[name];
|
||||
if (token == null) return;
|
||||
JObject value = token as JObject;
|
||||
if (value == null) throw Invalid(name + " 必须是 JSON 对象。");
|
||||
EnsureOnly(value, allowed);
|
||||
}
|
||||
|
||||
private static void EnsureNestedOnly(
|
||||
JObject root,
|
||||
string parent,
|
||||
string child,
|
||||
params string[] allowed)
|
||||
{
|
||||
JObject parentObject = root[parent] as JObject;
|
||||
if (parentObject == null || parentObject[child] == null) return;
|
||||
JObject value = parentObject[child] as JObject;
|
||||
if (value == null) throw Invalid(parent + "." + child + " 必须是 JSON 对象。");
|
||||
EnsureOnly(value, allowed);
|
||||
}
|
||||
|
||||
private static void EnsureOnly(JObject value, params string[] allowed)
|
||||
{
|
||||
HashSet<string> names = new HashSet<string>(allowed, StringComparer.Ordinal);
|
||||
JProperty unknown = value.Properties().FirstOrDefault(item => !names.Contains(item.Name));
|
||||
if (unknown != null)
|
||||
throw Invalid("业务适配器配置包含未知字段:" + unknown.Name);
|
||||
}
|
||||
|
||||
private static CommandKernelException Invalid(string message)
|
||||
{
|
||||
return new CommandKernelException("adapter_config_invalid", message, 6);
|
||||
}
|
||||
|
||||
private static string NormalizeProfilePath(
|
||||
string value,
|
||||
string baseDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw Invalid("已启用业务适配器时必须配置 customerProfilePath。");
|
||||
string candidate = value.Trim();
|
||||
return Path.GetFullPath(Path.IsPathRooted(candidate)
|
||||
? candidate
|
||||
: Path.Combine(baseDirectory ?? string.Empty, candidate));
|
||||
}
|
||||
|
||||
private static string Sha256(byte[] value)
|
||||
{
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(value);
|
||||
StringBuilder result = new StringBuilder(hash.Length * 2);
|
||||
foreach (byte item in hash) result.Append(item.ToString("x2"));
|
||||
return result.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PurchaseAdapterConfiguration
|
||||
{
|
||||
public PurchaseAdapterConfiguration()
|
||||
{
|
||||
MatchOptions = new PurchaseInvoiceMatchOptions();
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string AcceptanceEvidencePath { get; set; }
|
||||
public PurchaseWorkflowFieldMap Fields { get; set; }
|
||||
public PurchaseInvoiceMatchOptions MatchOptions { get; set; }
|
||||
|
||||
internal void Normalize(string baseDirectory)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
if (Fields == null || string.IsNullOrWhiteSpace(Fields.ModuleCode))
|
||||
throw new CommandKernelException(
|
||||
"adapter_config_invalid",
|
||||
"已启用的采购适配器缺少 fields.moduleCode。",
|
||||
6);
|
||||
AcceptanceEvidencePath = NormalizeEvidencePath(
|
||||
AcceptanceEvidencePath, baseDirectory, "采购");
|
||||
if (MatchOptions == null) MatchOptions = new PurchaseInvoiceMatchOptions();
|
||||
PurchaseInvoiceMatcher.Match(
|
||||
new PurchaseInvoiceDraft(),
|
||||
new List<PurchaseSourceLine>(),
|
||||
MatchOptions);
|
||||
if (MatchOptions.LineAmountMode == InvoiceLineAmountMode.None)
|
||||
throw new CommandKernelException(
|
||||
"adapter_config_invalid",
|
||||
"采购 matchOptions 超出允许范围,且 lineAmountMode 必须明确为不含税或含税。",
|
||||
6);
|
||||
}
|
||||
|
||||
private static string NormalizeEvidencePath(
|
||||
string value,
|
||||
string baseDirectory,
|
||||
string workflowName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new CommandKernelException(
|
||||
"adapter_config_invalid",
|
||||
"已启用的" + workflowName + "适配器缺少 acceptanceEvidencePath。",
|
||||
6);
|
||||
string candidate = value.Trim();
|
||||
return Path.GetFullPath(Path.IsPathRooted(candidate)
|
||||
? candidate
|
||||
: Path.Combine(baseDirectory ?? string.Empty, candidate));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LeaveAdapterConfiguration
|
||||
{
|
||||
public LeaveAdapterConfiguration()
|
||||
{
|
||||
ValidationOptions = new LeaveValidationOptions();
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
public string AcceptanceEvidencePath { get; set; }
|
||||
public LeaveWorkflowFieldMap Fields { get; set; }
|
||||
public LeaveValidationOptions ValidationOptions { get; set; }
|
||||
|
||||
internal void Normalize(string baseDirectory)
|
||||
{
|
||||
if (!Enabled) return;
|
||||
if (Fields == null || string.IsNullOrWhiteSpace(Fields.ModuleCode))
|
||||
throw new CommandKernelException(
|
||||
"adapter_config_invalid",
|
||||
"已启用的请假适配器缺少 fields.moduleCode。",
|
||||
6);
|
||||
AcceptanceEvidencePath = NormalizeEvidencePath(
|
||||
AcceptanceEvidencePath, baseDirectory, "请假");
|
||||
if (ValidationOptions == null) ValidationOptions = new LeaveValidationOptions();
|
||||
if (ValidationOptions.MaximumCalendarDays <= 0
|
||||
|| ValidationOptions.MaximumCalendarDays > 31
|
||||
|| ValidationOptions.MinimumReasonLength < 0
|
||||
|| ValidationOptions.MinimumReasonLength > 500
|
||||
|| ValidationOptions.HoursTolerance < 0
|
||||
|| ValidationOptions.HoursTolerance > 1m)
|
||||
throw new CommandKernelException(
|
||||
"adapter_config_invalid",
|
||||
"请假 validationOptions 超出允许范围。",
|
||||
6);
|
||||
}
|
||||
|
||||
private static string NormalizeEvidencePath(
|
||||
string value,
|
||||
string baseDirectory,
|
||||
string workflowName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new CommandKernelException(
|
||||
"adapter_config_invalid",
|
||||
"已启用的" + workflowName + "适配器缺少 acceptanceEvidencePath。",
|
||||
6);
|
||||
string candidate = value.Trim();
|
||||
return Path.GetFullPath(Path.IsPathRooted(candidate)
|
||||
? candidate
|
||||
: Path.Combine(baseDirectory ?? string.Empty, candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user