diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index e01a9f63..6a90b391 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -135,6 +135,20 @@ BOM_COSTING_AI_MAX_TOKENS=12000 BOM_COSTING_AI_REASONING_EFFORT=low BOM_COSTING_MODELING_PROVIDER= +# BOM 核算实时同款采购预估(1688 官方图搜)。 +# 网关固定为 https://skills-gateway.1688.com/api/find_product/1.0.0。 +# AK 只放服务器私密环境变量,服务端会按 x-csk-* 协议签名;实时同款价 × 采购系数 +# 是整件采购预估,历史 BOM 仅保留为结构参考。不要把 AK 写入 Git 或 NEXT_PUBLIC_* 变量。 +ALIBABA_1688_IMAGE_SEARCH_AK=replace-with-1688-image-search-ak +# 仅在官方网关地址调整时设置;系统会拒绝非官方 HTTPS 地址。 +ALIBABA_1688_IMAGE_SEARCH_ENDPOINT_URL=https://skills-gateway.1688.com/api/find_product/1.0.0 +ALIBABA_1688_IMAGE_SEARCH_TIMEOUT_MS=45000 +ALIBABA_1688_IMAGE_SEARCH_MIN_SIMILARITY=0.78 +ALIBABA_1688_PROCUREMENT_RATE=0.90 +ALIBABA_1688_IMAGE_SEARCH_PAGE_SIZE=10 +ALIBABA_1688_IMAGE_SEARCH_PURCHASE_AMOUNT=1 +ALIBABA_1688_IMAGE_SEARCH_TAGS=4306497 + # 素材库白底图回传 MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED=false MATERIAL_LIBRARY_WHITE_BACKGROUND_API_BASE_URL=https://example.com diff --git a/furniture-3d-system/prisma/migrations/20260815170000_add_bom_costing_realtime_market_quote/migration.sql b/furniture-3d-system/prisma/migrations/20260815170000_add_bom_costing_realtime_market_quote/migration.sql new file mode 100644 index 00000000..8d32d0f5 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260815170000_add_bom_costing_realtime_market_quote/migration.sql @@ -0,0 +1,5 @@ +-- Retain the auditable realtime 1688 search result with every scenario +-- revision. It is a whole-product procurement signal, not a line-level BOM +-- price, therefore it stays as a single JSON snapshot on the scenario. +ALTER TABLE "BomCostingScenario" +ADD COLUMN "realtimeMarketQuote" JSONB; diff --git a/furniture-3d-system/prisma/migrations/20260817143000_add_bom_costing_whole_product_estimate_audit/migration.sql b/furniture-3d-system/prisma/migrations/20260817143000_add_bom_costing_whole_product_estimate_audit/migration.sql new file mode 100644 index 00000000..9483cb91 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260817143000_add_bom_costing_whole_product_estimate_audit/migration.sql @@ -0,0 +1,5 @@ +-- Keeps the server-only audit for whole-SKU agent estimates. Public BOM DTOs +-- deliberately omit this field, so it cannot reveal private model context or +-- internal reference records to browsers or exports. +ALTER TABLE "BomCostingScenario" +ADD COLUMN "wholeProductEstimateAudit" JSONB; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index e7f449f6..f47a0449 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1400,6 +1400,8 @@ model BomCostingScenario { lossCost Decimal? @db.Decimal(18, 2) competitorPrice Decimal? @db.Decimal(18, 2) priceDelta Decimal? @db.Decimal(18, 2) + realtimeMarketQuote Json? + wholeProductEstimateAudit Json? confidence Decimal? @db.Decimal(5, 4) warning String @default("") @db.Text unresolvedCount Int @default(0) diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts index 14d993fd..fb2f7b6b 100644 --- a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.test.ts @@ -5,8 +5,7 @@ export {} const mockHandleRequest = jest.fn() const mockJsonError = jest.fn() const mockJsonSuccess = jest.fn() -const mockAssertCanModifyCurrentCosting = jest.fn() -const mockCalculate = jest.fn() +const mockGetCase = jest.fn() jest.mock('@/server/core/response', () => ({ handleRequest: (...args: unknown[]) => mockHandleRequest(...args), @@ -16,15 +15,12 @@ jest.mock('@/server/core/response', () => ({ jest.mock('@/server/services/bom-costing/bom-costing.service', () => ({ bomCostingService: { - assertCanModifyCurrentCosting: (...args: unknown[]) => - mockAssertCanModifyCurrentCosting(...args), + getCase: (...args: unknown[]) => mockGetCase(...args), }, })) jest.mock('@/server/services/bom-costing/bom-costing-scenario.service', () => ({ - bomCostingScenarioService: { - calculate: (...args: unknown[]) => mockCalculate(...args), - }, + bomCostingScenarioService: {}, })) const { AppError, ErrorCode } = require('@/server/core/errors') @@ -56,9 +52,8 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => { })) }) - it('只在当前核算可修改时生成材质替换场景', async () => { - mockAssertCanModifyCurrentCosting.mockResolvedValue({ id: 'case-ready' }) - mockCalculate.mockResolvedValue({ scenario: { id: 'scenario-1' } }) + it('关闭换材质计算,但仍验证当前用户可以访问目标案例', async () => { + mockGetCase.mockResolvedValue({ id: 'case-ready' }) const request = { json: async () => ({ skuId: 'sku-1', @@ -70,20 +65,19 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => { params: Promise.resolve({ id: 'case-ready' }), }) - expect(mockAssertCanModifyCurrentCosting).toHaveBeenCalledWith( + expect(mockGetCase).toHaveBeenCalledWith( 'case-ready', user, ) - expect(mockCalculate).toHaveBeenCalledWith({ - caseId: 'case-ready', - skuId: 'sku-1', - targetMaterialId: 'material-1', + expect(response).toMatchObject({ + success: false, + status: 409, + error: { code: ErrorCode.VALIDATION_FAILED, message: expect.stringContaining('换材质预估已暂时关闭') }, }) - expect(response).toMatchObject({ success: true, status: 200 }) }) - it('旧版或处理中的结果被守卫拒绝时不调用成本引擎', async () => { - mockAssertCanModifyCurrentCosting.mockRejectedValue( + it('没有案例读取权限时维持原有鉴权错误', async () => { + mockGetCase.mockRejectedValue( new AppError( ErrorCode.VALIDATION_FAILED, '当前核算尚未完成,请等待本次处理结束后再调整成本', @@ -105,6 +99,6 @@ describe('POST /api/bom-costing/cases/:id/scenarios', () => { status: 422, error: { code: ErrorCode.VALIDATION_FAILED }, }) - expect(mockCalculate).not.toHaveBeenCalled() + expect(mockGetCase).toHaveBeenCalledWith('case-processing', user) }) }) diff --git a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts index 0cd1fddc..93f9a1f0 100644 --- a/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts +++ b/furniture-3d-system/src/app/api/bom-costing/cases/[id]/scenarios/route.ts @@ -1,5 +1,4 @@ import { NextRequest } from 'next/server' -import { bomCostingScenarioCalculationSchema } from '@/lib/bom-costing/contracts' import { AppError, ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' import { bomCostingService } from '@/server/services/bom-costing/bom-costing.service' @@ -38,15 +37,14 @@ export async function POST(request: NextRequest, context: RouteContext) { async (ctx) => { const { id } = await context.params const caseId = decodeURIComponent(id) - const body = await request.json().catch(() => null) - const parsed = bomCostingScenarioCalculationSchema.safeParse(body) - if (!parsed.success) { - return jsonError(ErrorCode.VALIDATION_FAILED, '目标材质参数不正确', 400, ctx.requestId) - } try { - await bomCostingService.assertCanModifyCurrentCosting(caseId, ctx.user) - const result = await bomCostingScenarioService.calculate({ caseId, ...parsed.data }) - return jsonSuccess(result, 200, ctx.requestId) + await bomCostingService.getCase(caseId, ctx.user) + return jsonError( + ErrorCode.VALIDATION_FAILED, + '换材质预估已暂时关闭,请使用重新核算更新当前预估结果', + 409, + ctx.requestId, + ) } catch (error) { if (error instanceof AppError) { return jsonError(error.code, error.message, error.statusCode, ctx.requestId) diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx index 0dc95c38..d22a9195 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -1,13 +1,37 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import BomCostingWorkbenchClient, { formatBomCategorySkillVersionHint, + getHistoryStatusDotClass, + getHistoryStatusLabel, } from './BomCostingWorkbenchClient' +import type { BomCostingCaseSummary } from '@/lib/bom-costing/client-types' jest.mock('@/components/platform/AssetWorkbenchEmptyBackdrop', () => ({ AssetWorkbenchEmptyBackdrop: () =>
, })) describe('BomCostingWorkbenchClient', () => { + it('整件预估完成但结构待复核时,历史状态点仍表示完成', () => { + const item: BomCostingCaseSummary = { + id: 'case-structure-review', + title: '结构待复核但预估完成的案例', + status: 'READY', + progress: 100, + statusMessage: '整件预估已生成,结构差异待复核', + needsPriceReview: true, + sourceCacheHit: true, + storedImageCount: 79, + failedImageCount: 0, + createdById: 'user-1', + createdAt: '2026-08-18T00:00:00.000Z', + skuCount: 15, + estimatedCostRange: { min: 432.9, max: 930.38 }, + } + + expect(getHistoryStatusLabel(item)).toBe('预估完成(结构待复核)') + expect(getHistoryStatusDotClass(item)).toBe('bg-[#3f8a68]') + }) + it('旧结果 Skill 与当前版本不同时分别标明,且不把旧结果冒充为新版本', () => { expect(formatBomCategorySkillVersionHint({ resultVersion: 'bom-category-skill/cabinet-bookshelf/v1', @@ -511,6 +535,100 @@ describe('BomCostingWorkbenchClient', () => { expect( screen.getByRole('button', { name: 'SKU 图 2,联合分析中' }), ).toBeInTheDocument() + expect(global.fetch).toHaveBeenCalledTimes(1) + window.dispatchEvent(new Event('focus')) + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(2) + }) + } finally { + global.fetch = originalFetch + } + }) + + it('处理时长优先展示整轮核算耗时,而不是模型合并的毫秒耗时', async () => { + const originalFetch = global.fetch + const processingFinishedAt = new Date().toISOString() + const processingStartedAt = new Date( + new Date(processingFinishedAt).getTime() - 86_000, + ).toISOString() + const detail = { + id: 'case-duration', + sourceUrl: 'https://item.example.com/item?id=duration', + sourceHost: 'item.example.com', + title: '核算耗时案例', + providerCode: 'market-detail-multimodal', + providerTaskId: 'bom-task-duration', + sourceCacheHit: true, + status: 'ANALYZING', + progress: 97, + statusMessage: 'SKU 结构已就绪,正在生成分层成本预估', + targetErrorPercent: 10, + currency: 'CNY', + createdById: 'user-1', + processingStartedAt, + processingFinishedAt, + createdAt: processingStartedAt, + updatedAt: processingFinishedAt, + sourceSnapshot: null, + skus: [], + analysisRuns: [], + taskEvents: [{ + id: 'event-model-started', + eventType: 'MODEL_ANALYSIS_STARTED', + status: 'ANALYZING', + progress: 78, + message: '准备生成结构化 BOM', + providerTaskId: 'bom-task-duration', + payload: { model: 'gpt-5.6-terra', imageCount: 79 }, + createdAt: processingStartedAt, + }, { + id: 'event-model-completed', + eventType: 'MODEL_ANALYSIS_COMPLETED', + status: 'ANALYZING', + progress: 97, + message: 'SKU 结构已就绪,正在生成分层成本预估', + providerTaskId: 'bom-task-duration', + payload: { model: 'gpt-5.6-terra', imageCount: 79, durationMs: 10 }, + createdAt: processingFinishedAt, + }], + } + global.fetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ success: true, data: { case: detail } }), + })) as jest.Mock + + try { + render( + , + ) + + expect(await screen.findByText('1 分 26 秒')).toBeInTheDocument() + expect(screen.queryByText('0 秒')).not.toBeInTheDocument() } finally { global.fetch = originalFetch } @@ -1371,6 +1489,7 @@ describe('BomCostingWorkbenchClient', () => { costedSkuCount: 1, pendingSkuCount: 1, failedSkuCount: 1, + qualityWarningCount: 1, succeededSkuIds: ['sku-recalculate', 'sku-pending'], costedSkuIds: ['sku-recalculate'], pendingSkuIds: ['sku-pending'], @@ -1576,30 +1695,30 @@ describe('BomCostingWorkbenchClient', () => { expect(await screen.findByText('预计出厂成本')).toBeInTheDocument() const dynamicCostNotice = screen.getByTestId('bom-dynamic-cost-partial-notice') expect(dynamicCostNotice).toHaveTextContent( - '1/3 个 SKU 的原材质预估已完成', + '1/3 个 SKU 的预估已完成', ) expect(dynamicCostNotice).toHaveTextContent( - '这是原材质批次结果,不代表当前材质替换方案已经完成', + '失败规格 · 当前资料不足,暂无法预估', ) expect(dynamicCostNotice).toHaveTextContent( - '失败规格 · 事务启动超时', + '结构拆解差异已标记待复核,当前整件预估已保留', ) expect(screen.getByRole('alert', { - name: '原材质批次部分完成,包含成本生成失败', + name: '本轮批次部分完成,部分 SKU 暂无法预估', })).toBe(dynamicCostNotice) const failedSkuButton = screen.getByRole('button', { name: /失败规格/ }) - expect(within(failedSkuButton).getByText('生成失败')).toBeInTheDocument() + expect(within(failedSkuButton).getByText('资料不足')).toBeInTheDocument() fireEvent.click(failedSkuButton) - expect(screen.getAllByText('成本生成失败').length).toBeGreaterThan(0) + expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0) expect(screen.getByText( - '本轮该 SKU 成本生成失败:事务启动超时,两次重试仍失败。已保留结构事实,可按现有事实重新核算。', + '该规格暂未形成预估;当前结构与资料已保留。', )).toBeInTheDocument() expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument() expect(screen.queryByText('约 ¥999.00')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /待补价格规格/ })) expect(screen.getAllByText('待补成本').length).toBeGreaterThan(0) expect(screen.getByText( - '本轮该 SKU 待补成本:结构事实已保存,但公司价格资料尚未闭合,补齐后再生成完整预估。', + '该规格仍在整理预估依据;当前结构与资料已保留。', )).toBeInTheDocument() expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument() expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument() @@ -1675,7 +1794,7 @@ describe('BomCostingWorkbenchClient', () => { screen.getByText('按现有事实重算').closest('[data-motion="reveal"]'), ).toBeInTheDocument() const reuseFactsExplanation = screen.getByText( - '只重新汇总结构、BOM 与资料库价格,复用已保存的逐图结果。', + '只重新汇总结构、BOM 与预估结果,复用已保存的逐图结果。', ) expect(reuseFactsExplanation.closest('[role="tooltip"]')).toHaveClass('sr-only') fireEvent.click(screen.getByRole('button', { name: '查看按现有事实重算说明' })) @@ -1721,7 +1840,7 @@ describe('BomCostingWorkbenchClient', () => { } }) - it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => { + it('未匹配结构项不显示零元,并保留中性的预估待补状态', async () => { const originalFetch = global.fetch const now = new Date().toISOString() const unresolvedLine = { @@ -1803,69 +1922,7 @@ describe('BomCostingWorkbenchClient', () => { }], taskEvents: [], } - const resolvedDetail = { - ...detail, - skus: [{ - ...detail.skus[0], - estimatedCost: null, - costCalculatedAt: null, - costSummary: { - material: 51.57, - processing: 83, - labor: 35, - packaging: 0, - lossIncludedTotal: 169.57, - unresolvedLineCount: 0, - missingCostGroups: ['PACKAGING'], - }, - bomLines: [{ - ...unresolvedLine, - heightMm: 18, - matchStatus: 'MATCHED', - matchedLibraryType: 'SUBSTRATE', - matchedLibraryRecordId: 'rubberwood-18', - matchedLibraryName: '18ENF级橡胶木', - matchedLibrarySpec: '18mm · ENF', - resolvedUnitPrice: 80.63, - subtotal: 51.57, - }], - }], - } - - global.fetch = jest.fn(async (input, init) => { - const url = String(input) - if (url.endsWith('/lines/line-rubberwood') && init?.method === 'PATCH') { - const patchBody = - typeof init.body === 'string' ? JSON.parse(init.body) : {} - return { - ok: true, - json: async () => ({ - success: true, - data: { case: patchBody.review ? detail : resolvedDetail }, - }), - } as Response - } - if (url.endsWith('/lines/line-rubberwood')) { - return { - ok: true, - json: async () => ({ - success: true, - data: { - candidates: [{ - recordId: 'rubberwood-18', - libraryType: 'SUBSTRATE', - code: 'RW-18', - name: '18ENF级橡胶木', - spec: '18mm · ENF', - unit: '㎡', - unitPrice: 80.63, - thicknessMm: 18, - recommended: true, - }], - }, - }), - } as Response - } + global.fetch = jest.fn(async () => { return { ok: true, json: async () => ({ @@ -1907,83 +1964,20 @@ describe('BomCostingWorkbenchClient', () => { expect(await screen.findByText('橡胶木桌面板')).toBeInTheDocument() expect(screen.getByText('SKU 结构已就绪')).toBeInTheDocument() - expect(screen.getAllByText('核算未完成').length).toBeGreaterThan(0) + expect(screen.getAllByText('资料不足,暂无法预估').length).toBeGreaterThan(0) expect(screen.getByText('缺板厚')).toBeInTheDocument() - expect(screen.getByText('暂缺')).toBeInTheDocument() + expect(screen.getByText('预估待补')).toBeInTheDocument() expect(screen.queryByText('¥0.00')).not.toBeInTheDocument() - - fireEvent.click( - screen.getByRole('button', { name: '记录橡胶木桌面板人工复核值' }), - ) - expect(screen.getByText('材质与规格')).toBeInTheDocument() - expect(screen.getByText('部件尺寸')).toBeInTheDocument() - expect(screen.getByText('计价参数')).toBeInTheDocument() - fireEvent.change(screen.getByLabelText('材质名称'), { - target: { value: '橡胶木实木板' }, - }) - fireEvent.change(screen.getByLabelText('单价'), { - target: { value: '80' }, - }) - fireEvent.change(screen.getByLabelText('价格单位'), { - target: { value: '㎡' }, - }) - fireEvent.click(screen.getByRole('button', { name: '保存并留档' })) - - await waitFor(() => { - const reviewPatchCall = (global.fetch as jest.Mock).mock.calls.find( - ([url, init]) => { - if ( - !String(url).endsWith('/lines/line-rubberwood') || - init?.method !== 'PATCH' || - typeof init.body !== 'string' - ) { - return false - } - return Boolean(JSON.parse(init.body).review) - }, - ) - expect(reviewPatchCall).toBeDefined() - expect(JSON.parse(reviewPatchCall?.[1]?.body)).toEqual({ - review: expect.objectContaining({ - materialName: '橡胶木实木板', - unitPrice: 80, - priceUnit: '㎡', - }), - }) - }) - - fireEvent.click(screen.getByRole('button', { name: /确认价格/ })) - fireEvent.click( - await screen.findByRole('button', { name: /18ENF级橡胶木/ }), - ) - - await waitFor(() => { - const patchCall = (global.fetch as jest.Mock).mock.calls.find( - ([url, init]) => { - if ( - !String(url).endsWith('/lines/line-rubberwood') || - init?.method !== 'PATCH' || - typeof init.body !== 'string' - ) { - return false - } - return JSON.parse(init.body).matchedLibraryRecordId === 'rubberwood-18' - }, - ) - expect(patchCall).toBeDefined() - expect(JSON.parse(patchCall?.[1]?.body)).toEqual({ - matchedLibraryRecordId: 'rubberwood-18', - heightMm: 18, - }) - }) - expect((await screen.findAllByText('¥51.57')).length).toBeGreaterThan(0) - expect(screen.getByText('18ENF级橡胶木')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /人工复核|确认价格|保存并留档/ })).not.toBeInTheDocument() + expect((global.fetch as jest.Mock).mock.calls.some(([url]) => ( + String(url).endsWith('/lines/line-rubberwood') + ))).toBe(false) } finally { global.fetch = originalFetch } }) - it('动态成本优先展示,并提交目标材质生成双场景且保留超售价金额', async () => { + it('统一展示同材质预估,单项待补不影响整件完成且不展示换材质入口', async () => { const originalFetch = global.fetch const now = new Date().toISOString() const scenario = (input: { @@ -2002,6 +1996,24 @@ describe('BomCostingWorkbenchClient', () => { secondarySubtotal?: number aiSubtotal?: number rulesetVersion?: string + realtimeMarketQuote?: { + schemaVersion: 'bom-costing-1688-realtime/v1' + source: '1688_IMAGE_SEARCH' + status: 'AVAILABLE' + sourceImageUrl: string + searchedAt: string + procurementRate: number + marketPrice: number + procurementCost: number + estimateRange: { min: number; max: number } + confidence: number + comparableOfferCount: number + returnedOfferCount: number + warnings: string[] + offers: [] + structuralCost: number + structuralEstimateRange: { min: number; max: number } + } }) => ({ id: input.id, skuId: 'sku-dynamic', @@ -2034,6 +2046,7 @@ describe('BomCostingWorkbenchClient', () => { syncStatus: 'SUCCEEDED', syncCompletedAt: now, rulesetVersion: input.rulesetVersion ?? 'dynamic-bom-cost/v1', + realtimeMarketQuote: input.realtimeMarketQuote ?? null, createdAt: now, boardCutting: [{ sourceLineId: 'line-board', @@ -2163,14 +2176,17 @@ describe('BomCostingWorkbenchClient', () => { kind: 'SAME_MATERIAL', targetMaterialId: null, targetMaterialName: '', - status: 'INSUFFICIENT_DATA', - totalCost: null, - knownCost: 500, + status: 'PROVISIONAL', + totalCost: 1_350, + knownCost: 1_350, unresolvedCount: 1, competitorPrice: 600, - priceDelta: null, - unitPrice: null, - subtotal: null, + priceDelta: 750, + unitPrice: 1_600, + subtotal: 800, + secondarySubtotal: 550, + aiSubtotal: 18.5, + rulesetVersion: 'dynamic-bom-cost/v6', }), scenario({ id: 'scenario-substitution-old', @@ -2207,6 +2223,24 @@ describe('BomCostingWorkbenchClient', () => { subtotal: 800, secondarySubtotal: 550, rulesetVersion: 'dynamic-bom-cost/v6', + realtimeMarketQuote: { + schemaVersion: 'bom-costing-1688-realtime/v1', + source: '1688_IMAGE_SEARCH', + status: 'AVAILABLE', + sourceImageUrl: 'https://images.example.com/desk.jpg', + searchedAt: now, + procurementRate: 0.9, + marketPrice: 1_500, + procurementCost: 1_350, + estimateRange: { min: 1_215, max: 1_485 }, + confidence: 0.84, + comparableOfferCount: 3, + returnedOfferCount: 3, + warnings: [], + offers: [], + structuralCost: 1_350, + structuralEstimateRange: { min: 1_080, max: 1_620 }, + }, }), scenario({ id: 'scenario-substitution-updated', @@ -2423,13 +2457,11 @@ describe('BomCostingWorkbenchClient', () => { />, ) - expect((await screen.findAllByText('旧版结果待重新核算')).length).toBeGreaterThan(0) - expect(screen.getAllByText('已计部分 ¥500.00(不是整件成本)').length).toBeGreaterThan(0) - expect(screen.getAllByText('旧版结果待重新核算').length).toBeGreaterThan(0) - expect(screen.getByText('1 行没有可验证价格来源')).toBeInTheDocument() - expect(screen.getByText(/最近同步未成功:上游连接超时/)).toBeInTheDocument() + expect((await screen.findAllByText('约 ¥1,350.00')).length).toBeGreaterThan(0) + expect(screen.getAllByText('预计出厂成本').length).toBeGreaterThan(0) + expect(screen.getByText('1 行预估待补,不影响已形成的整件预估')).toBeInTheDocument() const skuButton = screen.getByRole('button', { name: /黑胡桃色 120cm/ }) - expect(within(skuButton).getByText('旧版结果待重新核算')).toBeInTheDocument() + expect(within(skuButton).getByText('约 ¥1,350.00')).toBeInTheDocument() expect(screen.getByText('旧规则留档:¥1,300.00')).toBeInTheDocument() const structureSummary = screen.getByText('结构与推导依据') fireEvent.click(structureSummary) @@ -2439,31 +2471,8 @@ describe('BomCostingWorkbenchClient', () => { expect(within(structureDetails as HTMLElement).getByText(/结构初判 1 轮 · 定向复核 2 轮/)).toBeInTheDocument() expect(within(structureDetails as HTMLElement).getByText('待复核')).toBeInTheDocument() expect(within(structureDetails as HTMLElement).getAllByText(/装配关系推导/).length).toBeGreaterThan(0) - expect(within(structureDetails as HTMLElement).getByRole('button', { name: '复核' })).toBeInTheDocument() + expect(within(structureDetails as HTMLElement).queryByRole('button', { name: '复核' })).not.toBeInTheDocument() - await waitFor(() => { - expect(screen.getByLabelText('选择替换目标材质')).toHaveValue( - 'material-rubberwood', - ) - }) - fireEvent.click(screen.getByRole('button', { name: '替换为 橡胶木' })) - expect(screen.getAllByText('约 ¥550.00').length).toBeGreaterThan(0) - - const calculateButton = screen.getByRole('button', { name: '生成替换方案' }) - await waitFor(() => expect(calculateButton).toBeEnabled()) - fireEvent.click(calculateButton) - - await waitFor(() => { - const postCall = (global.fetch as jest.Mock).mock.calls.find( - ([url, init]) => String(url).endsWith('/cases/case-dynamic/scenarios') && init?.method === 'POST', - ) - expect(postCall).toBeDefined() - expect(JSON.parse(postCall?.[1]?.body)).toEqual({ - skuId: 'sku-dynamic', - targetMaterialId: 'material-rubberwood', - }) - }) - expect(await screen.findByText('预计出厂成本 · 橡胶木')).toBeInTheDocument() const dynamicCostingHeading = screen.getByRole('heading', { name: '分层成本预估', level: 2, @@ -2489,39 +2498,22 @@ describe('BomCostingWorkbenchClient', () => { ) expect(screen.queryByText('暂无图片')).not.toBeInTheDocument() expect(screen.getByRole('heading', { name: '选择 SKU', level: 3 })).toBeInTheDocument() - expect(screen.getAllByText('约 ¥1,300.00').length).toBeGreaterThan(0) + expect(screen.getAllByText('约 ¥1,350.00').length).toBeGreaterThan(0) const costInfoButton = screen.getAllByRole('button', { name: '查看预计出厂成本说明', })[0] fireEvent.click(costInfoButton) - expect(screen.getAllByText(/板材按 8 件稳定批量排版/).length).toBeGreaterThan(0) - expect(screen.getAllByText(/批量利用率 82%/).length).toBeGreaterThan(0) - expect(screen.getAllByText(/单件整板承担上限 ¥1,000.00/).length).toBeGreaterThan(0) - expect(screen.getAllByText('预估 / 待复核').length).toBeGreaterThan(0) - expect(screen.getByText(/成本高于竞品售价,金额已保留/)).toBeInTheDocument() - expect(screen.getAllByText('约 ¥1,270.00').length).toBeGreaterThan(0) - expect(screen.getAllByText('公司历史 BOM 中位单价').length).toBeGreaterThan(0) - expect(screen.getAllByText('AI 参考预估 · 非正式采购价').length).toBeGreaterThan(0) - expect(screen.getAllByText('多模态模型参考公司知识预估').length).toBeGreaterThan(0) - expect(screen.getByText('1 行由多模态模型参考公司知识补充预估,整单已形成区间并等待复核')).toBeInTheDocument() - expect(screen.getAllByText(/参考 SKU:REF-BOARD/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/估算区间 ¥1,080.00–¥1,620.00/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/结构明细预估待补/).length).toBeGreaterThan(0) + expect(screen.getAllByText('预估完成').length).toBeGreaterThan(0) expect(screen.getByText('成本贡献明细')).toBeInTheDocument() expect(screen.getAllByText('最大贡献').length).toBeGreaterThan(0) - expect(screen.getAllByText(/占(?:已知成本 )?62%/).length).toBeGreaterThan(0) - expect(screen.getAllByText('较同材质').length).toBeGreaterThan(0) - expect(screen.getAllByText('节省 ¥50.00').length).toBeGreaterThan(0) - expect(screen.queryByText('-¥50.00')).not.toBeInTheDocument() - - const costSummaryAside = screen.getByText('预计出厂成本 · 橡胶木').closest('aside') - expect(costSummaryAside).not.toBeNull() - expect(within(costSummaryAside as HTMLElement).getByText('竞品售价 ¥600.00')).toBeInTheDocument() - expect(within(costSummaryAside as HTMLElement).getByText(/高于竞品售价 ¥700.00/)).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: '同材质基准' })) - expect(screen.queryByText('较同材质')).not.toBeInTheDocument() - expect(screen.queryByText('替换后预计')).not.toBeInTheDocument() - expect(screen.queryByText('节省 ¥50.00')).not.toBeInTheDocument() - expect(screen.getAllByText('历史价').length).toBeGreaterThan(0) + expect(screen.getAllByText(/占(?:已知成本 )?59%/).length).toBeGreaterThan(0) + expect(screen.queryByLabelText('选择替换目标材质')).not.toBeInTheDocument() + expect(screen.queryByText('生成替换方案')).not.toBeInTheDocument() + expect(screen.queryByText('竞品店铺')).not.toBeInTheDocument() + expect(screen.queryByText('item.example.com')).not.toBeInTheDocument() + expect(screen.queryByText(/1688|实时同款|实时采购|公司单价|公司历史 BOM|参考 SKU|竞品售价|非正式采购价/iu)).not.toBeInTheDocument() } finally { global.fetch = originalFetch } @@ -2712,10 +2704,8 @@ describe('BomCostingWorkbenchClient', () => { />, ) - expect(await screen.findByText('待确认品类商品')).toBeInTheDocument() - expect(screen.getAllByText('品类待确认').length).toBeGreaterThan(0) + expect(await screen.findByText('结构草稿')).toBeInTheDocument() expect(screen.getByText('请先确认品类')).toBeInTheDocument() - expect(screen.getAllByText('暂不估价').length).toBeGreaterThan(0) expect(screen.queryByText('¥0.00')).not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: '分层成本预估' })).not.toBeInTheDocument() diff --git a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx index 2a0d595c..210d1ccb 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.tsx @@ -2,7 +2,6 @@ import Image from 'next/image' import { - Fragment, useCallback, useEffect, useId, @@ -20,12 +19,9 @@ import { ChevronDown, ChevronRight, CircleAlert, - Clock3, - ExternalLink, Link2, ListFilter, PackageSearch, - Pencil, Play, RefreshCw, ScanSearch, @@ -41,10 +37,6 @@ import type { BomCostingCapabilities, BomCostingCaseDetail, BomCostingCaseSummary, - BomCostingDynamicCostingDetail, - BomCostingDynamicScenarioKindValue, - BomCostingLineDetail, - BomCostingPriceCandidateDetail, BomCostingScenarioDetail, BomCostingSkuDetail, } from '@/lib/bom-costing/client-types' @@ -89,55 +81,6 @@ interface RecalculationRequest { categorySkillId?: string } -type LineEditorMode = 'LIBRARY' | 'MANUAL' - -interface ManualReviewDraft { - materialCode: string - materialName: string - materialSpec: string - lengthMm: string - widthMm: string - heightMm: string - quantity: string - unitPrice: string - priceUnit: string - lossFactor: string - note: string -} - -type ManualReviewField = Exclude - -const MANUAL_REVIEW_FIELD_GROUPS: Array<{ - label: string - fields: Array -}> = [ - { - label: '材质与规格', - fields: [ - ['materialName', '材质名称', '如:橡胶木实木板'], - ['materialSpec', '材质规格', '如:18mm'], - ['materialCode', '材质编码', '可选'], - ], - }, - { - label: '部件尺寸', - fields: [ - ['lengthMm', '长度(mm)', '可选'], - ['widthMm', '宽度(mm)', '可选'], - ['heightMm', '厚度/高度(mm)', '可选'], - ], - }, - { - label: '计价参数', - fields: [ - ['quantity', '数量', '必填'], - ['unitPrice', '单价', '可选'], - ['priceUnit', '价格单位', '㎡ / 件 / 米'], - ['lossFactor', '损耗系数', '默认 1'], - ], - }, -] - const RECALCULATION_OPTIONS: Array<{ value: RecalculationMode label: string @@ -147,7 +90,7 @@ const RECALCULATION_OPTIONS: Array<{ { value: 'REUSE_FACTS', label: '按现有事实重算', - description: '只重新汇总结构、BOM 与资料库价格,复用已保存的逐图结果。', + description: '只重新汇总结构、BOM 与预估结果,复用已保存的逐图结果。', scope: '不读取竞品资料 · 不调用图片模型 · 仅汇总实际 SKU', }, { @@ -186,7 +129,7 @@ const STATUS_DOT_CLASSES: Record = { FAILED: 'bg-[#b6534e]', } -function getHistoryStatusLabel(item: BomCostingCaseSummary) { +export function getHistoryStatusLabel(item: BomCostingCaseSummary) { if (item.status !== 'READY') return STATUS_LABELS[item.status] if (item.requiresRecalculation) return '旧规则结果' if (item.hasFailedRecalculation) return '重算失败' @@ -194,17 +137,16 @@ function getHistoryStatusLabel(item: BomCostingCaseSummary) { (item.dynamicCostingSummary?.failedSkuCount ?? 0) > 0 || (item.dynamicCostingSummary?.pendingSkuCount ?? 0) > 0 ) return '部分完成' - if (item.needsPriceReview) return '待复核' + if (item.needsPriceReview) return '预估完成(结构待复核)' return STATUS_LABELS[item.status] } -function getHistoryStatusDotClass(item: BomCostingCaseSummary) { +export function getHistoryStatusDotClass(item: BomCostingCaseSummary) { if ( item.status === 'READY' && ( item.requiresRecalculation || item.hasFailedRecalculation || - item.needsPriceReview || (item.dynamicCostingSummary?.failedSkuCount ?? 0) > 0 || (item.dynamicCostingSummary?.pendingSkuCount ?? 0) > 0 ) @@ -339,15 +281,6 @@ function estimateConfidenceLabel(value: number | null | undefined) { return '较低' } -const ESTIMATE_BASIS_LABELS = { - MASTER_DATA: '基础资料库', - DIRECT_HISTORY: '直接历史匹配', - SKU_FAMILY: '同款 SKU 互证', - SIMILAR_HISTORY: '相似历史 BOM', - CATEGORY_COHORT: '同分类历史分布', - AI_REFERENCE_ESTIMATE: '多模态模型参考预估', -} as const - const STRUCTURE_EVIDENCE_BASIS_LABELS = { IMAGE: '图片明确证据', SOURCE_TEXT: '商品文字', @@ -355,100 +288,30 @@ const STRUCTURE_EVIDENCE_BASIS_LABELS = { STRUCTURAL_INFERENCE: '装配关系推导', } as const +function publicEstimateWarning(value: string) { + if (!value) return '' + return /(?:1688|实时同款|同款价|采购系数|市场价|竞品售价|公司(?:历史)?\s*BOM|参考\s*SKU|公司价格来源|基础资料库|正式采购|历史用量|镜像(?:同步|批次)?|供应商|店铺|商品链接)/iu.test(value) + ? '当前预估仍有待补信息,建议补充结构或尺寸后重新核算' + : value +} + function scenarioEvidenceText( scenario: BomCostingScenarioDetail, structureAnalysis?: BomCostingCaseDetail['structureAnalysis'], ) { - const basisLabels = Array.from(new Set(scenario.lines.flatMap((line) => ( - line.estimateBasis ? [ESTIMATE_BASIS_LABELS[line.estimateBasis]] : [] - )))) - if (basisLabels.length === 0 && scenario.estimateBasis) { - basisLabels.push(ESTIMATE_BASIS_LABELS[scenario.estimateBasis]) - } - const referenceCount = new Set(scenario.lines.flatMap((line) => line.referenceSkus)).size - const aiEstimatedLineCount = scenario.lines.filter( - (line) => line.estimateBasis === 'AI_REFERENCE_ESTIMATE', - ).length - const historicalLineSampleCount = Math.max(0, ...scenario.lines - .filter((line) => line.estimateBasis !== 'MASTER_DATA') - .map((line) => line.sampleCount ?? 0)) - const sampleCount = referenceCount || historicalLineSampleCount || ( - scenario.estimateBasis !== 'MASTER_DATA' ? scenario.sampleCount : 0 - ) - const boardCutting = scenario.boardCutting ?? [] - const boardCostTotal = boardCutting.reduce( - (sum, item) => sum + item.perUnitSheetCost, - 0, - ) - const weightedBoardUtilization = boardCostTotal > 0 - ? boardCutting.reduce( - (sum, item) => sum + item.utilization * item.perUnitSheetCost, - 0, - ) / boardCostTotal - : null - const selectedBatchSizes = Array.from(new Set( - boardCutting.map((item) => item.selectedBatchSize), - )).sort((left, right) => left - right) - const singleUnitBoardUpper = boardCutting.reduce( - (sum, item) => sum + item.singleUnitSheetCost, - 0, - ) return [ `估算区间 ${formatEstimateRange(scenario.estimateRange)}`, `可信度 ${estimateConfidenceLabel(scenario.confidence)}${scenario.confidence !== null ? `(${Math.round(scenario.confidence * 100)}%)` : ''}`, - sampleCount > 0 - ? `参考 ${sampleCount} 套历史 BOM` - : basisLabels.includes(ESTIMATE_BASIS_LABELS.MASTER_DATA) - ? '参考基础资料库标准价格' - : aiEstimatedLineCount > 0 - ? '模型结合结构事实、已核算明细与公司知识形成区间' - : '历史样本数待确认', - basisLabels.length > 0 ? `依据 ${basisLabels.join('、')}` : '', - scenario.estimatedLineCount > 0 ? `${scenario.estimatedLineCount} 条明细采用分层预估` : '', - aiEstimatedLineCount > 0 ? `${aiEstimatedLineCount} 条明细为 AI 参考预估,需人工复核` : '', + scenario.unresolvedCount > 0 ? `${scenario.unresolvedCount} 条结构明细预估待补` : '结构明细已纳入整件预估', structureAnalysis ? `结构初判 1 轮,定向复核 ${structureAnalysis.verificationRoundCount} 轮` : '', - selectedBatchSizes.length > 0 - ? `板材按 ${selectedBatchSizes.join('、')} 件稳定批量排版` - : '', - weightedBoardUtilization !== null - ? `批量利用率 ${Math.round(weightedBoardUtilization * 100)}%` - : '', - singleUnitBoardUpper > 0 - ? `单件整板承担上限 ${formatMoney(singleUnitBoardUpper)}` - : '', - scenario.warning ? `提醒 ${scenario.warning}` : '', + scenario.warning ? `提醒 ${publicEstimateWarning(scenario.warning)}` : '', ].filter(Boolean).join(';') } -function missingScenarioLabel(scenario: BomCostingScenarioDetail | null) { - if ( - !scenario || - !['dynamic-bom-cost/v5', 'dynamic-bom-cost/v6', 'dynamic-bom-cost/v7', 'dynamic-bom-cost/v8'].some((version) => - scenario.rulesetVersion.startsWith(version), - ) - ) { - return '旧版结果待重新核算' - } - return '核算未完成' -} - -function scenarioMarketPriceAlert(scenario: BomCostingScenarioDetail | null) { - if (!scenario) return null - if (scenario.marketPriceAlert) return scenario.marketPriceAlert - if (scenario.totalCost === null || scenario.competitorPrice === null) return null - if (scenario.totalCost > scenario.competitorPrice) return 'HIGH' as const - if (scenario.totalCost < scenario.competitorPrice * 0.1) return 'LOW' as const - return null -} - -function formatScenarioDifference(value: number | null | undefined) { - if (value === null || value === undefined) return '--' - if (Math.abs(value) < 0.005) return '无变化' - return value > 0 - ? `增加 ${formatMoney(value)}` - : `节省 ${formatMoney(Math.abs(value))}` +function missingScenarioLabel(_scenario: BomCostingScenarioDetail | null) { + return '资料不足,暂无法预估' } export function formatBomCategorySkillVersionHint({ @@ -541,6 +404,7 @@ function formatMetric(value: number | null, suffix = '') { function formatDuration(milliseconds: number | null) { if (milliseconds === null || milliseconds < 0) return '--' const totalSeconds = Math.floor(milliseconds / 1_000) + if (milliseconds > 0 && totalSeconds === 0) return '不足 1 秒' if (totalSeconds < 60) return `${totalSeconds} 秒` const minutes = Math.floor(totalSeconds / 60) const seconds = totalSeconds % 60 @@ -576,6 +440,7 @@ interface DynamicCostingCompletionNotice { pendingSkuCount: number failedSkuCount: number reviewSkuCount: number + qualityWarningCount: number succeededSkuIds: string[] costedSkuIds: string[] pendingSkuIds: string[] @@ -675,7 +540,7 @@ function parseDynamicCostingCompletionNotice( return [{ skuId: readPayloadText(item.skuId) || null, externalSkuId: readPayloadText(item.externalSkuId) || null, - message: readPayloadText(item.message) || '成本预估生成失败', + message: '资料不足,暂无法预估', }] }) : [] @@ -685,6 +550,7 @@ function parseDynamicCostingCompletionNotice( pendingSkuCount, failedSkuCount, reviewSkuCount: readPayloadNumber(payload.reviewSkuCount) ?? 0, + qualityWarningCount: readPayloadNumber(payload.qualityWarningCount) ?? 0, succeededSkuIds: readPayloadTextArray(payload.succeededSkuIds), costedSkuIds: readPayloadTextArray(payload.costedSkuIds), pendingSkuIds: readPayloadTextArray(payload.pendingSkuIds), @@ -774,8 +640,6 @@ function toSummary(detail: BomCostingCaseDetail): BomCostingCaseSummary { return { id: detail.id, title: detail.title, - sourceUrl: detail.sourceUrl, - sourceHost: detail.sourceHost, status: detail.status, progress: detail.progress, statusMessage: detail.statusMessage, @@ -805,22 +669,6 @@ function toSummary(detail: BomCostingCaseDetail): BomCostingCaseSummary { } } -function buildManualReviewDraft(line: BomCostingLineDetail): ManualReviewDraft { - return { - materialCode: line.materialCode, - materialName: line.materialName, - materialSpec: line.materialSpec, - lengthMm: line.lengthMm === null ? '' : String(line.lengthMm), - widthMm: line.widthMm === null ? '' : String(line.widthMm), - heightMm: line.heightMm === null ? '' : String(line.heightMm), - quantity: String(line.quantity), - unitPrice: line.resolvedUnitPrice === null ? '' : String(line.resolvedUnitPrice), - priceUnit: line.resolvedPriceUnit || line.unit, - lossFactor: String(line.lossFactor), - note: '', - } -} - function getStageState( detail: BomCostingCaseDetail, stage: (typeof PROCESS_STAGES)[number], @@ -878,16 +726,6 @@ function ProcessingCanvas({

{detail.title}

- - - {detail.sourceCacheHit ? ( 已复用资料 @@ -956,8 +794,8 @@ function ProcessInspector({ new Date(detail.processingStartedAt).getTime() : null const durationMs = - readNumber(completedPayload?.durationMs) ?? - processingDurationMs + processingDurationMs ?? + readNumber(completedPayload?.durationMs) const imageCount = readNumber(completedPayload?.imageCount) ?? readNumber(startedPayload?.imageCount) @@ -1150,29 +988,20 @@ function ProcessInspector({ ) : null} -
-
- - - {detail.providerTaskId} - -
-
) } function dynamicStatusLabel( status: BomCostingScenarioDetail['status'], - scope: 'scenario' | 'line' = 'scenario', ) { return status === 'FINAL' - ? scope === 'line' ? '历史价' : '预估完成' + ? '预估完成' : status === 'PROVISIONAL' - ? '预估 / 待复核' - : status === 'STALE' - ? '历史数据待更新' - : '核算未完成' + ? '预估完成' + : status === 'STALE' + ? '历史数据待更新' + : '资料不足' } const DYNAMIC_CATEGORY_LABELS: Record = { @@ -1199,63 +1028,18 @@ function dynamicStatusClass(status: BomCostingScenarioDetail['status']) { : 'bg-[#f3eddf] text-[#80613a]' } -function dynamicSyncLabel(state: BomCostingDynamicCostingDetail['sync']['state']) { - return state === 'READY' - ? '镜像新鲜' - : state === 'STALE' - ? '镜像已过期' - : state === 'MIXED' - ? '镜像批次不一致' - : '尚无成功镜像' -} - function DynamicCostingPanel({ sku, costing, structureAnalysis, - scenarioKind, - targetMaterialId, - calculating, - error, - onScenarioKindChange, - onTargetMaterialChange, - onCalculate, }: { sku: BomCostingSkuDetail - costing: BomCostingDynamicCostingDetail | undefined + costing: BomCostingCaseDetail['dynamicCosting'] structureAnalysis: BomCostingCaseDetail['structureAnalysis'] - scenarioKind: BomCostingDynamicScenarioKindValue - targetMaterialId: string - calculating: boolean - error: string - onScenarioKindChange: (kind: BomCostingDynamicScenarioKindValue) => void - onTargetMaterialChange: (id: string) => void - onCalculate: () => void }) { const skuScenarios = costing?.scenarios.filter((scenario) => scenario.skuId === sku.id) ?? [] const sameScenario = skuScenarios.find((scenario) => scenario.kind === 'SAME_MATERIAL') ?? null - const substitutionScenario = skuScenarios.find((scenario) => scenario.kind === 'MATERIAL_SUBSTITUTION') ?? null - const selectedScenario = scenarioKind === 'SAME_MATERIAL' ? sameScenario : substitutionScenario - const marketPriceAlert = scenarioMarketPriceAlert(selectedScenario) - const difference = sameScenario?.totalCost !== null && sameScenario?.totalCost !== undefined && - substitutionScenario?.totalCost !== null && substitutionScenario?.totalCost !== undefined - ? substitutionScenario.totalCost - sameScenario.totalCost - : null - const provisionalLineCount = selectedScenario?.lines.filter((line) => line.status === 'PROVISIONAL').length ?? 0 - const aiEstimatedLineCount = selectedScenario?.lines.filter( - (line) => line.estimateBasis === 'AI_REFERENCE_ESTIMATE', - ).length ?? 0 - const staleLineCount = selectedScenario?.lines.filter((line) => line.status === 'STALE').length ?? 0 - const materialName = costing?.materials.find((material) => material.id === targetMaterialId)?.name ?? '未选择' - const savedTargetMaterialId = substitutionScenario?.targetMaterialId ?? '' - const targetMaterialNeedsRefresh = Boolean( - substitutionScenario && targetMaterialId !== savedTargetMaterialId, - ) - const savedMaterialName = substitutionScenario?.targetMaterialName || materialName - const latestFailedAfterSuccess = Boolean( - costing?.sync.lastFailedAt && - (!costing.sync.lastSuccessfulAt || Date.parse(costing.sync.lastFailedAt) > Date.parse(costing.sync.lastSuccessfulAt)), - ) + const selectedScenario = sameScenario const categoryRows: Array<[string, number | null, keyof BomCostingScenarioDetail['categoryEstimateRanges']]> = selectedScenario ? [ ['材料', selectedScenario.materialCost, 'MATERIAL'], @@ -1265,7 +1049,7 @@ function DynamicCostingPanel({ ['损耗', selectedScenario.lossCost, 'LOSS'], ] : [] - const contributionBase = selectedScenario?.totalCost ?? selectedScenario?.knownCost ?? 0 + const contributionBase = selectedScenario?.totalCost ?? 0 const contributionLines = (selectedScenario?.lines ?? []) .map((line, index) => ({ line, index })) .sort((left, right) => ( @@ -1286,7 +1070,7 @@ function DynamicCostingPanel({ id="bom-dynamic-costing" className="order-2 min-w-0 scroll-mt-4 border-t border-black/[0.07] pt-5 xl:order-none xl:col-start-1 xl:row-start-2" aria-labelledby="bom-dynamic-costing-title" - aria-busy={calculating} + aria-busy={false} >
@@ -1294,120 +1078,28 @@ function DynamicCostingPanel({
- {costing ? ( -
- - {dynamicSyncLabel(costing.sync.state)} - - - 镜像完成于 {formatActivityTime(costing.sync.dataAsOf)} - -
- ) : null}
{!costing ? (
- 这条记录还没有 v7 预估结果。重新核算后会自动生成同材质基准;需要比较时再选择替换材质。 + 这条记录还没有可用预估结果。可在“重新核算”中更新当前预估。
) : ( <> -
-
- - -
-
-
- {([ - ['SAME_MATERIAL', '同材质基准'], - ['MATERIAL_SUBSTITUTION', `替换为 ${targetMaterialNeedsRefresh ? materialName : savedMaterialName}${targetMaterialNeedsRefresh ? '(待重新生成)' : ''}`], - ] as const).map(([kind, label]) => ( - - ))} -
- {targetMaterialNeedsRefresh ? ( -

- 目标材质已更改,当前替换结果仍是上次保存的 {savedMaterialName},请重新生成。 -

- ) : null} -
-
- - {costing.materials.length === 0 ? ( -
-
- ) : null} - {costing.sync.state !== 'READY' || latestFailedAfterSuccess ? ( -
-
- ) : null} - {error ? ( -
{error}
- ) : null} - {selectedScenario ? ( <>
- {scenarioKind === 'SAME_MATERIAL' ? '预计出厂成本 · 同材质基准' : `预计出厂成本 · 材质替换:${selectedScenario.targetMaterialName || materialName}`} + 预计出厂成本 {dynamicStatusLabel(selectedScenario.status)} @@ -1423,46 +1115,18 @@ function DynamicCostingPanel({ {scenarioEvidenceText(selectedScenario, structureAnalysis)}
- {selectedScenario.totalCost === null && - selectedScenario.knownCost !== null && - selectedScenario.knownCost > 0 ? ( -

- 已计部分 {formatMoney(selectedScenario.knownCost)}(不是整件成本) -

- ) : null} +

+ 结构拆解用于解释成本构成与后续复核;单项预估待补不会影响已形成的整件预估。 +

- {selectedScenario.competitorPrice !== null ? ( -
-
竞品售价
-
{formatMoney(selectedScenario.competitorPrice)}
-
- ) : null} - {scenarioKind === 'MATERIAL_SUBSTITUTION' ? ( -
-
较同材质
-
0 ? 'text-[#96504c]' : 'text-[#4d6b63]')}> - {formatScenarioDifference(difference)} -
-
- ) : null}
- {marketPriceAlert ? ( -
-
- ) : null}
{categoryRows.map(([label, value, rangeKey]) => (
{label} - {value === null || selectedScenario.totalCost === null ? '核算未完成' : formatEstimatedMoney(value)} - {value !== null && selectedScenario.totalCost !== null ? ( + {value === null ? '预估待补' : formatEstimatedMoney(value)} + {value !== null ? ( 估算区间 {formatEstimateRange(selectedScenario.categoryEstimateRanges?.[rangeKey])} @@ -1491,19 +1155,16 @@ function DynamicCostingPanel({
- {dynamicStatusLabel(line.status, 'line')} + {line.subtotal === null ? '预估待补' : dynamicStatusLabel(line.status)}
-
计价数量 · 系数
+
数量
- {line.pricingQuantity ?? line.quantity ?? '--'} {line.unit || ''} · {line.coefficient ?? '--'} + {line.quantity ?? '--'} {line.unit || ''} {line.confidence !== null ? ` · 置信度 ${Math.round(line.confidence * 100)}%` : ''}
- {line.quantity !== line.pricingQuantity && line.quantity !== null ? ( -
原始数量 {line.quantity} {line.unit || ''}
- ) : null}
行小计
@@ -1512,17 +1173,10 @@ function DynamicCostingPanel({
- {line.estimateBasis === 'AI_REFERENCE_ESTIMATE' - ? AI 参考预估 · 非正式采购价 - : line.unitPrice !== null - ? 公司单价 {formatMoney(line.unitPrice)} / {line.unit || '单位'} - : line.subtotal !== null - ? 按历史分类金额预估 - : 没有有效价格来源} -
{line.priceSource || '待匹配公司历史 BOM'}
- {line.referenceSkus.length > 0 ?
参考 SKU:{line.referenceSkus.slice(0, 3).join('、')}
: null} - {line.warning ?
{line.warning}
: null} - {line.sourceSyncedAt ?
同步 {formatActivityTime(line.sourceSyncedAt)}
: null} + + {line.subtotal === null ? '预估待补' : '已计入整件预估'} + + {line.warning ?
{publicEstimateWarning(line.warning)}
: null}
))} @@ -1534,8 +1188,8 @@ function DynamicCostingPanel({ 分类 部件 / 材质 - 数量 · 系数 - 价格 / 预估依据 + 数量 + 预估金额 状态 行小计 @@ -1553,28 +1207,20 @@ function DynamicCostingPanel({ {[line.originalMaterial, line.appliedMaterial !== line.originalMaterial ? `→ ${line.appliedMaterial}` : '', line.materialSpec] .filter(Boolean).join(' · ') || '材质待确认'}
- {line.warning ?
{line.warning}
: null} + {line.warning ?
{publicEstimateWarning(line.warning)}
: null} - {line.pricingQuantity ?? line.quantity ?? '--'} {line.unit || ''} -
系数 {line.coefficient ?? '--'}
+ {line.quantity ?? '--'} {line.unit || ''} - {line.estimateBasis === 'AI_REFERENCE_ESTIMATE' - ?
AI 参考预估 · 非正式采购价
- : line.unitPrice !== null - ?
{formatMoney(line.unitPrice)} / {line.unit || '单位'}
- : line.subtotal !== null - ?
按历史分类金额预估
- :
没有有效价格来源
} -
{line.priceSource || '待匹配公司历史 BOM'}
- {line.referenceSkus.length > 0 ?
参考 SKU:{line.referenceSkus.slice(0, 3).join('、')}
: null} +
+ {line.subtotal === null ? '预估待补' : formatEstimatedMoney(line.subtotal)} +
+ {line.estimateRange ?
区间 {formatEstimateRange(line.estimateRange)}
: null} - {dynamicStatusLabel(line.status, 'line')} + {line.subtotal === null ? '预估待补' : dynamicStatusLabel(line.status)} {line.confidence !== null ?
置信度 {Math.round(line.confidence * 100)}%
: null} - {line.sourceCreatedAt ?
来源 {formatActivityTime(line.sourceCreatedAt)}
: null} - {line.sourceSyncedAt ?
同步 {formatActivityTime(line.sourceSyncedAt)}
: null} {formatEstimatedMoney(line.subtotal)} @@ -1588,23 +1234,15 @@ function DynamicCostingPanel({
{selectedScenario.unresolvedCount > 0 - ? `${selectedScenario.unresolvedCount} 行没有可验证价格来源` - : aiEstimatedLineCount > 0 - ? `${aiEstimatedLineCount} 行由多模态模型参考公司知识补充预估,整单已形成区间并等待复核` - : selectedScenario.status === 'FINAL' - ? '所有行均有可验证价格来源' - : staleLineCount > 0 - ? `${staleLineCount} 行价格已过期,汇总结果需复核` - : provisionalLineCount > 0 - ? `${provisionalLineCount} 行采用暂估或推导数量,汇总结果需复核` - : '明细均有价格来源,但汇总含暂估判断,需复核主要驱动项'} + ? `${selectedScenario.unresolvedCount} 行预估待补,不影响已形成的整件预估` + : '整件预估已形成,可结合结构明细继续复核。'} - 规则 {selectedScenario.rulesetVersion} · 批次 {selectedScenario.syncRunId || '--'} + 最近核算于 {formatActivityTime(selectedScenario.createdAt)}
) : (
- 选择目标材质并点击“生成双场景”,系统会保存同材质基准和替换材质的完整明细。 + 当前 SKU 暂无可用预估结果,可在“重新核算”中更新。
)} @@ -1618,7 +1256,6 @@ function ResultCanvas({ capabilities, selectedSku, onSelectSku, - onCaseUpdated, onRecalculate, onResumeRecalculation, recalculating, @@ -1627,7 +1264,6 @@ function ResultCanvas({ capabilities: BomCostingCapabilities selectedSku: BomCostingSkuDetail onSelectSku: (skuId: string) => void - onCaseUpdated: (detail: BomCostingCaseDetail) => void onRecalculate: (input: RecalculationRequest) => Promise onResumeRecalculation: () => Promise recalculating: boolean @@ -1665,34 +1301,8 @@ function ResultCanvas({ const [selectedAssetIdBySku, setSelectedAssetIdBySku] = useState< Record >({}) - const [editingPriceLineId, setEditingPriceLineId] = useState( - null, - ) - const [editingLineMode, setEditingLineMode] = useState( - null, - ) - const [manualReviewDraft, setManualReviewDraft] = useState( - null, - ) - const [priceCandidatesByLine, setPriceCandidatesByLine] = useState< - Record - >({}) - const [priceCandidateQuery, setPriceCandidateQuery] = useState('') - const [loadingPriceLineId, setLoadingPriceLineId] = useState( - null, - ) - const [savingPriceLineId, setSavingPriceLineId] = useState( - null, - ) - const [priceEditorError, setPriceEditorError] = useState('') - const [dynamicScenarioKind, setDynamicScenarioKind] = - useState('SAME_MATERIAL') - const [dynamicTargetMaterialId, setDynamicTargetMaterialId] = useState('') - const [dynamicCalculating, setDynamicCalculating] = useState(false) - const [dynamicCalculationError, setDynamicCalculationError] = useState('') const [recalculationOpen, setRecalculationOpen] = useState(false) const [structureEvidenceOpen, setStructureEvidenceOpen] = useState(false) - const [reviewArchiveOpen, setReviewArchiveOpen] = useState(false) const [recalculationMode, setRecalculationMode] = useState('REUSE_FACTS') const [selectedReanalysisAssetIds, setSelectedReanalysisAssetIds] = @@ -1821,64 +1431,13 @@ function ResultCanvas({ const sameMaterialScenario = selectedSkuDynamicScenarios.find( (scenario) => scenario.kind === 'SAME_MATERIAL', ) ?? null - const substitutionScenario = selectedSkuDynamicScenarios.find( - (scenario) => scenario.kind === 'MATERIAL_SUBSTITUTION', - ) ?? null - const selectedDynamicScenario = dynamicScenarioKind === 'SAME_MATERIAL' - ? sameMaterialScenario - : substitutionScenario - const dynamicScenarioDifference = sameMaterialScenario?.totalCost !== null && sameMaterialScenario?.totalCost !== undefined && - substitutionScenario?.totalCost !== null && substitutionScenario?.totalCost !== undefined - ? substitutionScenario.totalCost - sameMaterialScenario.totalCost - : null - const selectedDynamicMarketPriceAlert = scenarioMarketPriceAlert(selectedDynamicScenario) - - useEffect(() => { - const materials = dynamicCosting?.materials ?? [] - const savedTargetId = substitutionScenario?.targetMaterialId ?? '' - setDynamicTargetMaterialId((current) => { - if (current && materials.some((material) => material.id === current)) return current - if (savedTargetId && materials.some((material) => material.id === savedTargetId)) return savedTargetId - return materials[0]?.id ?? '' - }) - setDynamicCalculationError('') - }, [detail.id, dynamicCosting?.materials, selectedSku.id, substitutionScenario?.targetMaterialId]) + const selectedDynamicScenario = sameMaterialScenario useEffect(() => { setRecalculationCategorySkillId(selectedCategorySkillId) setCategorySkillEdited(false) }, [detail.id, selectedCategorySkillId]) - const calculateDynamicCosting = async () => { - if (!dynamicTargetMaterialId || dynamicCalculating) return - setDynamicCalculating(true) - setDynamicCalculationError('') - try { - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/scenarios`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - skuId: selectedSku.id, - targetMaterialId: dynamicTargetMaterialId, - }), - }, - ) - const data = await readApiData<{ - dynamicCosting: BomCostingDynamicCostingDetail - }>(response) - onCaseUpdated({ ...detail, dynamicCosting: data.dynamicCosting }) - setDynamicScenarioKind('MATERIAL_SUBSTITUTION') - } catch (error) { - setDynamicCalculationError( - error instanceof Error ? error.message : '双场景成本生成失败,请重试', - ) - } finally { - setDynamicCalculating(false) - } - } - const setSelectedAssetId = (assetId: string) => { setSelectedAssetIdBySku((current) => ({ ...current, @@ -1911,16 +1470,16 @@ function ResultCanvas({ const failedRecalculationTitle = failedRecalculationRun?.mode === 'REANALYZE_ALL' - ? '上次“全部重新分析”未完成' + ? '上次“全部重新分析”暂未形成预估' : failedRecalculationRun?.mode === 'REANALYZE_SELECTED' - ? '上次“补充分析图片”未完成' - : '上次重新核算未完成' + ? '上次“补充分析图片”暂未形成预估' + : '上次重新核算暂未形成预估' const failedRecalculationResumeLabel = failedRecalculationRun?.mode === 'REANALYZE_ALL' ? '继续全部图片重新分析' : failedRecalculationRun?.mode === 'REANALYZE_SELECTED' ? '继续补充图片分析' - : '继续结构与价格汇总' + : '继续生成预估' const prepareReuseFactsRecalculation = () => { setRecalculationOpen(true) @@ -1990,151 +1549,9 @@ function ResultCanvas({ } const scrollToBom = () => { - const bomLines = document.getElementById('bom-costing-lines') - scrollIntoView(bomLines, { block: 'start' }) - } - - const openPriceEditor = async (line: BomCostingLineDetail) => { - if (editingPriceLineId === line.id && editingLineMode === 'LIBRARY') { - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - setPriceEditorError('') - return - } - setEditingPriceLineId(line.id) - setEditingLineMode('LIBRARY') - setManualReviewDraft(null) - setPriceCandidateQuery('') - setPriceEditorError('') - if (priceCandidatesByLine[line.id]) return - - setLoadingPriceLineId(line.id) - try { - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`, - { cache: 'no-store' }, - ) - const data = await readApiData<{ - candidates: BomCostingPriceCandidateDetail[] - }>(response) - setPriceCandidatesByLine((current) => ({ - ...current, - [line.id]: data.candidates, - })) - } catch (error) { - setPriceEditorError( - error instanceof Error ? error.message : '资料库价格读取失败', - ) - } finally { - setLoadingPriceLineId(null) - } - } - - const openManualReviewEditor = (line: BomCostingLineDetail) => { - if (editingPriceLineId === line.id && editingLineMode === 'MANUAL') { - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - setPriceEditorError('') - return - } - setEditingPriceLineId(line.id) - setEditingLineMode('MANUAL') - setManualReviewDraft(buildManualReviewDraft(line)) - setPriceEditorError('') - } - - const confirmPriceCandidate = async ( - line: BomCostingLineDetail, - candidate: BomCostingPriceCandidateDetail, - ) => { - if (savingPriceLineId) return - setSavingPriceLineId(line.id) - setPriceEditorError('') - try { - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - matchedLibraryRecordId: candidate.recordId, - ...(line.category === 'BOARD' && - line.heightMm === null && - candidate.thicknessMm !== null - ? { heightMm: candidate.thicknessMm } - : {}), - }), - }, - ) - const data = await readApiData<{ case: BomCostingCaseDetail }>(response) - onCaseUpdated(data.case) - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - } catch (error) { - setPriceEditorError( - error instanceof Error ? error.message : '价格确认失败,请重试', - ) - } finally { - setSavingPriceLineId(null) - } - } - - const saveManualReview = async (line: BomCostingLineDetail) => { - if (!manualReviewDraft || savingPriceLineId) return - - const parsePositive = ( - value: string, - label: string, - emptyValue: number | null, - ) => { - const normalized = value.trim() - if (!normalized) return emptyValue - const parsed = Number(normalized) - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`${label}必须是大于 0 的数字`) - } - return parsed - } - - try { - const review = { - materialCode: manualReviewDraft.materialCode.trim(), - materialName: manualReviewDraft.materialName.trim(), - materialSpec: manualReviewDraft.materialSpec.trim(), - lengthMm: parsePositive(manualReviewDraft.lengthMm, '长度', null), - widthMm: parsePositive(manualReviewDraft.widthMm, '宽度', null), - heightMm: parsePositive(manualReviewDraft.heightMm, '厚度/高度', null), - quantity: parsePositive(manualReviewDraft.quantity, '数量', line.quantity), - unitPrice: parsePositive(manualReviewDraft.unitPrice, '单价', null), - priceUnit: manualReviewDraft.priceUnit.trim(), - lossFactor: parsePositive(manualReviewDraft.lossFactor, '损耗系数', 1), - note: manualReviewDraft.note.trim(), - } - setSavingPriceLineId(line.id) - setPriceEditorError('') - const response = await fetch( - `/api/bom-costing/cases/${encodeURIComponent(detail.id)}/lines/${encodeURIComponent(line.id)}`, - { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ review }), - }, - ) - const data = await readApiData<{ case: BomCostingCaseDetail }>(response) - onCaseUpdated(data.case) - setEditingPriceLineId(null) - setEditingLineMode(null) - setManualReviewDraft(null) - } catch (error) { - setPriceEditorError( - error instanceof Error ? error.message : '复核记录保存失败,请重试', - ) - } finally { - setSavingPriceLineId(null) - } + scrollIntoView(document.getElementById('bom-costing-lines'), { + block: 'start', + }) } const hasPendingCost = @@ -2150,10 +1567,10 @@ function ResultCanvas({ : requiresRecalculation ? '旧版结果待重新核算' : selectedDynamicCostState === 'FAILED' - ? '成本生成失败' - : selectedDynamicCostState === 'PENDING' + ? '资料不足,暂无法预估' + : selectedDynamicCostState === 'PENDING' ? '待补成本' - : '核算未完成' + : '资料不足,暂无法预估' const missingCostGroups = new Set( selectedSku.costSummary.missingCostGroups ?? [], ) @@ -2186,14 +1603,14 @@ function ResultCanvas({ ? '当前记录由旧规则生成' : '', selectedSku.costSummary.unresolvedLineCount > 0 - ? `${selectedSku.costSummary.unresolvedLineCount} 项价格待确认` + ? `${selectedSku.costSummary.unresolvedLineCount} 项结构明细待补` : '', missingCostGroupLabels.length > 0 ? `${missingCostGroupLabels.join('、')}尚未识别` : '', ].filter(Boolean).join(',') const approximateCostLineCount = selectedSku.bomLines.filter( - (line) => line.matchStatus === 'NEEDS_CONFIRMATION' && line.subtotal !== null, + (line) => line.estimateStatus === 'ESTIMATED' && line.sourceConfidence !== null && line.sourceConfidence < 0.55, ).length const costSummaryRows = [ { @@ -2257,9 +1674,6 @@ function ResultCanvas({ const lowConfidenceStructureCount = selectedStructureItems.filter( (item) => item.confidence < 0.55 || item.reviewReasons.length > 0, ).length - const currentSkuReviewInputs = (detail.reviewInputs ?? []) - .filter((input) => !input.skuId || input.skuId === selectedSku.id) - .slice(0, 8) const recalculationPanelOpen = recalculationOpen && detail.status === 'READY' @@ -2304,31 +1718,41 @@ function ResultCanvas({ {dynamicCompletionNotice && ( dynamicCompletionNotice.pendingSkuCount > 0 || dynamicCompletionNotice.failedSkuCount > 0 || - dynamicCompletionNotice.reviewSkuCount > 0 + dynamicCompletionNotice.reviewSkuCount > 0 || + dynamicCompletionNotice.qualityWarningCount > 0 ) ? (
0 ? 'alert' : 'status'} aria-label={dynamicCompletionNotice.failedSkuCount > 0 - ? '原材质批次部分完成,包含成本生成失败' - : '原材质批次部分完成'} + ? '本轮批次部分完成,部分 SKU 暂无法预估' + : dynamicCompletionNotice.qualityWarningCount > 0 + ? '本轮预估已完成,结构拆解待复核' + : '本轮批次部分完成'} data-testid="bom-dynamic-cost-partial-notice" >