Files
lserp_cs_6.0/插件库/Lskj.CommandKernel/PurchaseInvoiceMatching.cs
T
2026-08-14 14:28:28 +08:00

526 lines
23 KiB
C#

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<PurchaseInvoiceLine>();
SourceDocuments = new List<PurchaseSourceDocument>();
}
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<PurchaseInvoiceLine> Lines { get; private set; }
public IList<PurchaseSourceDocument> SourceDocuments { get; private set; }
}
/// <summary>
/// 由受信任 AstrBot Tool 从本次消息附件生成的来源凭据。它会进入采购
/// 解析凭证、命令输入指纹和客户写入 payload,模型不能自行声明来源。
/// </summary>
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<PurchaseSourceLine>();
Issues = new List<string>();
}
public PurchaseInvoiceLine InvoiceLine { get; private set; }
public InvoiceLineMatchStatus Status { get; set; }
public PurchaseSourceLine SelectedSource { get; set; }
public IList<PurchaseSourceLine> Candidates { get; private set; }
public IList<string> Issues { get; private set; }
}
public sealed class PurchaseInvoiceMatchPlan
{
public PurchaseInvoiceMatchPlan()
{
Lines = new List<PurchaseInvoiceLineMatch>();
Issues = new List<string>();
}
public bool Executable { get; set; }
public decimal CalculatedTotalWithoutTax { get; set; }
public decimal CalculatedTaxAmount { get; set; }
public decimal CalculatedTotalWithTax { get; set; }
public IList<PurchaseInvoiceLineMatch> Lines { get; private set; }
public IList<string> 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<PurchaseSourceLine> 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<PurchaseSourceLine> sources = sourceLines.Where(item => item != null).ToList();
IList<PurchaseInvoiceLine> invoiceLines = invoice.Lines ?? new List<PurchaseInvoiceLine>();
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<PurchaseSourceLine> 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<PurchaseSourceLine> 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<PurchaseSourceLine> 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<string> 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<IGrouping<string, PurchaseInvoiceLineMatch>> groups = plan.Lines
.Where(item => item.Status == InvoiceLineMatchStatus.Exact && item.SelectedSource != null)
.GroupBy(item => item.SelectedSource.UniqueKey, StringComparer.OrdinalIgnoreCase);
foreach (IGrouping<string, PurchaseInvoiceLineMatch> 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<PurchaseInvoiceLineMatch> 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);
}
}
}