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 07c841d0..f47a0449 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1401,6 +1401,7 @@ model BomCostingScenario { 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 a69d1532..0c3010b7 100644 --- a/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx +++ b/furniture-3d-system/src/app/bom-costing/BomCostingWorkbenchClient.test.tsx @@ -1576,30 +1576,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( - '这是原材质批次结果,不代表当前材质替换方案已经完成', + '其余 SKU 可按现有事实重新核算', ) 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 待补成本:结构事实已保存,但公司价格资料尚未闭合,补齐后再生成完整预估。', + '本轮该 SKU 的结构事实已保存;待补信息完善后可继续形成完整预估。', )).toBeInTheDocument() expect(screen.queryByText('SKU 结构已就绪')).not.toBeInTheDocument() expect(screen.queryByText('约 ¥888.00')).not.toBeInTheDocument() @@ -1675,7 +1675,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 +1721,7 @@ describe('BomCostingWorkbenchClient', () => { } }) - it('未匹配材料不显示零元,并可在 BOM 行确认资料库价格', async () => { + it('未匹配结构项不显示零元,并保留中性的预估待补状态', async () => { const originalFetch = global.fetch const now = new Date().toISOString() const unresolvedLine = { @@ -1803,69 +1803,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 +1845,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: { @@ -2182,14 +2057,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', @@ -2460,13 +2338,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) @@ -2476,31 +2352,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, @@ -2526,42 +2379,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('实时采购预估 · 1688 同款').length).toBeGreaterThan(0) - expect(screen.getAllByText(/1688 同款价 ¥1,500\.00 × 90%/).length).toBeGreaterThan(0) - expect(screen.getAllByText(/结构拆解参考 ¥1,350\.00/).length).toBeGreaterThan(0) - 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 } @@ -2752,10 +2585,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 ff0c589d..c01e418f 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', }, { @@ -339,16 +282,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: '多模态模型参考预估', - REALTIME_1688_MARKET: '1688 实时同款采购预估', -} as const - const STRUCTURE_EVIDENCE_BASIS_LABELS = { IMAGE: '图片明确证据', SOURCE_TEXT: '商品文字', @@ -356,114 +289,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 realtimeQuote = scenario.realtimeMarketQuote?.status === 'AVAILABLE' && - scenario.realtimeMarketQuote.procurementCost !== null - ? scenario.realtimeMarketQuote - : null - 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 [ - realtimeQuote - ? `1688 同款实时价 ${formatMoney(realtimeQuote.marketPrice)} × ${(realtimeQuote.procurementRate * 100).toFixed(0)}%` - : '', `估算区间 ${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 realtimeMarketQuoteForScenario(scenario: BomCostingScenarioDetail | null | undefined) { - const quote = scenario?.realtimeMarketQuote - return quote?.status === 'AVAILABLE' && quote.procurementCost !== null - ? quote - : null -} - -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({ @@ -690,7 +539,7 @@ function parseDynamicCostingCompletionNotice( return [{ skuId: readPayloadText(item.skuId) || null, externalSkuId: readPayloadText(item.externalSkuId) || null, - message: readPayloadText(item.message) || '成本预估生成失败', + message: '资料不足,暂无法预估', }] }) : [] @@ -789,8 +638,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, @@ -820,22 +667,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], @@ -893,16 +724,6 @@ function ProcessingCanvas({

{detail.title}

- - - {detail.sourceCacheHit ? ( 已复用资料 @@ -1165,29 +986,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 = { @@ -1214,66 +1026,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 realtimeMarketQuote = scenarioKind === 'SAME_MATERIAL' - ? realtimeMarketQuoteForScenario(selectedScenario) - : null - 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'], @@ -1283,8 +1047,7 @@ function DynamicCostingPanel({ ['损耗', selectedScenario.lossCost, 'LOSS'], ] : [] - const contributionBase = realtimeMarketQuote?.structuralCost ?? - selectedScenario?.totalCost ?? selectedScenario?.knownCost ?? 0 + const contributionBase = selectedScenario?.totalCost ?? 0 const contributionLines = (selectedScenario?.lines ?? []) .map((line, index) => ({ line, index })) .sort((left, right) => ( @@ -1305,7 +1068,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} >
@@ -1313,124 +1076,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 ? ( <>
- {realtimeMarketQuote - ? '实时采购预估 · 1688 同款' - : scenarioKind === 'SAME_MATERIAL' - ? '预计出厂成本 · 同材质基准' - : `预计出厂成本 · 材质替换:${selectedScenario.targetMaterialName || materialName}`} + 预计出厂成本 {dynamicStatusLabel(selectedScenario.status)} @@ -1446,60 +1113,17 @@ function DynamicCostingPanel({ {scenarioEvidenceText(selectedScenario, structureAnalysis)}
- {selectedScenario.totalCost === null && - selectedScenario.knownCost !== null && - selectedScenario.knownCost > 0 ? ( -

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

- ) : null} - {realtimeMarketQuote ? ( -

- 1688 同款实时价 {formatMoney(realtimeMarketQuote.marketPrice)} × {(realtimeMarketQuote.procurementRate * 100).toFixed(0)}% - {realtimeMarketQuote.comparableOfferCount > 0 ? ` · ${realtimeMarketQuote.comparableOfferCount} 条可比同款` : ''} - {realtimeMarketQuote.searchedAt ? ` · 查询于 ${formatActivityTime(realtimeMarketQuote.searchedAt)}` : ''}。 - 结构拆解 {realtimeMarketQuote.structuralCost !== null && realtimeMarketQuote.structuralCost !== undefined - ? `参考 ${formatMoney(realtimeMarketQuote.structuralCost)}` - : '仍在补全'},不会因单项待补价否定本次整件预估。 -

- ) : null} +

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

- {selectedScenario.competitorPrice !== null ? ( -
-
竞品售价
-
{formatMoney(selectedScenario.competitorPrice)}
-
- ) : null} - {scenarioKind === 'MATERIAL_SUBSTITUTION' ? ( -
-
较同材质
-
0 ? 'text-[#96504c]' : 'text-[#4d6b63]')}> - {formatScenarioDifference(difference)} -
-
- ) : null}
- {marketPriceAlert ? ( -
-
- ) : null} - {realtimeMarketQuote ? ( -
- 上方为实时采购预估;以下是结构拆解参考。某个部件暂缺价格时,只标记为待补,不会把整件预估改成失败。 -
- ) : null}
{categoryRows.map(([label, value, rangeKey]) => (
{label} - {value === null ? realtimeMarketQuote ? '待补结构价' : '核算未完成' : formatEstimatedMoney(value)} + {value === null ? '预估待补' : formatEstimatedMoney(value)} {value !== null ? ( 估算区间 {formatEstimateRange(selectedScenario.categoryEstimateRanges?.[rangeKey])} @@ -1529,19 +1153,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}
行小计
@@ -1550,17 +1171,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}
))} @@ -1572,8 +1186,8 @@ function DynamicCostingPanel({ 分类 部件 / 材质 - 数量 · 系数 - 价格 / 预估依据 + 数量 + 预估金额 状态 行小计 @@ -1591,28 +1205,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)} @@ -1626,23 +1232,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 暂无可用预估结果,可在“重新核算”中更新。
)} @@ -1656,7 +1254,6 @@ function ResultCanvas({ capabilities, selectedSku, onSelectSku, - onCaseUpdated, onRecalculate, onResumeRecalculation, recalculating, @@ -1665,7 +1262,6 @@ function ResultCanvas({ capabilities: BomCostingCapabilities selectedSku: BomCostingSkuDetail onSelectSku: (skuId: string) => void - onCaseUpdated: (detail: BomCostingCaseDetail) => void onRecalculate: (input: RecalculationRequest) => Promise onResumeRecalculation: () => Promise recalculating: boolean @@ -1703,34 +1299,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] = @@ -1859,67 +1429,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 selectedRealtimeMarketQuote = dynamicScenarioKind === 'SAME_MATERIAL' - ? realtimeMarketQuoteForScenario(selectedDynamicScenario) - : null - 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, @@ -1952,16 +1468,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) @@ -2031,151 +1547,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 = @@ -2191,10 +1565,10 @@ function ResultCanvas({ : requiresRecalculation ? '旧版结果待重新核算' : selectedDynamicCostState === 'FAILED' - ? '成本生成失败' - : selectedDynamicCostState === 'PENDING' + ? '资料不足,暂无法预估' + : selectedDynamicCostState === 'PENDING' ? '待补成本' - : '核算未完成' + : '资料不足,暂无法预估' const missingCostGroups = new Set( selectedSku.costSummary.missingCostGroups ?? [], ) @@ -2227,14 +1601,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 = [ { @@ -2298,9 +1672,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' @@ -2351,25 +1722,25 @@ function ResultCanvas({ className="shrink-0 border-b border-[#b98743]/25 bg-[#fbf8f0]" role={dynamicCompletionNotice.failedSkuCount > 0 ? 'alert' : 'status'} aria-label={dynamicCompletionNotice.failedSkuCount > 0 - ? '原材质批次部分完成,包含成本生成失败' - : '原材质批次部分完成'} + ? '本轮批次部分完成,部分 SKU 暂无法预估' + : '本轮批次部分完成'} data-testid="bom-dynamic-cost-partial-notice" >