用AI简化供应商报价对比:从数据归一化到采购建议
在人工智能内容学习中,用 AI 简化供应商报价对比:从数据归一化到采购建议是常见主题。很多人在阅读时会遇到概念分散、步骤不清和注意点难以归纳的问题。本文按照基础概念、操作流程和关键细节,对相关内容进行整理。
一份询价单发出去,带着清晰的物料清单,十二家供应商回信。没有两家答得一样。一家发来工作簿,价格列叫 "Unit Price"、数量列叫 "Qty";另一家发来 PDF,同样的两列变成了 "Rate" 和 "Pcs";第三家用欧元报价,数量列用的是 Lot 而不是 Qty,还加了个脚注说"价格含运费、不含增值税"。在你能对比任何一行明细之前,这些都得先变成同一个意思。
vendor_a.xlsx Part No | Description | Qty | Unit Price | Total
vendor_b.pdf Item | Specification | Pcs | Rate | Amount
vendor_c.xlsx SKU | (空) | Lot | Unit Cost | Net (EUR)同样的信息,三种叫法、三种单位、一次币种换算。这部分内容会走完一个完整的 .NET 实现,把这样一个收件箱变成一份排序好的采购建议,用的是 Spire.Agent.Office——更重要的是,贯穿整个设计只有一条分界线:
智能体理解供应商字段的含义;你的应用决定每个零件由哪家供应商胜出。
| 智能体(语义层) | 应用(确定性层) |
|---|---|
把 Rate / Unit Cost / Unit Price 映射为 unitPrice | 把币种换算成美元 |
| 从 PDF 和 Excel 中提取明细 | 校验数量与总价 |
把 Qty / Pcs 映射为 quantity(Lot 仅在明确作为报价数量时) | 打分,并为每个零件选出胜出供应商 |
下面的一切都围绕这条边界展开:智能体负责把异构的 Office/PDF 文档落到统一的规范结构(canonical schema)上;应用负责每一个参与决策的数字。
1. 定义规范报价结构
任何对比的第一步,都是先定一个所有报价都能归约过去的目标结构。把它定义成一个普通的 C# 模型——这就是规范结构:
public sealed class QuoteLine
{
public string Vendor { get; set; } = "";
public string? Part { get; set; }
public string? Description { get; set; }
// 可空:智能体在字段缺失或含义不清时留 null。
public decimal? Quantity { get; set; }
public decimal? UnitPrice { get; set; }
public string? Currency { get; set; }
public decimal? Total { get; set; }
public int? DeliveryWeeks { get; set; }
public int? PaymentTerms { get; set; }
// 由应用在校验阶段写入 —— 智能体从不碰它。
public string? Flag { get; set; }
}从供应商自己的表头到这个结构的映射,正是问题的核心:
| 源字段(每家不同) | 规范字段 | 示例 |
|---|---|---|
Part No / Item / SKU | Part | A-1024 |
Qty / Pcs | Quantity | 500 |
Lot(仅当文档明确将其作为报价数量) | Quantity | 5 |
Unit Price / Rate / Unit Cost | UnitPrice | 12.50 |
Net / Amount / Total | Total | 6,250.00 |
| 文档中标明的币种符号 / 代码 | Currency | EUR |
Delivery / Lead Time | DeliveryWeeks | 4 |
Terms | PaymentTerms | 30 |
手写的解析器每来一家供应商都得重新推一遍这张表;智能体的意义,就在于按含义而不是按字符串匹配去套用这张表。
2. 加载异构的报价
收件箱是一个同时装着两种文档的文件夹,没有任何预处理步骤:智能体以原生格式读取每个文件,所以 PDF 和 Excel 用同一条指令、通过两个对象类型来处理。
C:sourcinginbox
├─ vendor_a.xlsx
├─ vendor_b.pdf
└─ vendor_c.xlsx
3. 用智能体把每份报价归一化
Spire.Agent.Office 对 Word、Excel、PowerPoint、PDF 统一暴露一个 AI() 处理器。装上包、一次性配置好智能体,再遍历收件箱:
dotnet add package Spire.Agent.Officeusing Spire.Agent.Office.AI;
using Spire.Agent.Office.Extensions;
using Spire.Pdf;
using Spire.Xls;
AIOptions agent = new AIOptions
{
SpireToken = spireToken, // 从你的 Spire.Agent.Office 账户获取
WorkDir = @"C:sourcingwork",
TimeoutMs = 300_000
};
const string instruction =
"读取这份供应商报价,提取明细。在输入文档所在的同一个目录下新建一个 .json 文件," +
"向其中写入一个 JSON 数组,每个明细行一个对象,每个对象只能包含这些字段:vendor、" +
"part、description、quantity、unitPrice、currency、total、deliveryWeeks、paymentTerms。" +
"vendor 是签发这份报价的公司,取自文档表头,绝不能留 null。deliveryWeeks 和 " +
"paymentTerms 是整数:deliveryWeeks 是交货周期(周),paymentTerms 是" +
"付款期限(天),所以 'Net 30' 要变成 30。把供应商" +
"自己的表头映射到这个结构上——'Rate'、'Unit Cost'、'Unit Price' 都表示 unitPrice;'Qty'、" +
"'Pcs' 都表示 quantity;'Lot' 仅在文档明确将其作为报价数量时才映射。若某个值缺失或" +
"含义不清,留 null,不要猜测。输出文件是交付物——必须确实创建并写入磁盘。";
foreach (string file in Directory.GetFiles(inboxPath))
{
string jsonPath = Path.Combine(normPath, Path.GetFileNameWithoutExtension(file) + ".json");
// 智能体调用走 LLM,偶尔会失败或不写输出文件;重试几次。
AIResult r = null!;
Exception? last = null;
for (int attempt = 1; attempt <= 3; attempt++)
{
try
{
if (file.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
using var quote = new PdfDocument();
quote.LoadFromFile(file);
r = quote.AI(agent).ExecuteInstruction(quote, instruction, jsonPath, Array.Empty<string>());
}
else
{
using var quote = new Workbook();
quote.LoadFromFile(file);
r = quote.AI(agent).ExecuteInstruction(quote, instruction, jsonPath, Array.Empty<string>());
}
}
catch (Exception e) { last = e; }
if (r is not null && r.Success) break;
}
if (r is null || !r.Success)
throw new InvalidOperationException($"归一化失败 {Path.GetFileName(file)}:{r?.ErrorMessage ?? last?.Message}");
}每次调用返回一个 AIResult,每份报价变成一份已经符合规范结构的 JSON 文件——vendor_b.pdf 产出的字段和 vendor_a.xlsx 完全一致。到这里,智能体做的只有语义提取和映射,没有任何定量计算。
同一套附件机制也能走得更远:把历史价格表或零件规格书与报价一起作为附件传入,智能体能标出与该零件通常价格明显偏离的明细——这是任何阈值都无法编码的判断。让它保持"建议"性质:问题由智能体提出,决定仍由你的代码来下。

4. 校验数据并换算币种
确定性的一半从这开始。把每份归一化后的 JSON 读回模型,用固定的汇率表换算成单一币种,再把站不住的行剔除:
using System.Text.Json;
private static readonly Dictionary<string, decimal> Fx = new()
{
["USD"] = 1.00m,
["EUR"] = 1.09m,
["GBP"] = 1.27m,
};
var quotes = new List<QuoteLine>();
foreach (string json in Directory.GetFiles(normPath, "*.json"))
{
quotes.AddRange(JsonSerializer.Deserialize<List<QuoteLine>>(File.ReadAllText(json),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!);
}
foreach (var q in quotes)
{
if (string.IsNullOrWhiteSpace(q.Part)) q.Flag = "缺少零件号";
else if (q.Quantity is null or <= 0) q.Flag = "数量非正";
else if (q.UnitPrice is null or <= 0) q.Flag = "单价非正";
else if (!Fx.TryGetValue(q.Currency ?? "", out var fx))
q.Flag = $"不支持的币种:{q.Currency ?? "(无)"}";
else if (q.Total is null) q.Flag = "缺少总价";
else if (q.DeliveryWeeks is null) q.Flag = "缺少交期";
else if (q.PaymentTerms is null) q.Flag = "缺少付款条款";
else if (Math.Abs((q.Quantity.Value * q.UnitPrice.Value
- q.Total.Value) * fx) > 0.01m)
q.Flag = "数量 × 单价 ≠ 总价";
}汇率表、校验阈值和算术,全都放在代码里——这样它们才能被评审、被版本化管理、被审计时引述。没有汇率的币种会被标记,而不是被静默当成美元;评分所依赖、却被智能体留空的任何字段也一样会被标记。Description 是唯一可以留空的字段——它不参与任何决策。智能体从没参与这些判断,它只提供了字段。
5. 用确定性规则打分排序
一条干净的行就可以打分。权重是固定的函数,不是模型的意见:
private const decimal PriceTargetUsd = 15.00m; // 每件预算
private static double Score(decimal unitUsd, int deliveryWeeks, int paymentTerms)
{
double price = unitUsd <= PriceTargetUsd ? 50.0
: 50.0 * (double)(PriceTargetUsd / unitUsd);
double delivery = deliveryWeeks <= 4 ? 30.0 : 0.0;
double terms = paymentTerms >= 30 ? 20.0 : 0.0;
return price + delivery + terms;
}把它套到每一条有效行上、排序,再写出推荐步骤要读的两份数据——排序后的报价行,和被剔除的异常行。这里的每一行都已通过第 4 步的校验,字段必然非空,! 和 .Value 只是让编译器满意:
public sealed record RankedQuote(
string Vendor, string Part, decimal UnitUsd,
int DeliveryWeeks, int PaymentTerms, double Score);
var scored = new List<RankedQuote>();
foreach (var q in quotes.Where(q => q.Flag is null))
{
decimal unitUsd = q.UnitPrice!.Value * Fx[q.Currency!];
int delivery = q.DeliveryWeeks!.Value;
int terms = q.PaymentTerms!.Value;
scored.Add(new RankedQuote(q.Vendor, q.Part!, unitUsd, delivery, terms,
Score(unitUsd, delivery, terms)));
}
scored.Sort((a, b) => b.Score.CompareTo(a.Score));
var flagged = quotes.Where(q => q.Flag is not null).ToList();
File.WriteAllText(@"C:sourcingdataranked-quotes.json",
JsonSerializer.Serialize(scored, new JsonSerializerOptions { WriteIndented = true }));
File.WriteAllText(@"C:sourcingdataflagged-lines.json",
JsonSerializer.Serialize(flagged, new JsonSerializerOptions { WriteIndented = true }));
6. 生成采购建议
最后这份文档又交回给智能体——但它是基于排序后的数据撰写,而不是选择。排序在第 5 步就已经定下来了,赢家也一样——每个零件一位:
using Spire.Doc;
// 每个零件一位赢家,由代码决定,不由模型决定。
string winners = string.Join("、", scored
.GroupBy(v => v.Part)
.Select(g => g.OrderByDescending(v => v.Score).First())
.Select(w => $"{w.Vendor}({w.Part})"));
// 智能体从指令里读取数据,而不是作为文件附件。
string rankedJson = File.ReadAllText(@"C:sourcingdataranked-quotes.json");
string flaggedJson = File.ReadAllText(@"C:sourcingdataflagged-lines.json");
using (Document report = new Document())
{
string instruction = $"""
写一份采购建议。
排序后的明细(已评分并按得分降序,每行一个对象):{rankedJson}
被剔除、未参与评分的异常行(每条都带 "flag" 字段):{flaggedJson}
各零件的获胜供应商是:{winners}
严格按上面给出的结果写明每个零件的赢家。
点名排序前三的明细,附上排序明细的对比表(供应商、零件、美元单价、交期、条款、
得分),列出异常行,用一句采购员能直接行动的
白话解释每条异常的原因。
不要重新计算得分,也不要另选赢家。
保持排版干净、可打印。
""";
AIResult result = report.AI(agent).ExecuteInstruction(
report,
instruction,
@"C:sourcingoutputpurchasing-recommendation.docx",
Array.Empty<string>());
if (result is null || !result.Success)
throw new InvalidOperationException($"报告生成失败:{result?.ErrorMessage}");
}赢家是写进指令里的纯数据,而不是模型从排序里得出的结论:物料清单里的每个零件,分别授予得分最高的供应商。它读取排序数据是为了表格和叙述;决定在上面已经做出,指令也明确禁止它重新推导这些结论。把 savePath 换成 .pdf,同一条指令就能导出建议供分发。异常标记在第 4 步也由代码判定——智能体从没评判任何一行。它在这一侧贡献的是语言:把 Flag 里 数量 × 单价 ≠ 总价 这类生硬的标记,改写为采购经理真正会读的句子,而不再重新裁决这个判断。代码判定,模型叙述——同一条边界,这次体现在输出侧。

7. 检视智能体会话
每次 ExecuteInstruction 调用都以会话(session)运行,Spire.Agent.Office 会把输入、生成的输出和执行产物一起放在 WorkDir 下:
C:sourcingwork
└─ .office_use_tmp
├─ Excel
│ └─ 0814140210_a1b2c3d4 ← vendor_a.xlsx 归一化
│ ├─ vendor_a.xlsx
│ └─ normalized.json
├─ Pdf
│ └─ 0814140223_e5f6a7b8 ← vendor_b.pdf 归一化
│ ├─ vendor_b.pdf
│ └─ normalized.json
└─ Word
└─ 0814140411_c9d0e1f2 ← 推荐文档生成
├─ ranked-quotes.json
└─ purchasing-recommendation.docx会话目录是一份完整记录——处理了什么、产出了什么,都能回溯,是整个工作流的审计轨迹。重点不在于智能体内部是怎么完成提取的:在应用层,异构的供应商文档被归约到同一套规范结构上,而校验、汇率换算、打分和供应商选择全都留在你的代码里,显式、确定。
这正是这套输出能在真实采购工作流里被信任的原因。供应商对某个数字有异议时,你可以指向会话目录,精确展示他们的报价是怎么被读出来的。
8. 完整工作流
整条流水线,从头到尾:
static void Run()
{
AIOptions agent = new() { SpireToken = spireToken, WorkDir = workDir, TimeoutMs = 300_000 };
NormalizeQuotes(agent, inboxPath, normPath); // 第 3 步 —— 智能体,语义
var quotes = LoadAndValidate(normPath); // 第 4 步 —— 代码,确定性
var (scored, flagged) = ScoreAndRank(quotes); // 第 5 步 —— 代码,确定性
WriteDatasets(scored, flagged); // 序列化为 JSON
GenerateRecommendation(agent, outputPath); // 第 6 步 —— 智能体,写文档
}这套设计里可复用的部分,并不是报价逻辑本身——而是它所划出的那条边界。语义提取(从异构 Office/PDF 文档里读出含义)属于智能体;每一个定量决策(币种、校验、打分、排序)属于你的代码。同样的切分可以直接搬到发票处理、简历筛选和 RFP 响应上:换掉规范结构、指令和打分函数,保留这条边界。
要自己跑通这套流程,Spire.Agent.Office 集成入门教程讲了集成配置,产品概览列出了 SDK 暴露的四个智能体(Word、Excel、PowerPoint、PDF)。
-
09.03
Milkis Strawberry Ad Shot Map
-
09.03
Viggle_AI中文入口_Viggle_AI官网中文使用路径
-
09.03
MiniMax_Agent_Coding_Plan任务拆得太散怎么办
-
09.03
Cinematic Beach Sunset Portrait
-
09.03
如何撰写营销活动效果评估报告?一份详细的范文与提示词供你参考!
-
09.03
Photorealistic Studio Portrait of a Man
-
- 锐捷网络(301165.SZ)发布上半年业绩
- 09.03
-
- 成都新增一个“科幻IP+人工智能”OPC社区
- 09.03
-
- AI辅助管理办公平台:新一代智能化办公赋能体系
- 09.03
-
-
- 美容院SWOT分析报告:如何在竞争中脱颖而出?
- 09.03
-
-
-
下载
- |
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- |
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏