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:
parent
6492c2e400
commit
48871454e9
188
furniture-3d-system/docs/架构设计/BOM成本预估v2-agent方案设计-20260916.md
Normal file
188
furniture-3d-system/docs/架构设计/BOM成本预估v2-agent方案设计-20260916.md
Normal 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 实测,不影响 ①② 开发)。
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
@ -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])
|
||||
}
|
||||
|
||||
281
furniture-3d-system/scripts/report-bom-agent-baseline.ts
Normal file
281
furniture-3d-system/scripts/report-bom-agent-baseline.ts
Normal 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)
|
||||
})
|
||||
@ -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(
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
16
furniture-3d-system/src/app/bom-costing/estimate/page.tsx
Normal file
16
furniture-3d-system/src/app/bom-costing/estimate/page.tsx
Normal 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 />
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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: '列出公司启用材质目录(材质身份的唯一来源,含别名)。',
|
||||
|
||||
@ -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 不属于该案例')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user