using System; using System.Collections.Generic; using System.Linq; namespace Lskj.CommandKernel { public static class PurchaseSourceDocumentContract { public const int MaximumCount = 3; public const long MaximumSizeBytes = 12L * 1024L * 1024L; public const string ImagePreprocessContract = "minimax_vlm_0.0.4"; public const string PdfPreprocessContract = "pdfium_minimax_pages_v1"; public const string CsvPreprocessContract = "document_sandbox_csv_v1"; public const string XlsxPreprocessContract = "document_sandbox_xlsx_v1"; public static bool Matches( string kind, string filename, string preprocessContract) { if (string.IsNullOrWhiteSpace(filename) || string.IsNullOrWhiteSpace(preprocessContract)) return false; string name = filename.Trim(); if (kind == "image") { return preprocessContract == ImagePreprocessContract && (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)); } if (kind != "file") return false; if (name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) return preprocessContract == PdfPreprocessContract; if (name.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) return preprocessContract == CsvPreprocessContract; if (name.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase)) return preprocessContract == XlsxPreprocessContract; return false; } } public enum InvoiceLineMatchStatus { Exact = 0, Ambiguous = 1, Unmatched = 2, Invalid = 3, Conflict = 4 } public enum InvoiceLineAmountMode { None = 0, TaxExclusive = 1, TaxInclusive = 2 } public sealed class PurchaseInvoiceDraft { public PurchaseInvoiceDraft() { Lines = new List(); SourceDocuments = new List(); } public string SupplierCode { get; set; } public string CurrencyCode { get; set; } public string InvoiceNumber { get; set; } public DateTime InvoiceDate { get; set; } public decimal TotalWithoutTax { get; set; } public decimal TaxAmount { get; set; } public decimal TotalWithTax { get; set; } public IList Lines { get; private set; } public IList SourceDocuments { get; private set; } } /// /// 由受信任 AstrBot Tool 从本次消息附件生成的来源凭据。它会进入采购 /// 解析凭证、命令输入指纹和客户写入 payload,模型不能自行声明来源。 /// public sealed class PurchaseSourceDocument { public string Kind { get; set; } public string Filename { get; set; } public string Sha256 { get; set; } public long SizeBytes { get; set; } public string ExtractionSha256 { get; set; } public string PreprocessContract { get; set; } } public sealed class PurchaseInvoiceLine { public string LineId { get; set; } public string MaterialCode { get; set; } public string Unit { get; set; } public string SourceOrderHint { get; set; } public decimal Quantity { get; set; } public decimal UnitPrice { get; set; } public decimal TaxRate { get; set; } public decimal TaxAmount { get; set; } public decimal LineAmount { get; set; } } public sealed class PurchaseSourceLine { public string SourceLineId { get; set; } public string SourceOrderId { get; set; } public string SourceOrderNumber { get; set; } public string SupplierCode { get; set; } public string CurrencyCode { get; set; } public string MaterialCode { get; set; } public string Unit { get; set; } public decimal RemainingQuantity { get; set; } public decimal UnitPrice { get; set; } public decimal TaxRate { get; set; } public decimal ExchangeRate { get; set; } public bool Closed { get; set; } public string UniqueKey { get { return (SourceOrderId ?? string.Empty) + "\u001f" + (SourceLineId ?? string.Empty); } } } public sealed class PurchaseInvoiceMatchOptions { public PurchaseInvoiceMatchOptions() { QuantityTolerance = 0.0001m; UnitPriceAbsoluteTolerance = 0.01m; UnitPriceRelativeTolerance = 0.0001m; TaxRateTolerance = 0.0001m; LineAmountTolerance = 0.02m; HeaderAmountTolerance = 0.05m; CurrencyScale = 2; LineAmountMode = InvoiceLineAmountMode.None; } public decimal QuantityTolerance { get; set; } public decimal UnitPriceAbsoluteTolerance { get; set; } public decimal UnitPriceRelativeTolerance { get; set; } public decimal TaxRateTolerance { get; set; } public decimal LineAmountTolerance { get; set; } public decimal HeaderAmountTolerance { get; set; } public int CurrencyScale { get; set; } public InvoiceLineAmountMode LineAmountMode { get; set; } } public sealed class PurchaseInvoiceLineMatch { public PurchaseInvoiceLineMatch(PurchaseInvoiceLine invoiceLine) { InvoiceLine = invoiceLine; Candidates = new List(); Issues = new List(); } public PurchaseInvoiceLine InvoiceLine { get; private set; } public InvoiceLineMatchStatus Status { get; set; } public PurchaseSourceLine SelectedSource { get; set; } public IList Candidates { get; private set; } public IList Issues { get; private set; } } public sealed class PurchaseInvoiceMatchPlan { public PurchaseInvoiceMatchPlan() { Lines = new List(); Issues = new List(); } public bool Executable { get; set; } public decimal CalculatedTotalWithoutTax { get; set; } public decimal CalculatedTaxAmount { get; set; } public decimal CalculatedTotalWithTax { get; set; } public IList Lines { get; private set; } public IList Issues { get; private set; } } public static class PurchaseInvoiceMatcher { public const decimal MaximumQuantityTolerance = 0.01m; public const decimal MaximumUnitPriceAbsoluteTolerance = 1m; public const decimal MaximumUnitPriceRelativeTolerance = 0.01m; public const decimal MaximumTaxRateTolerance = 0.001m; public const decimal MaximumLineAmountTolerance = 1m; public const decimal MaximumHeaderAmountTolerance = 5m; private const decimal ExchangeRateTolerance = 0.000001m; public static PurchaseInvoiceMatchPlan Match( PurchaseInvoiceDraft invoice, IEnumerable sourceLines, PurchaseInvoiceMatchOptions options) { if (invoice == null) throw new ArgumentNullException("invoice"); if (sourceLines == null) throw new ArgumentNullException("sourceLines"); if (options == null) throw new ArgumentNullException("options"); ValidateOptions(options); PurchaseInvoiceMatchPlan plan = new PurchaseInvoiceMatchPlan(); List sources = sourceLines.Where(item => item != null).ToList(); IList invoiceLines = invoice.Lines ?? new List(); if (!SafeText(invoice.SupplierCode, 64)) plan.Issues.Add("供应商必须先解析为 ERP 供应商编码,禁止用名称模糊猜测。"); if (!SafeText(invoice.CurrencyCode, 64)) plan.Issues.Add("币种编码不能为空。"); if (!SafeText(invoice.InvoiceNumber, 128)) plan.Issues.Add("发票号码不能为空。"); if (invoice.InvoiceDate == DateTime.MinValue || invoice.InvoiceDate.Kind != DateTimeKind.Unspecified || invoice.InvoiceDate.TimeOfDay != TimeSpan.Zero) plan.Issues.Add("发票日期必须是不含时间或时区的日期。"); if (invoiceLines.Count == 0) plan.Issues.Add("发票至少需要一行有效明细。"); if (invoiceLines.Where(item => item != null && !string.IsNullOrWhiteSpace(item.LineId)) .GroupBy(item => item.LineId.Trim(), StringComparer.OrdinalIgnoreCase) .Any(group => group.Count() > 1)) { plan.Issues.Add("发票明细行 ID 必须唯一,禁止同一行被重复匹配。"); } if (sources.Any(item => !ValidSource(item)) || sources.Where(item => ValidSource(item)) .GroupBy(item => item.UniqueKey, StringComparer.OrdinalIgnoreCase) .Any(group => group.Count() > 1)) { plan.Issues.Add("采购来源返回了无效或重复的来源契约,已拒绝匹配。"); sources.Clear(); } foreach (PurchaseInvoiceLine line in invoiceLines) { PurchaseInvoiceLineMatch match = MatchLine(invoice, line, sources, options); plan.Lines.Add(match); } ValidateFinancialTotals(invoice, plan, options); ApplyAggregateQuantityRules(plan, options); ApplyExchangeRateRules(plan); plan.Executable = plan.Issues.Count == 0 && plan.Lines.Count > 0 && plan.Lines.All(item => item.Status == InvoiceLineMatchStatus.Exact); return plan; } private static PurchaseInvoiceLineMatch MatchLine( PurchaseInvoiceDraft invoice, PurchaseInvoiceLine line, IEnumerable sources, PurchaseInvoiceMatchOptions options) { PurchaseInvoiceLineMatch match = new PurchaseInvoiceLineMatch(line); ValidateInvoiceLine(line, options, match.Issues); if (match.Issues.Count > 0) { match.Status = InvoiceLineMatchStatus.Invalid; return match; } List candidates = sources.Where(source => !source.Closed && !string.IsNullOrWhiteSpace(source.SourceOrderId) && !string.IsNullOrWhiteSpace(source.SourceLineId) && Same(source.SupplierCode, invoice.SupplierCode) && Same(source.CurrencyCode, invoice.CurrencyCode) && Same(source.MaterialCode, line.MaterialCode) && Same(source.Unit, line.Unit) && source.ExchangeRate > 0 && source.RemainingQuantity + options.QuantityTolerance >= line.Quantity && PriceMatches(source.UnitPrice, line.UnitPrice, options) && Math.Abs(source.TaxRate - line.TaxRate) <= options.TaxRateTolerance) .ToList(); foreach (PurchaseSourceLine candidate in candidates) match.Candidates.Add(candidate); if (candidates.Count == 0) { match.Status = InvoiceLineMatchStatus.Unmatched; match.Issues.Add("没有找到同时满足供应商、币种、物料、单位、汇率、未开票数量、单价和税率的来源行。"); return match; } if (!string.IsNullOrWhiteSpace(line.SourceOrderHint)) { List hinted = candidates.Where(source => Same(source.SourceOrderId, line.SourceOrderHint) || Same(source.SourceOrderNumber, line.SourceOrderHint)).ToList(); if (hinted.Count == 1) { match.Status = InvoiceLineMatchStatus.Exact; match.SelectedSource = hinted[0]; return match; } if (hinted.Count == 0) match.Issues.Add("发票给出的来源单提示与候选采购单不一致。"); else match.Issues.Add("来源单提示仍对应多个候选明细行。"); } if (candidates.Count == 1 && string.IsNullOrWhiteSpace(line.SourceOrderHint)) { match.Status = InvoiceLineMatchStatus.Exact; match.SelectedSource = candidates[0]; return match; } match.Status = InvoiceLineMatchStatus.Ambiguous; match.Issues.Add("存在多个合法来源行,必须由用户选择,模型不得猜测。"); return match; } private static void ValidateInvoiceLine( PurchaseInvoiceLine line, PurchaseInvoiceMatchOptions options, IList issues) { if (line == null) { issues.Add("发票明细不能为空。"); return; } if (!SafeText(line.LineId, 128)) issues.Add("发票明细行 ID 不能为空或格式无效。"); if (!SafeText(line.MaterialCode, 256)) issues.Add("物料必须先解析为有效的 ERP 物料编码。"); if (!SafeText(line.Unit, 64)) issues.Add("计量单位必须由 ERP 物料主数据唯一解析。"); if (!string.IsNullOrWhiteSpace(line.SourceOrderHint) && !SafeText(line.SourceOrderHint, 128)) issues.Add("来源采购单提示格式无效。"); if (line.Quantity <= 0) issues.Add("数量必须大于零。"); if (line.UnitPrice < 0) issues.Add("单价不能小于零。"); if (line.TaxRate < 0 || line.TaxRate > 1) issues.Add("税率必须使用 0 到 1 的小数格式。"); if (line.TaxAmount < 0) issues.Add("行税额不能小于零。"); if (line.LineAmount < 0) issues.Add("行金额不能小于零。"); if (issues.Count == 0 && options.LineAmountMode != InvoiceLineAmountMode.None) { decimal expectedLine; decimal expectedTax; try { expectedLine = Money(line.Quantity * line.UnitPrice, options.CurrencyScale); expectedTax = options.LineAmountMode == InvoiceLineAmountMode.TaxInclusive ? Money( line.TaxRate == 0 ? 0 : line.LineAmount - (line.LineAmount / (1 + line.TaxRate)), options.CurrencyScale) : Money(line.LineAmount * line.TaxRate, options.CurrencyScale); } catch (OverflowException) { issues.Add("数量、单价或税额计算溢出。"); return; } if (Math.Abs(expectedLine - line.LineAmount) > options.LineAmountTolerance) issues.Add("行金额与数量、单价计算结果超出配置容差。"); if (Math.Abs(expectedTax - line.TaxAmount) > options.LineAmountTolerance) issues.Add("行税额与金额、税率计算结果超出配置容差。"); } } private static void ValidateFinancialTotals( PurchaseInvoiceDraft invoice, PurchaseInvoiceMatchPlan plan, PurchaseInvoiceMatchOptions options) { if (options.LineAmountMode == InvoiceLineAmountMode.None) return; if (invoice.TotalWithoutTax < 0 || invoice.TaxAmount < 0 || invoice.TotalWithTax <= 0) { plan.Issues.Add("发票不含税金额、税额或价税合计无效。"); return; } try { decimal withoutTax = 0; decimal tax = 0; decimal withTax = 0; foreach (PurchaseInvoiceLine line in invoice.Lines.Where(item => item != null)) { tax += line.TaxAmount; if (options.LineAmountMode == InvoiceLineAmountMode.TaxInclusive) { withTax += line.LineAmount; withoutTax += line.LineAmount - line.TaxAmount; } else { withoutTax += line.LineAmount; withTax += line.LineAmount + line.TaxAmount; } } plan.CalculatedTotalWithoutTax = Money(withoutTax, options.CurrencyScale); plan.CalculatedTaxAmount = Money(tax, options.CurrencyScale); plan.CalculatedTotalWithTax = Money(withTax, options.CurrencyScale); if (Math.Abs(invoice.TotalWithoutTax - plan.CalculatedTotalWithoutTax) > options.HeaderAmountTolerance) plan.Issues.Add("发票不含税金额与明细汇总不一致。"); if (Math.Abs(invoice.TaxAmount - plan.CalculatedTaxAmount) > options.HeaderAmountTolerance) plan.Issues.Add("发票税额与明细税额汇总不一致。"); if (Math.Abs(invoice.TotalWithTax - plan.CalculatedTotalWithTax) > options.HeaderAmountTolerance) plan.Issues.Add("发票价税合计与明细汇总不一致。"); if (Math.Abs( invoice.TotalWithoutTax + invoice.TaxAmount - invoice.TotalWithTax) > options.HeaderAmountTolerance) plan.Issues.Add("发票头不含税金额加税额不等于价税合计。"); } catch (OverflowException) { plan.Issues.Add("发票金额汇总计算溢出。"); } } private static void ApplyAggregateQuantityRules( PurchaseInvoiceMatchPlan plan, PurchaseInvoiceMatchOptions options) { IEnumerable> groups = plan.Lines .Where(item => item.Status == InvoiceLineMatchStatus.Exact && item.SelectedSource != null) .GroupBy(item => item.SelectedSource.UniqueKey, StringComparer.OrdinalIgnoreCase); foreach (IGrouping group in groups) { PurchaseSourceLine source = group.First().SelectedSource; decimal requested = group.Sum(item => item.InvoiceLine.Quantity); if (requested <= source.RemainingQuantity + options.QuantityTolerance) continue; foreach (PurchaseInvoiceLineMatch item in group) { item.Status = InvoiceLineMatchStatus.Conflict; item.Issues.Add("多行发票累计占用数量超过来源采购行的未开票数量。"); } } } private static void ApplyExchangeRateRules(PurchaseInvoiceMatchPlan plan) { List selected = plan.Lines .Where(item => item.Status == InvoiceLineMatchStatus.Exact && item.SelectedSource != null) .ToList(); if (selected.Count <= 1) return; decimal minimum = selected.Min(item => item.SelectedSource.ExchangeRate); decimal maximum = selected.Max(item => item.SelectedSource.ExchangeRate); if (minimum > 0 && maximum - minimum <= ExchangeRateTolerance) return; foreach (PurchaseInvoiceLineMatch item in selected) { item.Status = InvoiceLineMatchStatus.Conflict; item.Issues.Add("同一张发票匹配到了不同汇率的采购来源,必须拆单或由财务确认汇率。"); } } private static bool PriceMatches( decimal sourcePrice, decimal invoicePrice, PurchaseInvoiceMatchOptions options) { decimal allowed = Math.Max( options.UnitPriceAbsoluteTolerance, Math.Abs(sourcePrice) * options.UnitPriceRelativeTolerance); return Math.Abs(sourcePrice - invoicePrice) <= allowed; } private static bool Same(string left, string right) { return string.Equals( (left ?? string.Empty).Trim(), (right ?? string.Empty).Trim(), StringComparison.OrdinalIgnoreCase); } private static bool ValidSource(PurchaseSourceLine source) { return source != null && SafeText(source.SourceOrderId, 128) && SafeText(source.SourceOrderNumber, 128) && SafeText(source.SourceLineId, 128) && SafeText(source.SupplierCode, 64) && SafeText(source.CurrencyCode, 64) && SafeText(source.MaterialCode, 256) && SafeText(source.Unit, 64) && source.RemainingQuantity >= 0 && source.UnitPrice >= 0 && source.TaxRate >= 0 && source.TaxRate <= 1 && source.ExchangeRate > 0; } private static bool SafeText(string value, int maximum) { if (string.IsNullOrWhiteSpace(value) || value.Length > maximum) return false; return !value.Any(char.IsControl); } private static void ValidateOptions(PurchaseInvoiceMatchOptions options) { if (options.QuantityTolerance < 0 || options.QuantityTolerance > MaximumQuantityTolerance || options.UnitPriceAbsoluteTolerance < 0 || options.UnitPriceAbsoluteTolerance > MaximumUnitPriceAbsoluteTolerance || options.UnitPriceRelativeTolerance < 0 || options.UnitPriceRelativeTolerance > MaximumUnitPriceRelativeTolerance || options.TaxRateTolerance < 0 || options.TaxRateTolerance > MaximumTaxRateTolerance || options.LineAmountTolerance < 0 || options.LineAmountTolerance > MaximumLineAmountTolerance || options.HeaderAmountTolerance < 0 || options.HeaderAmountTolerance > MaximumHeaderAmountTolerance || options.CurrencyScale < 0 || options.CurrencyScale > 6 || !Enum.IsDefined(typeof(InvoiceLineAmountMode), options.LineAmountMode)) { throw new ArgumentOutOfRangeException( "options", "采购匹配容差或币种精度超出商用安全范围。"); } } private static decimal Money(decimal value, int scale) { return Math.Round(value, scale, MidpointRounding.AwayFromZero); } } }