feat(bom-costing): BOM 预估 v2 —— 价格回退工具、case 级预估卡编排与预览页

- agent 工具:尺寸感知镜像检索 + 分类画像三级级联(包装 v1 回退链,导出纯函数复用)
- case 级编排:首批代表 SKU 自动精算,其余按需点算;结果持久缓存(新表 BomCostingAgentSkuEstimate,幂等迁移)
- EstimateCard 契约:代码侧聚合五行构成/区间包络/最小置信度/不确定点并集
- API:/api/bom-costing/cases/[id]/agent-estimate(GET 缓存卡 + POST 运行,复用模块鉴权)
- 预览页:/bom-costing/estimate(预估卡 + SKU 列表按需点算 + 竞品图片浏览,旁路不过闸门不设默认入口)
- Select 组件:透传 id/aria 到触发按钮;修复 skill 知识未注入循环的缺陷
- 基线工具:report:bom-agent-baseline(定价覆盖率 + shadow 聚合,含隧道重试)
- 设计文档:docs/架构设计/BOM成本预估v2-agent方案设计-20260916.md
This commit is contained in:
WuYongJie 2026-09-16 17:37:06 +08:00
parent 6492c2e400
commit 48871454e9
22 changed files with 2030 additions and 6 deletions

View File

@ -0,0 +1,188 @@
# BOM 成本预估 v2(agent 方案)设计文档
> 日期:2026-09-16
> 状态:设计稿,待评审
> 前置阅读:`docs/项目总览.md`、`src/server/services/bom-costing/agent/`(已建成的引擎)
> 关联会话结论:1688 以图搜图锚定为过渡方案(双重失败已实测证伪),终局为 agent 估算 + 公司数据
## 1. 一句话定位
**粘贴竞品链接 → 几分钟后得到一张诚实的成本预估卡。**
原始需求从未变过。v2 的一切设计决策都回到这句话:数字 + 构成故事 + 诚实的区间,工序全部退到幕后。
## 2. v1 的教训(为什么 v2 长这样)
| v1 的问题 | 根因 | v2 的对策 |
|---|---|---|
| 工序暴露给用户(逐图泳道、五阶段时间线、重算模式、复核流) | 工程美德当成了产品 | 一根进度条 + 一张卡;审计链存在但默认隐藏 |
| 27 个 SKU 全量核算(用户等十几分钟才见结果) | 首结果时间没有被设计 | 首批 3 个精算先出结果,其余按需点算,结果缓存 |
| 十几分钟等待 | 按 SKU 串行/并发全量跑 | case 级一次运行,2-3 分钟 |
| 复杂度随精度承诺膨胀 | 用流程堆精度 | 用数据+校准逼近精度(±10% 是"尽量"的校准目标) |
| 1688 锚定顶替估算 | 上线压力下的权宜 | 1688 永远是后台交叉验证信号 |
**必须保留的教训**:用户会锚定数字。区间 + 置信度 + 不确定点是答案的一部分,不是脚注——这是"给一个数"和"给一个诚实的数"的差别。
## 3. 设计总纲(五条,裁决一切分歧)
1. **预估,不是核算**:输出是带区间与置信度的假设生成器,供决策筛选,永不承诺报价级精度。
2. **agent 主导分析,代码承载确定性**:看什么图、怎么拆结构、查什么价格由 agent 决定;一切数值计算(汇总、区间合成、损耗)由确定性代码完成。模型永不算数、永不编价。
3. **价格只认公司数据**:历史 BOM 镜像是唯一定价依据(检索→分类中位→cohort→损耗四级回退);1688 行情仅作后台交叉验证,永不成为展示价。
4. **前端来源中性(不可触碰)**:界面永不出现 1688 字样/报价/链接;一切序列化出口经过 `src/lib/bom-costing/public-estimate-text.ts`。
5. **实测闸门**:agent 流程未经回测与真实案例对比验证(偏差分布达标)前,不替换任何线上行为。
## 4. 用户形态
### 4.1 默认入口:预估卡 + 全量 SKU 按需核算
**覆盖率是需求,等待时间是体验问题**——所有 SKU 都要能出结果,但采用"先出一批、其余按需点算"的渐进交付:
```
粘贴链接 → [进度条:抓取中 → 分析中 → 出预估](2-3 分钟)→
┌────────────────────────────────────────┐
│ 源氏木语 白蜡木实木衣柜 │
│ 预计出厂成本 ¥260 – 380(首批 3 个 SKU)│
│ 置信度:中等 │
│ 板材 ¥180± · 五金 ¥25± · 加工+人工 ¥60± · 包材 ¥15± │
│ 依据:8 张商品图 + 公司历史 BOM 12 条 │
│ 不确定:板厚未确认;铰链数量按两门推定 │
└────────────────────────────────────────┘
SKU 列表(27 个):
[¥260.29 已核算] (3) 原木色 0.9米两门 ← 首批自动精算
[¥375.48 已核算] (7) 原木色 1.0米 ← 首批自动精算
[¥406.77 已核算] (18) 原木色 1.96米 ← 首批自动精算
[点击核算] (4) 原木色 0.9米/高2.2米 ← 用户点击后单独运行
[点击核算] ... ← 已算结果缓存,不重跑
────────────────────────────────────────
商品图片(保留现有形式:主图/SKU图/详情图 分类浏览 + 大图预览)
```
- **首批自动精算**:默认 3 个代表性 SKU(跨度原则:最小/最大/中间,或按材质/结构差异选),2-3 分钟出卡——用户立刻有结果。
- **其余 SKU 按需点算**:未核算的 SKU 显示占位按钮,用户点击触发单 SKU agent 运行(复用现有 per-SKU runner,1-2 分钟/个);**结果持久缓存**,重复点击不重跑。
- 进度状态只有三个:抓取中 / 分析中 / 出预估(含失败态与重试按钮)。
- **渐进展开**:点构成 → BOM 行明细;点行 → 证据(哪张图、哪条镜像参考行)。审计链存在,默认折叠。
- **商品图片保留现有展示形式**(主图/SKU 图/详情图分类 + 逐图状态 + 大图预览):用户需要直接查看竞品链接内容,图片是判断依据,不是工程噪声。
### 4.2 深度模式(保留现有工作台)
需要全量 27 SKU 拆解、人工核对、证据溯源的用户,从预估卡一键进入现有工作台。v2 验证前工作台不动;验证后它降级为二级入口,不再是默认。
## 5. 架构:case 级 agent 运行
### 5.1 运行编排(新,核心增量)
交付模型:**首批自动精算 + 其余按需点算**。现有 per-SKU runner 是按需点算的执行体,case 级编排负责首批选择与结果汇总。
```
阶段一:runBomCostingAgentEstimateForCase(caseId)(首批)
1. 取案例简报(标题/渠道/已解析品类 skill)+ 归档素材 + SKU 清单(含尺寸)
2. 代码侧选首批 SKU(默认 3 个:最小/最大/中间,或按材质/结构差异;
单 SKU 商品即为它自己)
3. 逐个运行现有 per-SKU agent 循环(结构行 + 查价)
4. 代码侧汇总出 EstimateCard:卡面五行/区间/置信度 + SKU 列表状态
(已核算 3 / 待核算 24)
阶段二:按需点算(用户驱动)
用户点击未核算 SKU → 单 SKU agent 运行(现有 runner)→
结果持久缓存 → 卡面/SKU 列表实时更新
```
- 首批预算:≤24 轮 / ≤32 次模型调用 / ≤8 分钟(per-SKU 预算 × 3 + 编排开销)。
- **结果缓存**:per-SKU 结果按 (caseId, skuId, 引擎版本) 持久化;点击已核算 SKU 直接展示缓存,不重跑。SKU 重算 = 显式刷新动作(换引擎版本或用户主动重跑)。
- 失败语义:首批中单个 SKU 失败 → 该 SKU 标记失败可单独重试,不拖垮整卡;结构成功但价格饥饿 → 出结果但区间放宽 + 不确定点说明(PARTIAL 是合法产物)。
- 并发上限与幂等:同一 SKU 的点算请求可去重合并(进行中重复点击返回同一运行)。
### 5.2 输出契约:EstimateCard(case 级)
```ts
interface BomEstimateCard {
caseId: string
title: string
skus: Array<{ // 全量 SKU 清单 + 状态
externalSkuId: string
name: string
status: 'ESTIMATED' | 'PENDING' | 'FAILED' | 'RUNNING'
estimate?: BomCostingAgentEstimate // 已核算时:行/区间/置信度/evidence
cachedAt?: string
}>
firstBatchSkuIds: string[] // 首批自动精算的 SKU
breakdown: Array<{ // 卡面五行(由已核算 SKU 的行汇总;随点算增长)
category: 'BOARD'|'STEEL'|'ACCESSORY'|'PACKAGING'|'PROCESSING'|'LABOR'
total: number | null // null = 该组无价,卡面显示"待补"
}>
totalRange: { min: number; max: number } // 当前已核算 SKU 的区间包络
confidence: number // 已核算 SKU 的最小置信度
openQuestions: string[] // 卡面"不确定"(已核算 SKU 的并集)
usage: { rounds; modelCalls; elapsedMs }
}
```
原则:明细(行级)保留在各 SKU 的 estimate 里供展开;卡面只消费 breakdown/totalRange/confidence/openQuestions,并随按需点算增量更新。
### 5.3 价格供给(核心依赖,最大缺口)
首测"定价饥饿"的根治项——把 v1 场景服务的回退链包装成 agent 工具:
| 优先级 | 工具 | 包装自 |
|---|---|---|
| P0 | 尺寸感知镜像检索(分类/材质词/尺寸/参考 SKU 多级过滤) | `findReferenceCandidates` |
| P0 | 分类画像中位价 | `fillCategoryProfileFallbacks` |
| P1 | 同类 cohort 报价 | `resolveCohortQuote` |
| P1 | 历史损耗率 | `applyHistoricalLossFactorFallbacks` |
| P2 | AI 补估(置信度上限 0.75 + 标记复核) | 现有 estimator |
工具输出纪律:摘要级(无供应商/店铺/链接),带 sourceRowId 供 evidence 引用,单位明确。**1688 工具维持现状**(只透出状态/参考价/区间/可比数四个中性字段)。
### 5.4 数据与知识资产接入(全部保留,形态不变)
| 资产 | v2 中的角色 | 接入形态 |
|---|---|---|
| 历史 BOM 镜像(每日 MSSQL 同步) | 唯一价格依据 | 工具(5.3) |
| 品类 Skill(5 个)+ 结构先验 | agent 脑中的专家清单:必备组件、证据纪律、校验规则 | 提示词注入(`renderBomCostingAgentSkillKnowledge`,已建成) |
| 材质目录 | 材质身份唯一权威 | 工具(已接线) |
| 商品抓取/归档/抠图 | 看图取证的素材 | 工具 + mission 组装(已接线;抠图模型待切 remove-background) |
| 1688 行情 | 后台交叉验证信号 | 工具(已接线,中性摘要) |
### 5.5 校准闭环(±10% "尽量"的实现方式)
- **回测**:历史 BOM 镜像抽 20-50 个真实 SKU,同一规则/agent 重算 vs 真实成本,产出误差分布(复用 `backtest:bom-cost-reference` 基建)。
- **shadow 对比**:agent 结果与现行引擎结果对同一案例并排落库(工程域),持续记录偏差(`report:bom-cost-shadow-divergence` 同款模式)。
- **阈值语义**:±10% 是回测循环追的校准目标(按品类分段看分布),不是单案例门禁。偏差大 → 调工具/知识/提示词 → 再回测。
- 未过门槛 → agent 流程保持旁路,线上仍是现行方案。
## 6. 前端形态
- **新入口**:`/bom-costing` 默认视图替换为预估卡流(粘贴框 → 卡 → SKU 列表 → 图片浏览)。
- **商品图片**:保留现有展示形式(主图/SKU 图/详情图分类 tab + 大图预览 + 逐图状态),用户需要直接查看竞品内容;它从"处理进度件"回归"内容主体"。
- **SKU 列表**:已核算显示价格,未核算显示"点击核算"按钮(运行中显示进度态);点击结果写缓存并即时更新卡面。
- **组件**:新 `BomEstimateCard`(平台组件体系 + 白曜石规范),状态只有 加载/成功/失败(可重试)。
- **现有工作台**:不动,作为深度模式从卡进入。
- **移动端**:卡、SKU 列表与图片浏览响应式。
- 红线复检:卡面文案经过 `publicEstimateWarningText` 清洗;evidence 展开层同样不得出现来源敏感词。
## 7. 落地顺序(每步可独立验证)
| 步骤 | 内容 | 验收 |
|---|---|---|
| ① | 价格回退工具(P0 两个) | 对已知案例重跑,未定价行显著下降 |
| ② | 首批编排 + EstimateCard 契约 + 按需点算与结果缓存 | 单案例 2-8 分钟出首批卡;点击未核算 SKU 增量出结果且缓存生效 |
| ③ | 预估卡前端(含 SKU 列表与现有图片浏览)+ 三态进度 | 真人走通 粘贴→出卡→点算其他 SKU→看图核对 全流程 |
| ④ | 回测 + shadow 接入,按品类出基线报告 | 误差分布报告(对照 ±10% 目标) |
| ⑤ | 实测样本扩到 5-10 案例,按品类定门槛 | 达标 → 翻转默认入口;未达标 → 继续迭代 |
## 8. 红线(重申)
1. 前端永不出现 1688 相关内容;一切文本出口过脱敏契约。
2. agent 引擎在过实测闸门前保持旁路(不替换线上行为)。
3. 区间 + 置信度 + 不确定点是答案的一部分,任何展示形态不得省略。
4. 单价只来自工具结果;总额只由代码汇总。
5. 现有工作台在 v2 过门槛前不删不改语义。
## 9. 当前资产清单(截至本文日期)
已建成:agent 契约/工具层/循环/网关适配/skill 知识渲染/per-SKU runner(27 测试)、镜像与材质只读仓储、1688 中性摘要工具、板材开料工具、shadow 报表、trial 试跑脚本、生产真实案例(源氏木语 27 SKU)对照数据。
待建:价格回退工具(5.3 表)、case 级编排、EstimateCard 契约与前端、回测接入。
外部依赖:1688 新 key(当前过期,影响搜图与 A/B 实测,不影响 ①② 开发)。

View File

@ -23,6 +23,9 @@
"sync:jst-sku-product-archive": "tsx scripts/sync-jst-sku-product-archive.ts",
"sync:bom-cost-reference": "tsx scripts/sync-bom-cost-reference.ts",
"backtest:bom-cost-reference": "tsx scripts/backtest-bom-cost-reference.ts",
"report:bom-cost-shadow-divergence": "tsx scripts/report-bom-cost-shadow-divergence.ts",
"report:bom-agent-baseline": "tsx scripts/report-bom-agent-baseline.ts",
"trial:bom-costing-agent": "tsx scripts/run-bom-costing-agent-trial.ts",
"import:yunshan-created-at": "tsx scripts/import-yunshan-product-created-at.ts",
"import:product-archive-drawing-media": "tsx scripts/import-product-archive-drawing-media.ts",
"db:verify-master-data": "node scripts/verify-master-data-foundation.js",

View File

@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "BomCostingAgentSkuEstimate" (
"id" TEXT NOT NULL,
"caseId" TEXT NOT NULL,
"skuId" TEXT NOT NULL,
"externalSkuId" TEXT NOT NULL DEFAULT '',
"engineVersion" TEXT NOT NULL,
"status" TEXT NOT NULL,
"estimate" JSONB NOT NULL,
"usage" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BomCostingAgentSkuEstimate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "BomCostingAgentSkuEstimate_skuId_engineVersion_key" ON "BomCostingAgentSkuEstimate"("skuId", "engineVersion");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "BomCostingAgentSkuEstimate_caseId_idx" ON "BomCostingAgentSkuEstimate"("caseId");

View File

@ -4291,3 +4291,21 @@ model HomeQuickLink {
@@index([enabled, sortOrder])
}
/// BOM agent 旁路引擎的单 SKU 估算缓存(工程域;主键语义:skuId + engineVersion)。
/// 纯新增表,不与现有业务模型建外键关系;在 agent 流程过实测闸门前仅供工程侧工具使用。
model BomCostingAgentSkuEstimate {
id String @id @default(cuid())
caseId String
skuId String
externalSkuId String @default("")
engineVersion String
status String
estimate Json
usage Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([skuId, engineVersion])
@@index([caseId])
}

View File

@ -0,0 +1,281 @@
import { loadEnvConfig } from '@next/env'
import { Prisma } from '@prisma/client'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { bomCostingScenarioRepository } from '../src/server/repositories/bom-costing-scenario.repository'
loadEnvConfig(process.cwd())
/**
* BOM agent 基线报告(工程域,第④步 · 第一部分):
* 1) 定价覆盖率探测——对既有案例的 BOM 行,用新的回退工具链
* (尺寸感知候选检索 → 分类画像中位)做确定性试定价,零模型费用,
* 回答“定价饥饿根治了吗”。
* 2) shadow 偏差聚合——读取 agent 缓存表中已核算 SKU,对比现行引擎落库价格。
* 缓存为空时提示先运行 trial --card。
* 输出只进工程域(stdout / --json)。
*/
type CliOptions = { caseId: string; json: boolean }
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = { caseId: '', json: false }
for (const argument of argv) {
if (argument === '--help' || argument === '-h') {
console.log([
'用法: npm run report:bom-agent-baseline -- --case=<案例ID> [--json]',
'',
' --case=<id> 指定 READY 案例 ID',
' --json 以 JSON 输出完整报告',
].join('\n'))
process.exit(0)
} else if (argument.startsWith('--case=')) {
options.caseId = argument.slice('--case='.length).trim()
} else if (argument === '--json') {
options.json = true
} else {
throw new Error(`未知参数:${argument}(--help 查看用法)`)
}
}
if (!options.caseId) throw new Error('必须指定 --case=<案例ID>')
return options
}
interface LineProbe {
lineId: string
category: string
partName: string
materialName: string
v1Priced: boolean
candidatesHit: boolean
profileFallback: boolean
candidateCount: number
candidateUnitPriceRange: [number, number] | null
}
function decimalNumber(value: unknown): number | null {
if (value === null || value === undefined) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
// 隧道不稳定(已知,远端频繁重置连接):对连接类失败做长容忍重试。
async function withRetry<T>(operation: () => Promise<T>, attempts = 8): Promise<T> {
let lastError: unknown
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await operation()
} catch (error) {
lastError = error
const message = String(error instanceof Error ? error.message : error)
if (!/Connection terminated|Can't reach database|timed out|Closed|reset/i.test(message)) throw error
await new Promise((resolve) => setTimeout(resolve, 2_000))
}
}
throw lastError
}
async function probeLine(syncRunId: string, line: {
id: string
category: string
partName: string
materialName: string
lengthMm: Prisma.Decimal | null
widthMm: Prisma.Decimal | null
resolvedUnitPrice: Prisma.Decimal | null
}): Promise<LineProbe> {
const candidates = await withRetry(() => bomCostingScenarioRepository.findReferenceCandidates({
syncRunId,
category: line.category,
partName: line.partName || null,
materialName: line.materialName || null,
lengthMm: decimalNumber(line.lengthMm),
widthMm: decimalNumber(line.widthMm),
limit: 10,
}))
let profileFallback = false
if (candidates.length === 0) {
const cohortCandidates = await withRetry(() => bomCostingScenarioRepository.findCategoryCohortCandidates({
syncRunId,
category: line.category,
limit: 500,
}))
profileFallback = cohortCandidates.length > 0
}
const prices = candidates
.map((candidate) => decimalNumber(candidate.unitPrice))
.filter((value): value is number => value !== null && value > 0)
return {
lineId: line.id,
category: line.category,
partName: line.partName,
materialName: line.materialName,
v1Priced: decimalNumber(line.resolvedUnitPrice) !== null,
candidatesHit: candidates.length > 0,
profileFallback,
candidateCount: candidates.length,
candidateUnitPriceRange: prices.length > 0
? [Math.min(...prices), Math.max(...prices)]
: null,
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
await mkdir(path.join(process.cwd(), 'node_modules', 'server-only'), { recursive: true })
await writeFile(path.join(process.cwd(), 'node_modules', 'server-only', 'index.js'), 'module.exports = {}\n')
const { prisma } = await import('../src/lib/prisma')
try {
const { latestSuccessful } = await withRetry(() => bomCostingScenarioRepository.findLatestSyncStatuses())
if (!latestSuccessful) throw new Error('镜像没有成功同步批次,无法探测')
const skus = await withRetry(() => prisma.bomCostingSku.findMany({
where: { caseId: options.caseId },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
externalSkuId: true,
name: true,
bomLines: {
select: {
id: true,
category: true,
partName: true,
materialName: true,
lengthMm: true,
widthMm: true,
resolvedUnitPrice: true,
},
},
},
}))
if (skus.length === 0) throw new Error('案例没有 SKU')
// shadow 部分:agent 缓存(可能为空;表未迁移时降级为不可用)
let cached: Array<{ skuId: string; status: string; estimate: unknown }> = []
let cacheUnavailable: string | null = null
try {
cached = await prisma.bomCostingAgentSkuEstimate.findMany({
where: { caseId: options.caseId, engineVersion: 'bom-costing-agent-card/v1' },
select: { skuId: true, status: true, estimate: true },
})
} catch {
cacheUnavailable = '缓存表未创建(迁移未应用),shadow 对比暂不可用'
}
const scenarioBySku = new Map(
(await withRetry(() => prisma.bomCostingScenario.findMany({
where: { caseId: options.caseId, kind: 'SAME_MATERIAL', isCurrent: true },
select: { skuId: true, totalCost: true, realtimeMarketQuote: true },
}))).map((scenario) => [scenario.skuId, scenario]),
)
// v1 的行级定价在场景行(动态成本阶段),不在源 BOM 行的 resolvedUnitPrice。
const scenarioLinesBySku = new Map<string, { priced: number; total: number }>()
const scenarioLines = await withRetry(() => prisma.bomCostingScenarioLine.findMany({
where: { scenario: { caseId: options.caseId, kind: 'SAME_MATERIAL', isCurrent: true } },
select: { unitPrice: true, subtotal: true, estimateLow: true, scenario: { select: { skuId: true } } },
}))
for (const line of scenarioLines) {
const lineSkuId = line.scenario.skuId
const entry = scenarioLinesBySku.get(lineSkuId) ?? { priced: 0, total: 0 }
entry.total += 1
if (
(line.unitPrice !== null && Number(line.unitPrice) > 0) ||
line.subtotal !== null || line.estimateLow !== null
) entry.priced += 1
scenarioLinesBySku.set(lineSkuId, entry)
}
const skuReports: Array<Record<string, unknown>> = []
let totalLines = 0
let totalCandidatesHit = 0
let totalProfileFallback = 0
let totalV1Priced = 0
const categoryStats = new Map<string, { lines: number; candidatesHit: number; profileFallback: number; v1Priced: number }>()
// 只探测前 3 个 SKU(覆盖探测是确定性的,3 个足以代表;全量探测耗时线性增长)
let totalV1Lines = 0
for (const sku of skus.slice(0, 3)) {
const probes: LineProbe[] = []
for (const line of sku.bomLines) {
probes.push(await probeLine(latestSuccessful.id, line))
}
const candidatesHit = probes.filter((probe) => probe.candidatesHit).length
const profileFallback = probes.filter((probe) => probe.profileFallback && !probe.candidatesHit).length
totalLines += probes.length
totalCandidatesHit += candidatesHit
totalProfileFallback += profileFallback
for (const probe of probes) {
const stats = categoryStats.get(probe.category) ?? { lines: 0, candidatesHit: 0, profileFallback: 0, v1Priced: 0 }
stats.lines += 1
if (probe.candidatesHit) stats.candidatesHit += 1
else if (probe.profileFallback) stats.profileFallback += 1
categoryStats.set(probe.category, stats)
}
const scenario = scenarioBySku.get(sku.id)
const quote = (scenario?.realtimeMarketQuote ?? null) as Record<string, unknown> | null
const cachedEstimate = cached.find((entry) => entry.skuId === sku.id)
const v1ScenarioLines = scenarioLinesBySku.get(sku.id) ?? { priced: 0, total: 0 }
totalV1Priced += v1ScenarioLines.priced
totalV1Lines += v1ScenarioLines.total
skuReports.push({
externalSkuId: sku.externalSkuId,
name: sku.name.slice(0, 20),
lines: probes.length,
candidatesHit,
profileFallback,
unpriced: probes.length - candidatesHit - profileFallback,
v1Priced: v1ScenarioLines.priced,
v1LineTotal: v1ScenarioLines.total,
currentEngineTotal: scenario?.totalCost === null || scenario?.totalCost === undefined ? null : Number(scenario.totalCost),
structuralCost: quote?.structuralCost ?? null,
anchorCost: quote?.procurementCost ?? null,
agentCached: cachedEstimate ? cachedEstimate.status : 'NONE',
})
}
const report = {
caseId: options.caseId,
probedSkuCount: Math.min(3, skus.length),
totalSkuCount: skus.length,
priceCoverage: {
totalLines,
candidatesHit: totalCandidatesHit,
profileFallback: totalProfileFallback,
unpriced: totalLines - totalCandidatesHit - totalProfileFallback,
coveragePct: totalLines === 0 ? null : Number((((totalCandidatesHit + totalProfileFallback) / totalLines) * 100).toFixed(1)),
v1Priced: totalV1Priced,
v1CoveragePct: totalV1Lines === 0 ? null : Number(((totalV1Priced / totalV1Lines) * 100).toFixed(1)),
},
byCategory: [...categoryStats.entries()].map(([category, stats]) => ({ category, ...stats })),
skus: skuReports,
shadow: {
cachedSkuCount: cached.length,
note: cacheUnavailable ?? (cached.length === 0 ? '缓存为空:先运行 trial --card 生成 agent 结果后重跑本报告' : undefined),
},
generatedAt: new Date().toISOString(),
}
if (options.json) {
console.log(JSON.stringify(report, null, 2))
} else {
console.log(`案例 ${options.caseId} 基线探测(前 ${report.probedSkuCount}/${report.totalSkuCount} SKU,${totalLines} 行)`)
console.log(`新工具链覆盖:候选命中 ${totalCandidatesHit} + 画像兜底 ${totalProfileFallback} = ${report.priceCoverage.coveragePct}%(未定价 ${report.priceCoverage.unpriced})`)
console.log(`v1 场景行已定价:${totalV1Priced}/${totalV1Lines}(${report.priceCoverage.v1CoveragePct}%)`)
console.log('--- 按分类 ---')
for (const [category, stats] of categoryStats) {
console.log(` ${category}: ${stats.lines} 行,候选 ${stats.candidatesHit},画像 ${stats.profileFallback}`)
}
console.log('--- 逐 SKU ---')
for (const sku of skuReports) {
console.log(` ${sku.externalSkuId} ${sku.name} | 行=${sku.lines} 候选=${sku.candidatesHit} 画像=${sku.profileFallback} 未定价=${sku.unpriced} | v1定价行=${sku.v1Priced}/${sku.v1LineTotal} | 现行总价=${sku.currentEngineTotal} 结构=${sku.structuralCost} 锚定=${sku.anchorCost} | agent缓存=${sku.agentCached}`)
}
console.log(`shadow:${report.shadow.cachedSkuCount} 个缓存结果${report.shadow.note ? `(${report.shadow.note})` : ''}`)
}
} finally {
await prisma.$disconnect()
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})

View File

@ -26,6 +26,7 @@ type CliOptions = {
skuId: string | null
maxImages: number
json: boolean
card: boolean
}
const DEFAULT_MAX_IMAGES = 8
@ -37,15 +38,18 @@ function parseArgs(argv: string[]): CliOptions {
skuId: null,
maxImages: DEFAULT_MAX_IMAGES,
json: false,
card: false,
}
for (const argument of argv) {
if (argument === '--help' || argument === '-h') {
console.log([
'用法: npm run trial:bom-costing-agent -- --case=<案例ID> [--sku=<SKU记录ID>] [--max-images=N] [--json]',
' 或: npm run trial:bom-costing-agent -- --case=<案例ID> --card [--json] (case 级预估卡:首批自动精算)',
' 或: npm run trial:bom-costing-agent -- --latest=N (最近 N 个 READY 案例的主 SKU)',
'',
' --case=<id> 指定案例 ID(与 --latest 二选一)',
' --sku=<id> 指定 SKU 记录 ID(默认取排序最前的 SKU)',
' --card case 级运行:首批代表 SKU 精算并输出预估卡(其余 SKU 可用 --sku 按需点算)',
' --max-images=N 送入模型的图片上限,默认 8',
' --json 以 JSON 输出完整结果(含 trace)',
'',
@ -68,6 +72,8 @@ function parseArgs(argv: string[]): CliOptions {
throw new Error('--max-images must be an integer between 1 and 16')
}
options.maxImages = maxImages
} else if (argument === '--card') {
options.card = true
} else if (argument === '--json') {
options.json = true
} else {
@ -90,8 +96,55 @@ function agentTotalFromResult(result: BomCostingAgentRunResult) {
return Math.round(total * 100) / 100
}
async function runCardMode(options: CliOptions) {
await ensureServerOnlyStub()
const { prisma } = await import('../src/lib/prisma')
const { runBomCostingAgentEstimateForCase, estimateSkuOnDemand } = await import(
'../src/server/services/bom-costing/agent/bom-costing-agent-case-runner'
)
try {
if (!options.caseId) throw new Error('--card 模式必须指定 --case=<案例ID>')
console.log(`case 级预估卡运行中(首批自动精算):case=${options.caseId}`)
const { card } = await runBomCostingAgentEstimateForCase({
caseId: options.caseId,
options: { maxImages: options.maxImages },
})
if (options.json) {
console.log(JSON.stringify(card, null, 2))
return
}
console.log(`\n=== ${card.title} ===`)
console.log(`预计出厂成本区间(已核算 ${card.estimatedSkuCount} SKU):${card.totalRange ? `¥${card.totalRange.min} ~ ¥${card.totalRange.max}` : '--'}`)
console.log(`置信度:${card.confidence === null ? '--' : `${Math.round(card.confidence * 100)}%`}`)
console.log('构成:' + card.breakdown.map((row) => `${row.category} ${row.total === null ? '待补' : `¥${row.total}`}`).join(' · '))
if (card.openQuestions.length > 0) {
console.log('不确定:' + card.openQuestions.slice(0, 5).join(';'))
}
console.log('')
for (const sku of card.skus) {
const marker = sku.firstBatch ? '[首批]' : ' '
const price = sku.status === 'ESTIMATED' && sku.estimate
? `¥${sku.estimate.estimateRange.min}~${sku.estimate.estimateRange.max}`
: sku.status === 'FAILED' ? '核算失败(可重试)' : '点击核算'
console.log(` ${marker} ${sku.externalSkuId} ${sku.name.slice(0, 22)} | ${price}`)
}
if (options.skuId) {
console.log(`\n按需点算:sku=${options.skuId}`)
const entry = await estimateSkuOnDemand({ caseId: options.caseId, skuId: options.skuId })
const estimate = entry.runResult?.estimate
console.log(` 结果:${entry.status}${estimate ? ` ¥${estimate.estimateRange.min}~${estimate.estimateRange.max} 置信度 ${Math.round(estimate.confidence * 100)}%` : ''}`)
}
} finally {
await prisma.$disconnect()
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.card) {
await runCardMode(options)
return
}
await ensureServerOnlyStub()
const { prisma } = await import('../src/lib/prisma')
const { runBomCostingAgentTrialForSku } = await import(

View File

@ -0,0 +1,79 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { bomCostingAccessService } from '@/server/services/bom-costing/bom-costing-access.service'
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import {
estimateSkuOnDemand,
getAgentEstimateCard,
runBomCostingAgentEstimateForCase,
} from '@/server/services/bom-costing/agent/bom-costing-agent-case-runner'
type RouteContext = {
params: Promise<{ id: string }>
}
async function buildCasePreview(caseId: string) {
const card = await getAgentEstimateCard(caseId)
if (!card) throw new AppError(ErrorCode.BOM_COSTING_CASE_NOT_FOUND)
const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(caseId)
return {
card,
images: assets.map((asset) => ({
assetId: asset.id,
kind: asset.kind,
url: asset.storedUrl || asset.originalUrl,
skuTags: Array.isArray(asset.externalSkuIds)
? (asset.externalSkuIds as unknown[]).filter((item): item is string =>
typeof item === 'string' && item.trim() !== '')
: [],
})),
}
}
export async function GET(request: NextRequest, context: RouteContext) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
try {
await bomCostingAccessService.assertCanView(ctx.user)
return jsonSuccess(await buildCasePreview(decodeURIComponent(id)), 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}
export async function POST(request: NextRequest, context: RouteContext) {
return handleRequest(
request,
async (ctx) => {
const { id } = await context.params
const caseId = decodeURIComponent(id)
try {
await bomCostingAccessService.assertCanView(ctx.user)
const body = await request.json().catch(() => ({})) as { skuId?: string }
// agent 估算走旁路引擎:POST 触发运行(case 首批 或 单 SKU 按需),
// 完成后返回聚合卡片。运行时长可达数分钟,调用方需耐心等待。
if (body.skuId) {
await estimateSkuOnDemand({ caseId, skuId: body.skuId })
} else {
await runBomCostingAgentEstimateForCase({ caseId })
}
return jsonSuccess(await buildCasePreview(caseId), 200, ctx.requestId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
},
{ requireAuth: true },
)
}

View File

@ -0,0 +1,251 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { ToastContainer, useToast } from '@/components/ui/Toast'
import { cn } from '@/lib/utils'
import type { BomEstimateCard } from '@/server/services/bom-costing/agent/agent-estimate-card'
type Phase = 'idle' | 'creating' | 'analyzing' | 'ready' | 'error'
interface PreviewImage {
assetId: string
kind: 'MAIN' | 'SKU' | 'DETAIL'
url: string
skuTags: string[]
}
const IMAGE_TABS: Array<{ key: PreviewImage['kind'] | 'ALL'; label: string }> = [
{ key: 'ALL', label: '全部' },
{ key: 'MAIN', label: '主图' },
{ key: 'SKU', label: 'SKU 图' },
{ key: 'DETAIL', label: '详情图' },
]
const BREAKDOWN_LABELS: Record<string, string> = {
BOARD: '板材',
STEEL: '钢材',
ACCESSORY: '五金',
PACKAGING: '包材',
PROCESSING: '加工',
LABOR: '人工',
}
function confidenceLabel(value: number | null) {
if (value === null) return '待确认'
if (value >= 0.8) return '较高'
if (value >= 0.6) return '中等'
return '较低'
}
export default function EstimateClient() {
const toast = useToast()
const { toasts, removeToast } = useToast()
const [sourceUrl, setSourceUrl] = useState('')
const [phase, setPhase] = useState<Phase>('idle')
const [errorMessage, setErrorMessage] = useState('')
const [caseId, setCaseId] = useState<string | null>(null)
const [card, setCard] = useState<BomEstimateCard | null>(null)
const [images, setImages] = useState<PreviewImage[]>([])
const [imageTab, setImageTab] = useState<PreviewImage['kind'] | 'ALL'>('ALL')
const [selectedImageId, setSelectedImageId] = useState<string | null>(null)
const [pendingSkuId, setPendingSkuId] = useState<string | null>(null)
async function handleEstimate() {
if (!sourceUrl.trim()) return
setPhase('creating')
setErrorMessage('')
setCard(null)
setImages([])
try {
const createRes = await fetch('/api/bom-costing/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceUrl: sourceUrl.trim() }),
})
const createData = await createRes.json()
const newCaseId: string | undefined = createData?.data?.case?.id
if (!createData.success || !newCaseId) {
throw new Error(createData?.error?.message || '无法创建核算案例,请检查链接')
}
setCaseId(newCaseId)
setPhase('analyzing')
const runRes = await fetch(`/api/bom-costing/cases/${newCaseId}/agent-estimate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const runData = await runRes.json()
if (!runData.success) {
throw new Error(runData?.error?.message || '预估失败,请稍后重试')
}
setCard(runData.data.card as BomEstimateCard)
setImages(runData.data.images as PreviewImage[])
setPhase('ready')
} catch (error) {
setPhase('error')
setErrorMessage(error instanceof Error ? error.message : '预估失败,请稍后重试')
}
}
async function handleEstimateSku(skuId: string) {
if (!caseId) return
setPendingSkuId(skuId)
try {
const res = await fetch(`/api/bom-costing/cases/${caseId}/agent-estimate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ skuId }),
})
const data = await res.json()
if (!data.success) throw new Error(data?.error?.message || '核算失败')
setCard(data.data.card as BomEstimateCard)
toast.success('该 SKU 已完成核算')
} catch (error) {
toast.error(error instanceof Error ? error.message : '核算失败')
} finally {
setPendingSkuId(null)
}
}
const visibleImages = images.filter((image) => imageTab === 'ALL' || image.kind === imageTab)
const selectedImage = visibleImages.find((image) => image.assetId === selectedImageId) ?? visibleImages[0] ?? null
const busy = phase === 'creating' || phase === 'analyzing'
return (
<div className="mx-auto w-full max-w-6xl px-4 py-6">
<ToastContainer toasts={toasts} onClose={removeToast} position="top" />
<header className="mb-4">
<h1 className="text-base font-bold text-[#2f3a3d]">BOM 成本预估</h1>
<p className="mt-0.5 text-xs text-[#6a7a7d]">粘贴竞品链接,先出代表 SKU 的成本预估,其余 SKU 点击逐个核算(v2 预览)。</p>
</header>
<div className="flex items-center gap-2 rounded-[8px] bg-white p-3 ring-1 ring-black/[0.06]">
<Input
value={sourceUrl}
onChange={(event) => setSourceUrl(event.target.value)}
placeholder="粘贴竞品商品链接(淘宝/天猫/1688/京东/抖音)"
disabled={busy}
className="h-9 flex-1 text-xs"
/>
<Button size="small" onClick={handleEstimate} disabled={busy || !sourceUrl.trim()} className="h-9">
{busy ? '预估中…' : '开始预估'}
</Button>
</div>
{busy && (
<div className="mt-4 flex items-center gap-3 rounded-[8px] bg-white px-4 py-3 text-xs text-[#4a5a5d] ring-1 ring-black/[0.06]">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-[#47646b] border-t-transparent" />
{phase === 'creating' ? '正在读取竞品资料与归档图片…' : 'AI 正在看图拆结构、检索公司历史价格…(约 2-3 分钟,请保持页面打开)'}
</div>
)}
{phase === 'error' && (
<div className="mt-4 rounded-[8px] bg-red-500/[0.06] px-4 py-3 text-xs text-red-600 ring-1 ring-red-500/20">
{errorMessage}
</div>
)}
{card && phase === 'ready' && (
<div className="mt-4 space-y-4">
<section className="rounded-[10px] bg-white p-5 ring-1 ring-black/[0.06]">
<h2 className="text-sm font-bold text-[#2f3a3d]">{card.title || '竞品成本预估'}</h2>
<div className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-2xl font-black tracking-tight text-[#2f3a3d]">
{card.totalRange ? `¥${card.totalRange.min} – ${card.totalRange.max}` : '暂未形成区间'}
</span>
<span className="rounded-[4px] bg-[#47646b]/10 px-1.5 py-0.5 text-[10px] font-bold text-[#47646b]">
置信度 {confidenceLabel(card.confidence)}{card.confidence !== null ? ` ${Math.round(card.confidence * 100)}%` : ''}
</span>
<span className="text-[10px] text-[#8a9799]">已核算 {card.estimatedSkuCount}/{card.skus.length} 个 SKU</span>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[#4a5a5d]">
{card.breakdown.map((row) => (
<span key={row.category}>
{BREAKDOWN_LABELS[row.category] ?? row.category}
<span className="ml-1 font-bold text-[#2f3a3d]">{row.total === null ? '待补' : `¥${row.total}`}</span>
</span>
))}
</div>
{card.openQuestions.length > 0 && (
<p className="mt-3 rounded-[6px] bg-amber-500/[0.06] px-3 py-2 text-[11px] leading-4 text-[#8a5f3f]">
不确定:{card.openQuestions.slice(0, 4).join(';')}
</p>
)}
</section>
<section className="rounded-[10px] bg-white p-4 ring-1 ring-black/[0.06]">
<h3 className="text-xs font-bold text-[#2f3a3d]">SKU 列表</h3>
<ul className="mt-2 divide-y divide-black/[0.04]">
{card.skus.map((sku) => (
<li key={sku.skuId} className="flex items-center justify-between gap-3 py-2 text-xs">
<div className="min-w-0 flex-1">
<span className="font-bold text-[#2f3a3d]">{sku.externalSkuId}</span>
<span className="ml-2 truncate text-[#6a7a7d]">{sku.name}</span>
{sku.firstBatch && (
<span className="ml-2 rounded-[3px] bg-[#47646b]/10 px-1 py-px text-[9px] font-bold text-[#47646b]">首批</span>
)}
</div>
{sku.status === 'ESTIMATED' && sku.estimate ? (
<span className="shrink-0 font-bold text-[#2f3a3d]">
¥{sku.estimate.estimateRange.min} – {sku.estimate.estimateRange.max}
</span>
) : sku.status === 'FAILED' ? (
<Button size="small" variant="gray" className="h-7 text-[10px]" onClick={() => handleEstimateSku(sku.skuId)} disabled={pendingSkuId !== null}>
{pendingSkuId === sku.skuId ? '核算中…' : '重新核算'}
</Button>
) : (
<Button size="small" variant="gray" className="h-7 text-[10px]" onClick={() => handleEstimateSku(sku.skuId)} disabled={pendingSkuId !== null}>
{pendingSkuId === sku.skuId ? '核算中…' : '点击核算'}
</Button>
)}
</li>
))}
</ul>
</section>
{images.length > 0 && (
<section className="rounded-[10px] bg-white p-4 ring-1 ring-black/[0.06]">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-[#2f3a3d]">商品图片</h3>
<div className="flex gap-1">
{IMAGE_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => { setImageTab(tab.key); setSelectedImageId(null) }}
className={cn(
'rounded-[5px] px-2 py-1 text-[10px] font-bold transition',
imageTab === tab.key ? 'bg-[#47646b] text-white' : 'bg-black/[0.04] text-[#6a7a7d] hover:bg-black/[0.07]',
)}
>
{tab.label}
</button>
))}
</div>
</div>
{selectedImage && (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={selectedImage.url} alt="竞品图片" className="mt-3 max-h-[420px] w-full rounded-[8px] object-contain" />
)}
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
{visibleImages.map((image) => (
<button
key={image.assetId}
onClick={() => setSelectedImageId(image.assetId)}
className={cn(
'h-16 w-16 shrink-0 overflow-hidden rounded-[6px] ring-1 transition',
selectedImage?.assetId === image.assetId ? 'ring-2 ring-[#47646b]' : 'ring-black/[0.08] hover:ring-black/20',
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.url} alt={`${image.kind} 图片`} className="h-full w-full object-cover" />
</button>
))}
</div>
</section>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,16 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { bomCostingAccessService } from '@/server/services/bom-costing/bom-costing-access.service'
import EstimateClient from './EstimateClient'
export const metadata = {
title: 'BOM 成本预估(v2 预览)',
}
export default async function BomEstimatePage() {
const { authUser } = await requireSession('/login')
if (!(await bomCostingAccessService.canView(authUser))) {
redirect('/intake')
}
return <EstimateClient />
}

View File

@ -75,7 +75,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
disabled,
value,
onChange,
children,
children: _children,
// onClick/onKeyDown 由组件内部的触发按钮接管,不透传(其 select 元素
// 事件签名与 button 不兼容,且外部场景不应覆盖交互实现)。
onClick: _ignoredOnClick,
@ -259,8 +259,12 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-label={label}
{...(props as unknown as React.ComponentProps<'button'>)}
id={props.id}
name={props.name}
title={props.title}
autoFocus={props.autoFocus}
aria-label={props['aria-label'] ?? label}
aria-describedby={props['aria-describedby']}
>
<span className={cn(
'truncate text-left',

View File

@ -59,6 +59,62 @@ export class BomCostingAgentRepository {
return sku?.id ?? null
}
async listSkusForCase(caseId: string) {
return prisma.bomCostingSku.findMany({
where: { caseId },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
externalSkuId: true,
name: true,
lengthMm: true,
widthMm: true,
heightMm: true,
sortOrder: true,
},
})
}
async findCachedSkuEstimate(skuId: string, engineVersion: string) {
return prisma.bomCostingAgentSkuEstimate.findUnique({
where: { skuId_engineVersion: { skuId, engineVersion } },
})
}
async listCachedSkuEstimates(caseId: string, engineVersion: string) {
return prisma.bomCostingAgentSkuEstimate.findMany({
where: { caseId, engineVersion },
})
}
async upsertCachedSkuEstimate(input: {
caseId: string
skuId: string
externalSkuId: string
engineVersion: string
status: string
estimate: unknown
usage: unknown
}) {
return prisma.bomCostingAgentSkuEstimate.upsert({
where: { skuId_engineVersion: { skuId: input.skuId, engineVersion: input.engineVersion } },
create: {
caseId: input.caseId,
skuId: input.skuId,
externalSkuId: input.externalSkuId,
engineVersion: input.engineVersion,
status: input.status,
estimate: input.estimate as Prisma.InputJsonValue,
usage: input.usage as Prisma.InputJsonValue,
},
update: {
status: input.status,
estimate: input.estimate as Prisma.InputJsonValue,
usage: input.usage as Prisma.InputJsonValue,
},
})
}
async listArchivedAssetsForCase(caseId: string) {
const sourceCase = await prisma.bomCostingCase.findUnique({
where: { id: caseId },

View File

@ -0,0 +1,120 @@
import { buildEstimateCard, selectFirstBatchSkus } from './agent-estimate-card'
import type { BomCostingAgentRunResult } from './agent-contracts'
function skuInput(overrides: Partial<Parameters<typeof selectFirstBatchSkus>[0][number]> = {}) {
return {
skuId: 's',
externalSkuId: 'SKU',
name: 'n',
lengthMm: null as number | null,
widthMm: null as number | null,
heightMm: null as number | null,
...overrides,
}
}
function runResult(overrides: {
total?: [number, number]
confidence?: number
question?: string
categoryTotals?: Record<string, number | number[]>
}): BomCostingAgentRunResult {
const [min, max] = overrides.total ?? [100, 150]
return {
status: 'COMPLETED',
estimate: {
structureSummary: 's',
lines: [],
confidence: overrides.confidence ?? 0.6,
estimateRange: { min, max },
openQuestions: overrides.question ? [overrides.question] : [],
},
categoryTotals: {
BOARD: 80,
ACCESSORY: 20,
unresolvedLineCount: 0,
...overrides.categoryTotals,
} as never,
warnings: [],
trace: [],
usage: { rounds: 2, modelCalls: 2, elapsedMs: 10 },
}
}
describe('selectFirstBatchSkus', () => {
it('单 SKU 与双 SKU 全部入选', () => {
const one = [skuInput({ skuId: 'a' })]
expect(selectFirstBatchSkus(one)).toHaveLength(1)
const two = [skuInput({ skuId: 'a' }), skuInput({ skuId: 'b' })]
expect(selectFirstBatchSkus(two)).toHaveLength(2)
})
it('五个 SKU 按最大外形轴取 最小/中位/最大,保持原顺序', () => {
const skus = [
skuInput({ skuId: 'a', lengthMm: 900 }),
skuInput({ skuId: 'b', lengthMm: 1000 }),
skuInput({ skuId: 'c', lengthMm: 1330 }),
skuInput({ skuId: 'd', lengthMm: 1760 }),
skuInput({ skuId: 'e', lengthMm: 1960 }),
]
const picked = selectFirstBatchSkus(skus)
expect(picked.map((sku) => sku.skuId)).toEqual(['a', 'c', 'e'])
})
it('尺寸全缺的 SKU 不参与代表选择,除非没有别的可选', () => {
const skus = [
skuInput({ skuId: 'no-dim' }),
skuInput({ skuId: 'small', widthMm: 400 }),
skuInput({ skuId: 'mid', widthMm: 800 }),
skuInput({ skuId: 'large', widthMm: 1600 }),
]
expect(selectFirstBatchSkus(skus).map((sku) => sku.skuId)).toEqual(['small', 'mid', 'large'])
expect(selectFirstBatchSkus([skuInput({ skuId: 'no-dim' })]).map((sku) => sku.skuId))
.toEqual(['no-dim'])
})
})
describe('buildEstimateCard', () => {
it('聚合五行构成、区间包络、最小置信度与不确定点并集', () => {
const skus = [skuInput({ skuId: 'a', externalSkuId: 'S-A' }), skuInput({ skuId: 'b', externalSkuId: 'S-B' }), skuInput({ skuId: 'c', externalSkuId: 'S-C' })]
const card = buildEstimateCard({
caseId: 'case-1',
title: '测试衣柜',
skus,
firstBatchSkuIds: ['a', 'b', 'c'],
resultsBySkuId: new Map([
['a', { status: 'ESTIMATED' as const, runResult: runResult({ total: [200, 260], confidence: 0.7, question: '板厚未确认', categoryTotals: { BOARD: 180, ACCESSORY: 20 } }) }],
['b', { status: 'ESTIMATED' as const, runResult: runResult({ total: [300, 380], confidence: 0.55, question: '铰链数量按两门推定', categoryTotals: { BOARD: 260, LABOR: 40 } }) }],
['c', { status: 'PENDING' as const }],
]),
usage: { rounds: 6, modelCalls: 6, elapsedMs: 1000 },
})
expect(card.estimatedSkuCount).toBe(2)
expect(card.pendingSkuCount).toBe(1)
expect(card.totalRange).toEqual({ min: 200, max: 380 })
expect(card.confidence).toBe(0.55)
expect(card.openQuestions).toEqual(['板厚未确认', '铰链数量按两门推定'])
const board = card.breakdown.find((row) => row.category === 'BOARD')
expect(board?.total).toBe(440)
const labor = card.breakdown.find((row) => row.category === 'LABOR')
expect(labor?.total).toBe(40)
const packaging = card.breakdown.find((row) => row.category === 'PACKAGING')
expect(packaging?.total).toBeNull()
expect(card.skus.find((sku) => sku.skuId === 'c')?.status).toBe('PENDING')
})
it('全部未核算时区间为 null,FAILED 不计入已核算数', () => {
const card = buildEstimateCard({
caseId: 'case-1',
title: 't',
skus: [skuInput({ skuId: 'a' }), skuInput({ skuId: 'b' })],
firstBatchSkuIds: ['a'],
resultsBySkuId: new Map([['a', { status: 'FAILED' as const }]]),
usage: { rounds: 0, modelCalls: 0, elapsedMs: 5 },
})
expect(card.totalRange).toBeNull()
expect(card.confidence).toBeNull()
expect(card.failedSkuCount).toBe(1)
expect(card.estimatedSkuCount).toBe(0)
})
})

View File

@ -0,0 +1,168 @@
import type { BomCostingAgentEstimate } from './agent-contracts'
import type { BomCostingAgentRunResult } from './agent-contracts'
// EstimateCard 契约(设计文档 5.2):case 级预估卡的纯数据形状与聚合规则。
// 聚合全部由代码完成:模型只产出单 SKU 的 estimate,这里负责五行构成、
// 区间包络、置信度(取最小)与不确定点并集。
export const BOM_COSTING_AGENT_ENGINE_VERSION = 'bom-costing-agent-card/v1'
export type BomEstimateSkuStatus = 'ESTIMATED' | 'PENDING' | 'FAILED' | 'RUNNING'
export interface BomEstimateSkuEntry {
externalSkuId: string
name: string
status: BomEstimateSkuStatus
estimate?: BomCostingAgentEstimate
warnings?: string[]
cachedAt?: string
}
export interface BomEstimateCardBreakdownRow {
category: 'BOARD' | 'STEEL' | 'ACCESSORY' | 'PACKAGING' | 'PROCESSING' | 'LABOR'
total: number | null
}
export interface BomEstimateCard {
caseId: string
title: string
engineVersion: string
skus: Array<BomEstimateSkuEntry & { skuId: string; firstBatch: boolean }>
firstBatchSkuIds: string[]
breakdown: BomEstimateCardBreakdownRow[]
totalRange: { min: number; max: number } | null
confidence: number | null
openQuestions: string[]
estimatedSkuCount: number
pendingSkuCount: number
failedSkuCount: number
usage: { rounds: number; modelCalls: number; elapsedMs: number }
}
export interface BomEstimateSkuInput {
skuId: string
externalSkuId: string
name: string
lengthMm: number | null
widthMm: number | null
heightMm: number | null
}
// 首批代表 SKU 选择(跨度原则):按最大外形轴排序后取 最小/中位/最大 三个,
// 返回保持原清单顺序;尺寸全缺的 SKU 排在末位不参与代表选择(除非没有别的可选)。
export function selectFirstBatchSkus<T extends BomEstimateSkuInput>(
skus: readonly T[],
limit = 3,
): T[] {
if (skus.length <= limit) return [...skus]
const metric = (sku: T) => {
const values = [sku.lengthMm, sku.widthMm, sku.heightMm]
.filter((value): value is number => value !== null && Number.isFinite(value) && value > 0)
return values.length > 0 ? Math.max(...values) : 0
}
const ordered = [...skus]
.map((sku, index) => ({ sku, index, metric: metric(sku) }))
.sort((left, right) => left.metric - right.metric || left.index - right.index)
const withDimension = ordered.filter((item) => item.metric > 0)
const picks = new Set<number>()
if (withDimension.length >= 3) {
picks.add(withDimension[0]!.index)
picks.add(withDimension[Math.floor((withDimension.length - 1) / 2)].index)
picks.add(withDimension[withDimension.length - 1]!.index)
} else {
for (const item of withDimension) picks.add(item.index)
}
if (picks.size < limit) {
for (const item of ordered) {
if (picks.size >= limit) break
picks.add(item.index)
}
}
return [...picks].sort((left, right) => left - right).map((index) => skus[index]!)
}
function roundMoney(value: number) {
return Math.round(value * 100) / 100
}
const BREAKDOWN_CATEGORIES: BomEstimateCardBreakdownRow['category'][] = [
'BOARD',
'STEEL',
'ACCESSORY',
'PACKAGING',
'PROCESSING',
'LABOR',
]
export interface BomEstimateBuildInput {
caseId: string
title: string
skus: readonly BomEstimateSkuInput[]
firstBatchSkuIds: readonly string[]
resultsBySkuId: ReadonlyMap<string, {
status: BomEstimateSkuStatus
runResult?: BomCostingAgentRunResult
cachedAt?: string
}>
usage: { rounds: number; modelCalls: number; elapsedMs: number }
}
export function buildEstimateCard(input: BomEstimateBuildInput): BomEstimateCard {
const firstBatch = new Set(input.firstBatchSkuIds)
const totalsByCategory = new Map<string, number>()
let rangeMin = Number.POSITIVE_INFINITY
let rangeMax = Number.NEGATIVE_INFINITY
let confidence: number | null = null
const openQuestions = new Set<string>()
let estimatedSkuCount = 0
let failedSkuCount = 0
const skus = input.skus.map((sku) => {
const result = input.resultsBySkuId.get(sku.skuId)
const status: BomEstimateSkuStatus = result?.status ?? 'PENDING'
if (status === 'ESTIMATED' && result?.runResult?.estimate) {
estimatedSkuCount += 1
const estimate = result.runResult.estimate
for (const [category, total] of Object.entries(result.runResult.categoryTotals ?? {})) {
if (category === 'unresolvedLineCount' || typeof total !== 'number') continue
totalsByCategory.set(category, roundMoney((totalsByCategory.get(category) ?? 0) + total))
}
if (estimate.estimateRange.min < rangeMin) rangeMin = estimate.estimateRange.min
if (estimate.estimateRange.max > rangeMax) rangeMax = estimate.estimateRange.max
if (confidence === null || estimate.confidence < confidence) confidence = estimate.confidence
for (const question of estimate.openQuestions) openQuestions.add(question)
}
if (status === 'FAILED') failedSkuCount += 1
return {
skuId: sku.skuId,
externalSkuId: sku.externalSkuId,
name: sku.name,
status,
estimate: status === 'ESTIMATED' ? result?.runResult?.estimate ?? undefined : undefined,
warnings: status === 'ESTIMATED' ? result?.runResult?.warnings : undefined,
cachedAt: result?.cachedAt,
firstBatch: firstBatch.has(sku.skuId),
}
})
return {
caseId: input.caseId,
title: input.title,
engineVersion: BOM_COSTING_AGENT_ENGINE_VERSION,
skus,
firstBatchSkuIds: [...input.firstBatchSkuIds],
breakdown: BREAKDOWN_CATEGORIES.map((category) => ({
category,
total: totalsByCategory.get(category) ?? null,
})),
totalRange: Number.isFinite(rangeMin) && Number.isFinite(rangeMax)
? { min: roundMoney(rangeMin), max: roundMoney(rangeMax) }
: null,
confidence,
openQuestions: [...openQuestions],
estimatedSkuCount,
pendingSkuCount: input.skus.length - estimatedSkuCount - failedSkuCount,
failedSkuCount,
usage: input.usage,
}
}

View File

@ -1,6 +1,8 @@
import { Prisma } from '@prisma/client'
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import { bomCostingScenarioRepository } from '@/server/repositories/bom-costing-scenario.repository'
import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search'
import { buildCategoryProfileEstimate } from '../bom-costing-scenario.service'
import { createDefaultBomCostingAgentToolDeps } from './agent-tool-providers'
jest.mock('@/server/repositories/bom-costing-agent.repository', () => ({
@ -11,14 +13,37 @@ jest.mock('@/server/repositories/bom-costing-agent.repository', () => ({
},
}))
jest.mock('@/server/repositories/bom-costing-scenario.repository', () => ({
bomCostingScenarioRepository: {
findLatestSyncStatuses: jest.fn(),
findReferenceCandidates: jest.fn(),
findCategoryCohortCandidates: jest.fn(),
},
}))
jest.mock('../bom-costing-1688-image-search', () => ({
bomCosting1688ImageSearchService: {
findRealtimeQuote: jest.fn(),
},
}))
jest.mock('../bom-costing-scenario.service', () => ({
buildCategoryProfileEstimate: jest.fn(),
}))
const repositoryMock = jest.mocked(bomCostingAgentRepository)
const scenarioRepositoryMock = jest.mocked(bomCostingScenarioRepository)
const realtimeMock = jest.mocked(bomCosting1688ImageSearchService)
const profileBuilderMock = jest.mocked(buildCategoryProfileEstimate)
function latestSyncRun(id: string | null) {
scenarioRepositoryMock.findLatestSyncStatuses.mockResolvedValue({
latestSuccessful: id === null ? null : { id } as never,
latestFailed: null,
mirrorState: id === null ? 'EMPTY' : 'READY',
mirrorRunIds: id === null ? [] : [id],
})
}
describe('createDefaultBomCostingAgentToolDeps', () => {
beforeEach(() => {
@ -114,4 +139,145 @@ describe('createDefaultBomCostingAgentToolDeps', () => {
})
expect(Object.keys(summary as Record<string, unknown>)).not.toContain('warnings')
})
it('searchMirrorCandidates 锁定最新成功批次并做尺寸感知检索映射', async () => {
latestSyncRun('run-1')
scenarioRepositoryMock.findReferenceCandidates.mockResolvedValue([
{
id: 'c1',
sourceRowId: 'row-9',
sourceSku: 'SKU-Z',
bomVersionKey: 'v1',
category: '板材',
categoryCode: 'BOARD',
partNumber: '',
partName: '侧板',
materialName: '白蜡木拼板 18mm',
materialCode: '',
materialSpec: '1220x2440',
processName: '',
unit: '块',
quantity: new Prisma.Decimal('2'),
coefficient: null,
rawCoefficient: null,
lengthMm: new Prisma.Decimal('800'),
widthMm: new Prisma.Decimal('400'),
areaSqm: new Prisma.Decimal('0.64'),
edgeLengthM: null,
unitPrice: new Prisma.Decimal('33.30'),
amount: new Prisma.Decimal('66.60'),
sourceCreatedAt: new Date('2026-08-01'),
syncedAt: new Date('2026-08-02'),
},
] as never)
const deps = createDefaultBomCostingAgentToolDeps()
const candidates = await deps.searchMirrorCandidates({
category: 'BOARD',
partName: '侧板',
materialKeywords: ['白蜡木', '拼板'],
lengthMm: 800,
widthMm: 400,
}, { caseId: 'c' })
expect(scenarioRepositoryMock.findLatestSyncStatuses).toHaveBeenCalledTimes(1)
expect(scenarioRepositoryMock.findReferenceCandidates).toHaveBeenCalledWith(expect.objectContaining({
syncRunId: 'run-1',
category: 'BOARD',
partName: '侧板',
materialName: '白蜡木 拼板',
lengthMm: 800,
widthMm: 400,
}))
expect(candidates).toEqual([{
sourceRowId: 'row-9',
sourceSku: 'SKU-Z',
partName: '侧板',
materialName: '白蜡木拼板 18mm',
materialSpec: '1220x2440',
unit: '块',
unitPrice: 33.3,
amount: 66.6,
quantity: 2,
lengthMm: 800,
widthMm: 400,
areaSqm: 0.64,
}])
})
it('searchMirrorCandidates 无成功同步批次时返回空而不抛错', async () => {
latestSyncRun(null)
const deps = createDefaultBomCostingAgentToolDeps()
const candidates = await deps.searchMirrorCandidates({ category: 'BOARD' }, { caseId: 'c' })
expect(candidates).toEqual([])
expect(scenarioRepositoryMock.findReferenceCandidates).not.toHaveBeenCalled()
})
it('getCategoryProfileEstimate 三级级联:同族命中即返回', async () => {
latestSyncRun('run-1')
const profile = {
basis: 'SKU_FAMILY' as const,
total: 268.5,
estimateRange: { min: 240, max: 300 },
medianLineCount: 9,
medianLineAmount: 30,
medianPhysicalQuantity: 2.4,
physicalUnit: 'AREA_SQM' as const,
lossFactor: 1,
sampleCount: 12,
rawSampleCount: 14,
outlierCount: 2,
}
profileBuilderMock.mockReturnValueOnce(profile)
scenarioRepositoryMock.findCategoryCohortCandidates.mockResolvedValue([] as never)
const deps = createDefaultBomCostingAgentToolDeps()
const result = await deps.getCategoryProfileEstimate({
category: 'BOARD',
familyReferenceSkus: ['SKU-A'],
similarReferenceSkus: ['SKU-B'],
}, { caseId: 'c' })
expect(result).toEqual({
basis: 'SKU_FAMILY',
total: 268.5,
estimateRange: { min: 240, max: 300 },
medianLineCount: 9,
medianPhysicalQuantity: 2.4,
physicalUnit: 'AREA_SQM',
sampleCount: 12,
})
expect(scenarioRepositoryMock.findCategoryCohortCandidates).toHaveBeenCalledTimes(1)
})
it('getCategoryProfileEstimate 同族与相似皆缺时回退同分类 cohort,全缺返回 NONE', async () => {
latestSyncRun('run-1')
profileBuilderMock.mockReturnValueOnce(null).mockReturnValueOnce(null).mockReturnValueOnce({
basis: 'CATEGORY_COHORT',
total: 180,
estimateRange: { min: 150, max: 220 },
medianLineCount: 7,
medianLineAmount: 26,
medianPhysicalQuantity: null,
physicalUnit: null,
lossFactor: 1,
sampleCount: 30,
rawSampleCount: 33,
outlierCount: 3,
})
scenarioRepositoryMock.findCategoryCohortCandidates.mockResolvedValue([] as never)
const deps = createDefaultBomCostingAgentToolDeps()
const result = await deps.getCategoryProfileEstimate({
category: 'LABOR',
familyReferenceSkus: ['SKU-A'],
similarReferenceSkus: ['SKU-B'],
}, { caseId: 'c' })
expect(result).toMatchObject({ basis: 'CATEGORY_COHORT', total: 180, sampleCount: 30 })
expect(scenarioRepositoryMock.findCategoryCohortCandidates).toHaveBeenCalledTimes(3)
profileBuilderMock.mockReset()
profileBuilderMock.mockReturnValue(null)
const none = await deps.getCategoryProfileEstimate({ category: 'LABOR' }, { caseId: 'c' })
expect(none).toEqual({ basis: 'NONE' })
expect(profileBuilderMock).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,11 +1,36 @@
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import { bomCostingScenarioRepository } from '@/server/repositories/bom-costing-scenario.repository'
import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search'
import { buildCategoryProfileEstimate, type CategoryProfileEstimate } from '../bom-costing-scenario.service'
import type { BomCostingAgentToolDeps } from './agent-tools'
function candidateNumber(value: unknown): number | null {
if (value === null || value === undefined) return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function toProfileSummary(estimate: CategoryProfileEstimate) {
return {
basis: estimate.basis,
total: estimate.total,
estimateRange: estimate.estimateRange,
medianLineCount: estimate.medianLineCount,
medianPhysicalQuantity: estimate.medianPhysicalQuantity,
physicalUnit: estimate.physicalUnit,
sampleCount: estimate.sampleCount,
}
}
// 工具接线层:repository 只出原始行,这里负责映射成 agent 可消费的摘要。
// 实时行情工具刻意只透出状态/采购参考价/区间/可比数,warnings 与 offers
// 留在服务端内部,避免进入 agent 转录后流向前端。
export function createDefaultBomCostingAgentToolDeps(): BomCostingAgentToolDeps {
// 价格工具共用的数据边界:只允许最新成功同步批次成为定价来源。
async function latestSuccessfulSyncRunId() {
const { latestSuccessful } = await bomCostingScenarioRepository.findLatestSyncStatuses()
return latestSuccessful?.id ?? null
}
return {
listArchivedImages: async (context) => {
const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(context.caseId)
@ -32,6 +57,66 @@ export function createDefaultBomCostingAgentToolDeps(): BomCostingAgentToolDeps
sourceRowId: row.sourceRowId,
}))
},
searchMirrorCandidates: async (input) => {
const syncRunId = await latestSuccessfulSyncRunId()
if (!syncRunId) return []
const candidates = await bomCostingScenarioRepository.findReferenceCandidates({
syncRunId,
category: input.category,
partName: input.partName ?? null,
// nonEmptyTokens 会按空白分词并为每个关键词生成多形态 token,
// 因此多关键词以空格拼接传入即可。
materialName: input.materialKeywords && input.materialKeywords.length > 0
? input.materialKeywords.join(' ')
: null,
lengthMm: input.lengthMm ?? null,
widthMm: input.widthMm ?? null,
referenceSkus: input.referenceSkus,
limit: input.limit,
})
return candidates.map((candidate) => ({
sourceRowId: candidate.sourceRowId,
sourceSku: candidate.sourceSku,
partName: candidate.partName,
materialName: candidate.materialName,
materialSpec: candidate.materialSpec,
unit: candidate.unit,
unitPrice: candidateNumber(candidate.unitPrice),
amount: candidateNumber(candidate.amount),
quantity: candidateNumber(candidate.quantity),
lengthMm: candidateNumber(candidate.lengthMm),
widthMm: candidateNumber(candidate.widthMm),
areaSqm: candidateNumber(candidate.areaSqm),
}))
},
getCategoryProfileEstimate: async (input) => {
const syncRunId = await latestSuccessfulSyncRunId()
if (!syncRunId) return { basis: 'NONE' as const }
const cascade = async (
basis: CategoryProfileEstimate['basis'],
referenceSkus: string[] | undefined,
) => {
if (!referenceSkus || referenceSkus.length === 0) return null
const candidates = await bomCostingScenarioRepository.findCategoryCohortCandidates({
syncRunId,
category: input.category,
referenceSkus,
limit: 500,
})
return buildCategoryProfileEstimate(candidates, basis, input.category)
}
const family = await cascade('SKU_FAMILY', input.familyReferenceSkus)
if (family) return toProfileSummary(family)
const similar = await cascade('SIMILAR_HISTORY', input.similarReferenceSkus)
if (similar) return toProfileSummary(similar)
const cohortCandidates = await bomCostingScenarioRepository.findCategoryCohortCandidates({
syncRunId,
category: input.category,
limit: 500,
})
const cohort = buildCategoryProfileEstimate(cohortCandidates, 'CATEGORY_COHORT', input.category)
return cohort ? toProfileSummary(cohort) : { basis: 'NONE' as const }
},
listMaterialOptions: async () => {
const options = await bomCostingAgentRepository.listEnabledMaterialOptions()
return options.map((option) => ({

View File

@ -47,12 +47,53 @@ export interface BomCostingAgentRealtimeQuoteSummary {
comparableOfferCount: number
}
export interface BomCostingAgentMirrorCandidate {
sourceRowId: string
sourceSku: string
partName: string
materialName: string
materialSpec: string
unit: string
unitPrice: number | null
amount: number | null
quantity: number | null
lengthMm: number | null
widthMm: number | null
areaSqm: number | null
}
export interface BomCostingAgentCategoryProfile {
basis: 'SKU_FAMILY' | 'SIMILAR_HISTORY' | 'CATEGORY_COHORT'
total: number
estimateRange: { min: number; max: number }
medianLineCount: number
medianPhysicalQuantity: number | null
physicalUnit: 'AREA_SQM' | null
sampleCount: number
}
export interface BomCostingAgentToolDeps {
listArchivedImages: (context: BomCostingAgentToolContext) => Promise<BomCostingAgentArchivedImage[]>
searchReferencePrices: (
input: { category?: string; materialKeywords?: string[]; limit?: number },
context: BomCostingAgentToolContext,
) => Promise<BomCostingAgentReferenceCandidate[]>
searchMirrorCandidates: (
input: {
category: string
partName?: string
materialKeywords?: string[]
lengthMm?: number
widthMm?: number
referenceSkus?: string[]
limit?: number
},
context: BomCostingAgentToolContext,
) => Promise<BomCostingAgentMirrorCandidate[]>
getCategoryProfileEstimate: (
input: { category: string; familyReferenceSkus?: string[]; similarReferenceSkus?: string[] },
context: BomCostingAgentToolContext,
) => Promise<BomCostingAgentCategoryProfile | { basis: 'NONE' }>
listMaterialOptions: (context: BomCostingAgentToolContext) => Promise<BomCostingAgentMaterialOption[]>
searchRealtimeMarket: (
input: { imageUrl: string },
@ -163,6 +204,55 @@ export function createBomCostingAgentTools(deps: BomCostingAgentToolDeps) {
}, context)
},
},
{
name: 'search_mirror_candidates',
description:
'尺寸感知的公司历史 BOM 行级检索(定价首选)。输入 {category(BOARD|STEEL|ACCESSORY|PACKAGING|PROCESSING|LABOR), ' +
'partName?, materialKeywords?, lengthMm?, widthMm?, referenceSkus?, limit?};' +
'板件长宽按 ±20% 尺寸带匹配(BOARD 尺寸无匹配时不做无尺寸兜底,防止以小充大);' +
'返回候选行的单价/金额/尺寸/来源 SKU,可直接作为该行定价依据。',
execute: (input: unknown, context: BomCostingAgentToolContext) => {
const record = typeof input === 'object' && input !== null ? input as Record<string, unknown> : {}
if (typeof record.category !== 'string' || !record.category.trim()) {
return Promise.resolve({ error: '输入不合法:category 必填(BOARD|STEEL|ACCESSORY|PACKAGING|PROCESSING|LABOR)' })
}
return deps.searchMirrorCandidates({
category: record.category.trim(),
partName: typeof record.partName === 'string' ? record.partName : undefined,
materialKeywords: Array.isArray(record.materialKeywords)
? record.materialKeywords.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
: undefined,
lengthMm: typeof record.lengthMm === 'number' ? record.lengthMm : undefined,
widthMm: typeof record.widthMm === 'number' ? record.widthMm : undefined,
referenceSkus: Array.isArray(record.referenceSkus)
? record.referenceSkus.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
: undefined,
limit: typeof record.limit === 'number' ? record.limit : undefined,
}, context)
},
},
{
name: 'get_category_profile',
description:
'按分类获取公司历史整套 BOM 的中位总额(同名 SKU 族 → 相似整套 → 同分类三级回退)。' +
'输入 {category, familyReferenceSkus?, similarReferenceSkus?};' +
'返回中位总额/区间/中位行数/中位板件面积(BOARD)与样本数,用于整套级别兜底价或合理性校验。',
execute: (input: unknown, context: BomCostingAgentToolContext) => {
const record = typeof input === 'object' && input !== null ? input as Record<string, unknown> : {}
if (typeof record.category !== 'string' || !record.category.trim()) {
return Promise.resolve({ error: '输入不合法:category 必填' })
}
return deps.getCategoryProfileEstimate({
category: record.category.trim(),
familyReferenceSkus: Array.isArray(record.familyReferenceSkus)
? record.familyReferenceSkus.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
: undefined,
similarReferenceSkus: Array.isArray(record.similarReferenceSkus)
? record.similarReferenceSkus.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
: undefined,
}, context)
},
},
{
name: 'list_material_options',
description: '列出公司启用材质目录(材质身份的唯一来源,含别名)。',

View File

@ -0,0 +1,206 @@
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import { runBomCostingAgentEstimateForCase, estimateSkuOnDemand } from './bom-costing-agent-case-runner'
import { runBomCostingAgentTrialForSku } from './bom-costing-agent-runner'
import type { BomCostingAgentRunResult } from './agent-contracts'
jest.mock('@/server/repositories/bom-costing-agent.repository', () => ({
bomCostingAgentRepository: {
findCaseForAgentTrial: jest.fn(),
listSkusForCase: jest.fn(),
listCachedSkuEstimates: jest.fn(),
findCachedSkuEstimate: jest.fn(),
upsertCachedSkuEstimate: jest.fn(),
findSkuForAgentTrial: jest.fn(),
listArchivedAssetsForCase: jest.fn(),
},
}))
jest.mock('./bom-costing-agent-runner', () => ({
runBomCostingAgentTrialForSku: jest.fn(),
}))
const repositoryMock = jest.mocked(bomCostingAgentRepository)
const trialMock = jest.mocked(runBomCostingAgentTrialForSku)
function skuRow(id: string, externalSkuId: string, lengthMm: number | null) {
return {
id,
externalSkuId,
name: `n-${externalSkuId}`,
lengthMm,
widthMm: null,
heightMm: null,
sortOrder: 0,
}
}
function completedResult(range: [number, number], confidence: number): BomCostingAgentRunResult {
return {
status: 'COMPLETED',
estimate: {
structureSummary: 's',
lines: [],
confidence,
estimateRange: { min: range[0], max: range[1] },
openQuestions: ['板厚未确认'],
},
categoryTotals: { BOARD: 100, unresolvedLineCount: 0 } as never,
warnings: [],
trace: [],
usage: { rounds: 3, modelCalls: 3, elapsedMs: 50 },
}
}
function cacheEntry(skuId: string, status: string) {
return {
id: `cache-${skuId}`,
caseId: 'case-1',
skuId,
externalSkuId: 'S',
engineVersion: 'bom-costing-agent-card/v1',
status,
estimate: completedResult([100, 120], 0.6),
usage: {},
createdAt: new Date('2026-09-16T00:00:00Z'),
updatedAt: new Date('2026-09-16T01:00:00Z'),
}
}
beforeEach(() => {
jest.clearAllMocks()
repositoryMock.findCaseForAgentTrial.mockResolvedValue({
id: 'case-1',
title: '测试衣柜',
sourceHost: 'taobao.com',
status: 'READY' as never,
skillCode: 'WARDROBE',
})
repositoryMock.listSkusForCase.mockResolvedValue([
skuRow('s-small', 'S1', 900),
skuRow('s-mid', 'S2', 1300),
skuRow('s-large', 'S3', 1900),
skuRow('s-extra', 'S4', 2200),
] as never)
repositoryMock.listCachedSkuEstimates.mockResolvedValue([] as never)
repositoryMock.upsertCachedSkuEstimate.mockResolvedValue({} as never)
})
describe('runBomCostingAgentEstimateForCase', () => {
it('首批只精算 3 个代表 SKU,其余保持 PENDING,卡片聚合正确', async () => {
trialMock.mockImplementation(async ({ skuId }) => ({
caseId: 'case-1',
skuId: skuId!,
externalSkuId: 'S',
skillCode: null,
selectedImageCount: 8,
result: completedResult(skuId === 's-small' ? [200, 260] : [300, 380], 0.6),
}))
const { card } = await runBomCostingAgentEstimateForCase({ caseId: 'case-1' })
expect(trialMock).toHaveBeenCalledTimes(3)
// 跨度原则:最小/中位/最大(s-extra 是最大外形轴)
expect(card.firstBatchSkuIds).toEqual(['s-small', 's-mid', 's-extra'])
expect(card.estimatedSkuCount).toBe(3)
expect(card.pendingSkuCount).toBe(1)
expect(card.totalRange).toEqual({ min: 200, max: 380 })
expect(card.title).toBe('测试衣柜')
const extra = card.skus.find((sku) => sku.skuId === 's-large')
expect(extra?.status).toBe('PENDING')
expect(extra?.firstBatch).toBe(false)
expect(repositoryMock.upsertCachedSkuEstimate).toHaveBeenCalledTimes(3)
})
it('缓存命中跳过模型调用;失败缓存不作为有效结果', async () => {
repositoryMock.listCachedSkuEstimates.mockResolvedValue([
cacheEntry('s-small', 'COMPLETED'),
cacheEntry('s-mid', 'FAILED'),
] as never)
trialMock.mockImplementation(async ({ skuId }) => ({
caseId: 'case-1',
skuId: skuId!,
externalSkuId: 'S',
skillCode: null,
selectedImageCount: 4,
result: completedResult([280, 340], 0.5),
}))
const { card } = await runBomCostingAgentEstimateForCase({ caseId: 'case-1' })
// s-small 缓存命中不重跑;s-mid 是 FAILED 缓存 → 重算;s-extra 首批新算
expect(trialMock).toHaveBeenCalledTimes(2)
expect(card.estimatedSkuCount).toBe(3)
expect(repositoryMock.upsertCachedSkuEstimate).toHaveBeenCalledTimes(2)
})
it('首批单个 SKU 失败不拖垮整卡', async () => {
trialMock.mockImplementation(async ({ skuId }) => {
if (skuId === 's-mid') throw new Error('模型调用失败')
return {
caseId: 'case-1',
skuId: skuId!,
externalSkuId: 'S',
skillCode: null,
selectedImageCount: 4,
result: completedResult([240, 300], 0.5),
}
})
const { card } = await runBomCostingAgentEstimateForCase({ caseId: 'case-1' })
expect(card.failedSkuCount).toBe(1)
expect(card.estimatedSkuCount).toBe(2)
expect(card.totalRange).toEqual({ min: 240, max: 300 })
})
})
describe('estimateSkuOnDemand', () => {
it('有效缓存直接返回,不再调用模型', async () => {
repositoryMock.findCachedSkuEstimate.mockResolvedValue(cacheEntry('s-extra', 'COMPLETED') as never)
const entry = await estimateSkuOnDemand({ caseId: 'case-1', skuId: 's-extra' })
expect(entry.status).toBe('ESTIMATED')
expect(entry.cachedAt).toBe('2026-09-16T01:00:00.000Z')
expect(trialMock).not.toHaveBeenCalled()
})
it('无缓存时运行并落缓存', async () => {
repositoryMock.findCachedSkuEstimate.mockResolvedValue(null)
repositoryMock.findSkuForAgentTrial.mockResolvedValue({
id: 's-extra',
caseId: 'case-1',
externalSkuId: 'S4',
name: 'n-S4',
lengthMm: null,
widthMm: null,
heightMm: null,
weightKg: null,
packageWeightKg: null,
sortOrder: 3,
} as never)
trialMock.mockResolvedValue({
caseId: 'case-1',
skuId: 's-extra',
externalSkuId: 'S4',
skillCode: null,
selectedImageCount: 6,
result: completedResult([260, 320], 0.55),
})
const entry = await estimateSkuOnDemand({ caseId: 'case-1', skuId: 's-extra' })
expect(entry.status).toBe('ESTIMATED')
expect(trialMock).toHaveBeenCalledTimes(1)
expect(repositoryMock.upsertCachedSkuEstimate).toHaveBeenCalledWith(expect.objectContaining({
caseId: 'case-1',
skuId: 's-extra',
engineVersion: 'bom-costing-agent-card/v1',
status: 'ESTIMATED',
}))
})
it('SKU 不属于该案例时抛错', async () => {
repositoryMock.findCachedSkuEstimate.mockResolvedValue(null)
repositoryMock.findSkuForAgentTrial.mockResolvedValue(null)
await expect(estimateSkuOnDemand({ caseId: 'case-1', skuId: 'other' }))
.rejects.toThrow('SKU 不属于该案例')
})
})

View File

@ -0,0 +1,212 @@
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import type { BomCostingAgentRunResult } from './agent-contracts'
import {
BOM_COSTING_AGENT_ENGINE_VERSION,
buildEstimateCard,
selectFirstBatchSkus,
type BomEstimateCard,
type BomEstimateSkuStatus,
} from './agent-estimate-card'
import { runBomCostingAgentTrialForSku } from './bom-costing-agent-runner'
// case 级旁路编排(设计文档 5.1):首批自动精算 + 其余按需点算。
// 只读案例数据、只写 agent 缓存表,不进处理管线。
export interface BomCostingCaseEstimateResult {
card: BomEstimateCard
}
type SkuResultEntry = {
status: BomEstimateSkuStatus
runResult?: BomCostingAgentRunResult
cachedAt?: string
}
export interface BomCostingCaseEstimateOptions {
firstBatchLimit?: number
maxImages?: number
signal?: AbortSignal
}
function runResultStatusToSkuStatus(status: BomCostingAgentRunResult['status']): BomEstimateSkuStatus {
// PARTIAL(结构有行但全未定价)也是合法产物:出结果、区间放宽、不确定点说明。
return status === 'FAILED' ? 'FAILED' : 'ESTIMATED'
}
// 缓存行是写入时的原始状态;读取时归一化为卡片状态口径(容错历史 COMPLETED/PARTIAL)。
function cachedStatusToSkuStatus(status: string): BomEstimateSkuStatus {
return status === 'COMPLETED' || status === 'PARTIAL' ? 'ESTIMATED' : status === 'FAILED' ? 'FAILED' : 'PENDING'
}
async function estimateSingleSku(input: {
caseId: string
skuId: string
externalSkuId: string
maxImages?: number
signal?: AbortSignal
}): Promise<SkuResultEntry> {
const trial = await runBomCostingAgentTrialForSku({
caseId: input.caseId,
skuId: input.skuId,
maxImages: input.maxImages,
signal: input.signal,
})
const status = runResultStatusToSkuStatus(trial.result.status)
await bomCostingAgentRepository.upsertCachedSkuEstimate({
caseId: input.caseId,
skuId: input.skuId,
externalSkuId: input.externalSkuId,
engineVersion: BOM_COSTING_AGENT_ENGINE_VERSION,
status,
estimate: trial.result,
usage: trial.result.usage,
})
return {
status,
runResult: trial.result,
cachedAt: new Date().toISOString(),
}
}
export async function runBomCostingAgentEstimateForCase(input: {
caseId: string
options?: BomCostingCaseEstimateOptions
}): Promise<BomCostingCaseEstimateResult> {
const options = input.options ?? {}
const startedAt = Date.now()
const brief = await bomCostingAgentRepository.findCaseForAgentTrial(input.caseId)
if (!brief) throw new Error(`案例不存在或已删除:${input.caseId}`)
const skus = await bomCostingAgentRepository.listSkusForCase(input.caseId)
if (skus.length === 0) throw new Error(`案例没有 SKU:${input.caseId}`)
const cache = await bomCostingAgentRepository.listCachedSkuEstimates(
input.caseId,
BOM_COSTING_AGENT_ENGINE_VERSION,
)
const resultsBySkuId = new Map<string, SkuResultEntry>()
for (const entry of cache) {
if (entry.status === 'FAILED') continue
resultsBySkuId.set(entry.skuId, {
status: cachedStatusToSkuStatus(entry.status),
runResult: entry.estimate as unknown as BomCostingAgentRunResult,
cachedAt: entry.updatedAt.toISOString(),
})
}
const firstBatch = selectFirstBatchSkus(
skus.map((sku) => ({
skuId: sku.id,
externalSkuId: sku.externalSkuId,
name: sku.name,
lengthMm: sku.lengthMm === null ? null : Number(sku.lengthMm),
widthMm: sku.widthMm === null ? null : Number(sku.widthMm),
heightMm: sku.heightMm === null ? null : Number(sku.heightMm),
})),
options.firstBatchLimit ?? 3,
)
const firstBatchSkuIds = firstBatch.map((sku) => sku.skuId)
for (const sku of firstBatch) {
if (resultsBySkuId.has(sku.skuId)) continue
try {
resultsBySkuId.set(sku.skuId, await estimateSingleSku({
caseId: input.caseId,
skuId: sku.skuId,
externalSkuId: sku.externalSkuId,
maxImages: options.maxImages,
signal: options.signal,
}))
} catch (error) {
console.warn(`[BOM agent] 首批 SKU 估算失败 sku=${sku.externalSkuId}:`,
error instanceof Error ? error.message : error)
resultsBySkuId.set(sku.skuId, { status: 'FAILED' })
}
}
const card = buildEstimateCard({
caseId: input.caseId,
title: brief.title,
skus: skus.map((sku) => ({
skuId: sku.id,
externalSkuId: sku.externalSkuId,
name: sku.name,
lengthMm: sku.lengthMm === null ? null : Number(sku.lengthMm),
widthMm: sku.widthMm === null ? null : Number(sku.widthMm),
heightMm: sku.heightMm === null ? null : Number(sku.heightMm),
})),
firstBatchSkuIds,
resultsBySkuId,
usage: {
rounds: 0,
modelCalls: 0,
elapsedMs: Date.now() - startedAt,
},
})
return { card }
}
// 只读卡片:仅从缓存表聚合,不发起任何模型调用(GET / 前端刷新用)。
export async function getAgentEstimateCard(caseId: string): Promise<BomEstimateCard | null> {
const brief = await bomCostingAgentRepository.findCaseForAgentTrial(caseId)
if (!brief) return null
const skus = await bomCostingAgentRepository.listSkusForCase(caseId)
const cache = await bomCostingAgentRepository.listCachedSkuEstimates(
caseId,
BOM_COSTING_AGENT_ENGINE_VERSION,
)
const resultsBySkuId = new Map<string, SkuResultEntry>()
for (const entry of cache) {
if (entry.status === 'FAILED') continue
resultsBySkuId.set(entry.skuId, {
status: cachedStatusToSkuStatus(entry.status),
runResult: entry.estimate as unknown as BomCostingAgentRunResult,
cachedAt: entry.updatedAt.toISOString(),
})
}
return buildEstimateCard({
caseId,
title: brief.title,
skus: skus.map((sku) => ({
skuId: sku.id,
externalSkuId: sku.externalSkuId,
name: sku.name,
lengthMm: sku.lengthMm === null ? null : Number(sku.lengthMm),
widthMm: sku.widthMm === null ? null : Number(sku.widthMm),
heightMm: sku.heightMm === null ? null : Number(sku.heightMm),
})),
firstBatchSkuIds: [],
resultsBySkuId,
usage: { rounds: 0, modelCalls: 0, elapsedMs: 0 },
})
}
export async function estimateSkuOnDemand(input: {
caseId: string
skuId: string
options?: Pick<BomCostingCaseEstimateOptions, 'maxImages' | 'signal'>
}): Promise<SkuResultEntry> {
const cached = await bomCostingAgentRepository.findCachedSkuEstimate(
input.skuId,
BOM_COSTING_AGENT_ENGINE_VERSION,
)
if (cached && cached.status !== 'FAILED') {
return {
status: cachedStatusToSkuStatus(cached.status),
runResult: cached.estimate as unknown as BomCostingAgentRunResult,
cachedAt: cached.updatedAt.toISOString(),
}
}
const sku = await bomCostingAgentRepository.findSkuForAgentTrial(input.skuId)
if (!sku || sku.caseId !== input.caseId) {
throw new Error(`SKU 不属于该案例:${input.skuId}`)
}
const entry = await estimateSingleSku({
caseId: input.caseId,
skuId: input.skuId,
externalSkuId: sku.externalSkuId,
maxImages: input.options?.maxImages,
signal: input.options?.signal,
})
return entry
}

View File

@ -144,6 +144,8 @@ describe('runBomCostingAgentLoop', () => {
sourceRowId: 'row-1',
}]
},
searchMirrorCandidates: async () => [],
getCategoryProfileEstimate: async () => ({ basis: 'NONE' as const }),
listMaterialOptions: async () => [],
searchRealtimeMarket: async () => ({
status: 'NOT_CONFIGURED' as const,

View File

@ -21,6 +21,7 @@ const MODEL_CALL_RETRY_DELAY_MS = 3_000
export interface BomCostingAgentMission {
userContent: BomCostingMultimodalUserContent
skillKnowledge?: string
toolContext?: BomCostingAgentToolContext
budget?: Partial<BomCostingAgentBudget>
signal?: AbortSignal
@ -147,7 +148,10 @@ export async function runBomCostingAgentLoop(input: {
attempt += 1
try {
const result = await input.callModel({
systemPrompt: buildBomCostingAgentSystemPrompt({ toolRuntimes: input.toolRuntimes }),
systemPrompt: buildBomCostingAgentSystemPrompt({
toolRuntimes: input.toolRuntimes,
skillKnowledge: input.mission.skillKnowledge,
}),
userContent: renderTranscript(input.mission.userContent, observations),
})
content = result.content

View File

@ -120,6 +120,7 @@ export async function runBomCostingAgentTrialForSku(input: {
const result = await runBomCostingAgentLoop({
mission: {
userContent,
skillKnowledge: skillKnowledge ?? undefined,
toolContext: { caseId: input.caseId, skuId: input.skuId },
budget: input.budget,
signal: input.signal,

View File

@ -1361,7 +1361,7 @@ async function resolveCohortQuote(input: {
return quoteFromCohortCandidates(cohortCandidates, input.line, 'CATEGORY_COHORT')
}
type CategoryProfileEstimate = {
export type CategoryProfileEstimate = {
basis: Extract<DynamicBomEstimateBasis, 'SKU_FAMILY' | 'SIMILAR_HISTORY' | 'CATEGORY_COHORT'>
total: number
estimateRange: { min: number; max: number }
@ -1539,7 +1539,7 @@ async function applyHistoricalLossFactorFallbacks(input: {
}
}
function buildCategoryProfileEstimate(
export function buildCategoryProfileEstimate(
candidates: BomCostingScenarioReferenceCandidate[],
basis: CategoryProfileEstimate['basis'],
category: string,