From 9a1e588af3202addcbfabc2ab1ec3a025999a053 Mon Sep 17 00:00:00 2001 From: jingchuan Date: Mon, 10 Aug 2026 15:29:39 +0800 Subject: [PATCH 1/5] fix(oa-board): terminate withdrawn XP2026070100000242 --- furniture-3d-system/src/server/services/oa-board.constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/furniture-3d-system/src/server/services/oa-board.constants.ts b/furniture-3d-system/src/server/services/oa-board.constants.ts index 905903df..6694d2a0 100644 --- a/furniture-3d-system/src/server/services/oa-board.constants.ts +++ b/furniture-3d-system/src/server/services/oa-board.constants.ts @@ -625,4 +625,5 @@ export const OA_BOARD_MANUAL_OVERALL_STATUS_OVERRIDES = { XP2026072300000345: 'TERMINATED', XP2026070300000255: 'TERMINATED', XP2026070400000261: 'TERMINATED', + XP2026070100000242: 'TERMINATED', } as const From f55bddb017e47cd043629a7f351958a365e54c79 Mon Sep 17 00:00:00 2001 From: jingchuan Date: Mon, 10 Aug 2026 16:18:35 +0800 Subject: [PATCH 2/5] fix(oa-board): honor manual termination overrides --- furniture-3d-system/src/server/services/oa-board.constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/furniture-3d-system/src/server/services/oa-board.constants.ts b/furniture-3d-system/src/server/services/oa-board.constants.ts index 6694d2a0..d7356bd6 100644 --- a/furniture-3d-system/src/server/services/oa-board.constants.ts +++ b/furniture-3d-system/src/server/services/oa-board.constants.ts @@ -626,4 +626,6 @@ export const OA_BOARD_MANUAL_OVERALL_STATUS_OVERRIDES = { XP2026070300000255: 'TERMINATED', XP2026070400000261: 'TERMINATED', XP2026070100000242: 'TERMINATED', + XP2026071700000329: 'TERMINATED', + XP2026071800000332: 'TERMINATED', } as const From 127730c5cf87817a82eb77bb15cf3008b768e9ff Mon Sep 17 00:00:00 2001 From: jingchuan Date: Mon, 10 Aug 2026 17:26:47 +0800 Subject: [PATCH 3/5] fix(oa-board): add manual termination overrides --- furniture-3d-system/src/server/services/oa-board.constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/furniture-3d-system/src/server/services/oa-board.constants.ts b/furniture-3d-system/src/server/services/oa-board.constants.ts index d7356bd6..1106becd 100644 --- a/furniture-3d-system/src/server/services/oa-board.constants.ts +++ b/furniture-3d-system/src/server/services/oa-board.constants.ts @@ -628,4 +628,6 @@ export const OA_BOARD_MANUAL_OVERALL_STATUS_OVERRIDES = { XP2026070100000242: 'TERMINATED', XP2026071700000329: 'TERMINATED', XP2026071800000332: 'TERMINATED', + XP2026080100000354: 'TERMINATED', + XP2026080300000367: 'TERMINATED', } as const From fe48bbb181f0c68a690cab9e461f8f7d9dbf8566 Mon Sep 17 00:00:00 2001 From: jingchuan Date: Mon, 10 Aug 2026 18:23:51 +0800 Subject: [PATCH 4/5] feat(oa-board): add private daily report --- .../src/app/api/intake/oa-board/route.ts | 61 +++--- .../src/app/intake/oa-board/OaBoardClient.tsx | 176 +++++++++++++++++- .../src/app/intake/oa-board/page.tsx | 61 +++--- .../__tests__/oa-board.service.test.ts | 98 ++++++++++ .../services/oa-board-daily-report-access.ts | 9 + .../src/server/services/oa-board.service.ts | 137 +++++++++++++- 6 files changed, 481 insertions(+), 61 deletions(-) create mode 100644 furniture-3d-system/src/server/services/oa-board-daily-report-access.ts diff --git a/furniture-3d-system/src/app/api/intake/oa-board/route.ts b/furniture-3d-system/src/app/api/intake/oa-board/route.ts index 91ed586d..8b097a38 100644 --- a/furniture-3d-system/src/app/api/intake/oa-board/route.ts +++ b/furniture-3d-system/src/app/api/intake/oa-board/route.ts @@ -1,5 +1,6 @@ import { NextRequest } from 'next/server' import { handleRequest, jsonSuccess } from '@/server/core/response' +import { canViewOaBoardDailyReport } from '@/server/services/oa-board-daily-report-access' import { createEmptyOaBoardDashboard, oaBoardService } from '@/server/services/oa-board.service' export const dynamic = 'force-dynamic' @@ -31,34 +32,48 @@ export async function GET(request: NextRequest) { const nodeName = searchParams.get('nodeName') ?? '' const timeoutOnly = searchParams.get('timeoutOnly') === '1' const timeoutStatus = searchParams.get('timeoutStatus') + const reportDate = searchParams.get('reportDate') ?? '' + const canViewDailyReport = canViewOaBoardDailyReport(ctx.user) try { - const dashboard = await oaBoardService.getDashboard({ - keyword: keyword || undefined, - approvalNumber: approvalNumber || undefined, - overallStatus: overallStatus.length > 0 ? overallStatus : undefined, - createdDateStart: createdDateStart || undefined, - createdDateEnd: createdDateEnd || undefined, - productType: productType || undefined, - approvalStage: approvalStage || undefined, - developmentPlan: developmentPlan || undefined, - productPlanner: productPlanner || undefined, - structureDesigner: structureDesigner || undefined, - appearanceDesigner: appearanceDesigner || undefined, - purchaseOwner: purchaseOwner || undefined, - spuOwner: spuOwner || undefined, - owner: owner || undefined, - nodeName: nodeName || undefined, - timeoutOnly, - timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined, - }) + const dashboard = await oaBoardService.getDashboard( + { + keyword: keyword || undefined, + approvalNumber: approvalNumber || undefined, + overallStatus: overallStatus.length > 0 ? overallStatus : undefined, + createdDateStart: createdDateStart || undefined, + createdDateEnd: createdDateEnd || undefined, + productType: productType || undefined, + approvalStage: approvalStage || undefined, + developmentPlan: developmentPlan || undefined, + productPlanner: productPlanner || undefined, + structureDesigner: structureDesigner || undefined, + appearanceDesigner: appearanceDesigner || undefined, + purchaseOwner: purchaseOwner || undefined, + spuOwner: spuOwner || undefined, + owner: owner || undefined, + nodeName: nodeName || undefined, + timeoutOnly, + timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined, + }, + canViewDailyReport ? reportDate || undefined : undefined, + ) - return jsonSuccess(dashboard, 200, ctx.requestId) - } catch (error) { - console.error('Failed to load OA board dashboard', error) return jsonSuccess( { - ...createEmptyOaBoardDashboard(), + ...dashboard, + dailyReport: canViewDailyReport ? dashboard.dailyReport : undefined, + }, + 200, + ctx.requestId, + ) + } catch (error) { + console.error('Failed to load OA board dashboard', error) + const emptyDashboard = createEmptyOaBoardDashboard() + return jsonSuccess( + { + ...emptyDashboard, + dailyReport: canViewDailyReport ? emptyDashboard.dailyReport : undefined, notice: '当前看板表尚未完成迁移或同步,先返回空列表占位。', }, 200, diff --git a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx index 3fba8728..98df292e 100644 --- a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx +++ b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx @@ -4,8 +4,10 @@ import { useEffect, useMemo, useRef, useState, useTransition } from 'react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { AlertTriangle, + CalendarDays, ChevronDown, CheckCircle2, + ClipboardCheck, Clock3, Download, ExternalLink, @@ -27,6 +29,7 @@ import { import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants' import type { OaBoardDashboardDTO, + OaBoardDailyReportDTO, OaBoardProjectListItemDTO, OaBoardStageGraphDTO, OaBoardStageGraphNodeDTO, @@ -59,7 +62,14 @@ type FilterOption = { label: string } -type OaBoardViewTab = 'summary' | 'detail' | 'brand-planning' | 'timeout-analysis' | 'shipment' | 'listing' +type OaBoardViewTab = + | 'summary' + | 'daily' + | 'detail' + | 'brand-planning' + | 'timeout-analysis' + | 'shipment' + | 'listing' type BrandPlanningFilterState = { keyword: string @@ -165,6 +175,7 @@ const DEFAULT_STATUS_SELECT_VALUE = '__default__' function parseOaBoardViewTab(value: string | null): OaBoardViewTab { if ( + value === 'daily' || value === 'detail' || value === 'brand-planning' || value === 'timeout-analysis' || @@ -2711,6 +2722,126 @@ function BrandPlanningBoardPanel() { ) } +function DailyReportPanel({ + report, + onReportDateChange, +}: { + report: OaBoardDailyReportDTO + onReportDateChange: (value: string) => void +}) { + const completionMetrics = [ + { + label: '新增新品流程', + value: report.newProjectCount, + detail: '按立项评审创建日期', + icon: CalendarDays, + accentClass: 'text-sky-700', + }, + { + label: '完成新品流程', + value: report.completedProjectCount, + detail: '按总流程最终完成日期', + icon: CheckCircle2, + accentClass: 'text-emerald-700', + }, + { + label: '首单下单完成', + value: report.firstOrderCompletedCount, + detail: '按首单子流程结束日期', + icon: ClipboardCheck, + accentClass: 'text-amber-700', + }, + { + label: '美术设计完成', + value: report.artCompletedCount, + detail: '按美术设计申请结束日期', + icon: Sparkles, + accentClass: 'text-rose-700', + }, + { + label: '限价流程完成', + value: report.pricingCompletedCount, + detail: '按新品限价流程结束日期', + icon: CheckCircle2, + accentClass: 'text-violet-700', + }, + ] + + return ( +
+
+
+
+

+ 流程日报 +

+

按 OA 流程创建和结束时间统计

+
+ +
+
+ {completionMetrics.map((metric) => { + const Icon = metric.icon + return ( +
+
+

{metric.label}

+ +
+
+

+ {metric.value} +

+

{metric.detail}

+
+
+ ) + })} +
+
+ +
+
+
+

当前在途流程

+

截至本次 OA 数据同步仍在推进

+
+
+

+ {report.currentInProgressCount} +

+

条流程

+
+
+
+
+

当前总流程超时

+

仅按总实际时效与总目标时效判断

+
+
+

+ {report.currentOverallOvertimeCount} +

+

条流程

+
+
+
+
+ ) +} + export default function OaBoardClient({ dashboard, loadErrorMessage, @@ -2718,7 +2849,8 @@ export default function OaBoardClient({ dashboard: OaBoardDashboardDTO loadErrorMessage?: string | null }) { - const { items: allItems, stageGraph } = dashboard + const { items: allItems, stageGraph, dailyReport } = dashboard + const canViewDailyReport = Boolean(dailyReport) const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() @@ -2775,13 +2907,17 @@ export default function OaBoardClient({ [filterSearchParamsKey], ) const [filters, setFilters] = useState(appliedFilters) - const [activeTab, setActiveTab] = useState(() => - parseOaBoardViewTab(searchParams.get('tab')), - ) + const [activeTab, setActiveTab] = useState(() => { + const requestedTab = parseOaBoardViewTab(searchParams.get('tab')) + return requestedTab === 'daily' && !canViewDailyReport ? 'summary' : requestedTab + }) + const showingDailyReport = canViewDailyReport && activeTab === 'daily' const activeTabLabel = activeTab === 'summary' ? '新品进度汇总' - : activeTab === 'detail' + : showingDailyReport + ? '新品流程日报' + : activeTab === 'detail' ? '新品流程明细' : activeTab === 'brand-planning' ? '新品品牌策划看板' @@ -2801,6 +2937,15 @@ export default function OaBoardClient({ disabled: false, onClick: () => switchTab('summary'), }, + ...(canViewDailyReport + ? [{ + key: 'daily' as const, + label: '新品流程日报', + active: showingDailyReport, + disabled: false, + onClick: () => switchTab('daily'), + }] + : []), { key: 'detail', label: '新品流程明细', @@ -2847,6 +2992,7 @@ export default function OaBoardClient({ } const switchTab = (nextTab: OaBoardViewTab) => { + if (nextTab === 'daily' && !canViewDailyReport) return if (nextTab === activeTab) return const params = new URLSearchParams(searchParams.toString()) @@ -2861,6 +3007,16 @@ export default function OaBoardClient({ window.history.replaceState(window.history.state, '', query ? `${pathname}?${query}` : pathname) } + const changeReportDate = (nextDate: string) => { + if (!dailyReport || !nextDate || nextDate === dailyReport.reportDate) return + + const params = preserveActiveTab(new URLSearchParams(searchParams.toString())) + params.set('reportDate', nextDate) + startTransition(() => { + router.replace(`${pathname}?${params.toString()}`) + }) + } + useEffect(() => { setFilters(appliedFilters) setCurrentPage(1) @@ -3287,7 +3443,7 @@ export default function OaBoardClient({ <>
{activeTab === 'summary' ? `${overviewItems.length} 条流程` + : showingDailyReport && dailyReport + ? `${dailyReport.reportDate} 日报` : activeTab === 'brand-planning' ? 'SKU 维度' : `${items.length} 条命中`} - {activeTab === 'summary' ? ( + {showingDailyReport && dailyReport ? ( + + ) : activeTab === 'summary' ? ( <>
diff --git a/furniture-3d-system/src/app/intake/oa-board/page.tsx b/furniture-3d-system/src/app/intake/oa-board/page.tsx index 338429ce..630f9626 100644 --- a/furniture-3d-system/src/app/intake/oa-board/page.tsx +++ b/furniture-3d-system/src/app/intake/oa-board/page.tsx @@ -5,6 +5,7 @@ import { oaBoardService, } from '@/server/services/oa-board.service' import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants' +import { canViewOaBoardDailyReport } from '@/server/services/oa-board-daily-report-access' import { readOaBoardLocalPreviewDashboard } from '@/server/services/oa-board-local-preview.service' import OaBoardClient from './OaBoardClient' @@ -16,10 +17,8 @@ export default async function OaBoardPage({ searchParams?: Promise> }) { const allowLocalPreview = process.env.OA_BOARD_LOCAL_PREVIEW === '1' - - if (!allowLocalPreview) { - await requireSession('/login') - } + const session = allowLocalPreview ? null : await requireSession('/login') + const canViewDailyReport = canViewOaBoardDailyReport(session?.authUser) const params = searchParams ? await searchParams : {} const readSingleParam = (value: string | string[] | undefined) => @@ -47,30 +46,34 @@ export default async function OaBoardPage({ const nodeName = readSingleParam(params.nodeName) const timeoutOnly = params.timeoutOnly === '1' const timeoutStatus = readSingleParam(params.timeoutStatus) + const reportDate = readSingleParam(params.reportDate) const loadLiveDashboard = () => - oaBoardService.getDashboard({ - keyword: keyword || undefined, - approvalNumber: approvalNumber || undefined, - overallStatus: - overallStatus.length > 0 - ? overallStatus - : [...OA_BOARD_DEFAULT_TRACKED_STATUSES], - createdDateStart: createdDateStart || undefined, - createdDateEnd: createdDateEnd || undefined, - productType: productType || undefined, - approvalStage: approvalStage || undefined, - developmentPlan: developmentPlan || undefined, - productPlanner: productPlanner || undefined, - structureDesigner: structureDesigner || undefined, - appearanceDesigner: appearanceDesigner || undefined, - purchaseOwner: purchaseOwner || undefined, - spuOwner: spuOwner || undefined, - owner: owner || undefined, - nodeName: nodeName || undefined, - timeoutOnly, - timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined, - }) + oaBoardService.getDashboard( + { + keyword: keyword || undefined, + approvalNumber: approvalNumber || undefined, + overallStatus: + overallStatus.length > 0 + ? overallStatus + : [...OA_BOARD_DEFAULT_TRACKED_STATUSES], + createdDateStart: createdDateStart || undefined, + createdDateEnd: createdDateEnd || undefined, + productType: productType || undefined, + approvalStage: approvalStage || undefined, + developmentPlan: developmentPlan || undefined, + productPlanner: productPlanner || undefined, + structureDesigner: structureDesigner || undefined, + appearanceDesigner: appearanceDesigner || undefined, + purchaseOwner: purchaseOwner || undefined, + spuOwner: spuOwner || undefined, + owner: owner || undefined, + nodeName: nodeName || undefined, + timeoutOnly, + timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined, + }, + canViewDailyReport ? reportDate || undefined : undefined, + ) const resolveDashboard = () => loadLiveDashboard() @@ -116,12 +119,16 @@ export default async function OaBoardPage({ nodeName, timeoutOnly, timeoutStatus, + reportDate: canViewDailyReport ? reportDate : '', }) + const dashboard = canViewDailyReport + ? result.dashboard + : { ...result.dashboard, dailyReport: undefined } return ( ) diff --git a/furniture-3d-system/src/server/services/__tests__/oa-board.service.test.ts b/furniture-3d-system/src/server/services/__tests__/oa-board.service.test.ts index cfa5ee4d..bb8b4a1f 100644 --- a/furniture-3d-system/src/server/services/__tests__/oa-board.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/oa-board.service.test.ts @@ -1,7 +1,9 @@ import { + buildOaBoardDailyReport, hasOaBoardProjectOvertime, type OaBoardProjectListItemDTO, } from '../oa-board.service' +import { canViewOaBoardDailyReport } from '../oa-board-daily-report-access' function buildItem({ overallTimeoutFlag = false, @@ -47,3 +49,99 @@ describe('OA 看板总超时口径', () => { expect(hasOaBoardProjectOvertime(buildItem({ overallTimeoutFlag: true }))).toBe(true) }) }) + +describe('OA 看板流程日报', () => { + it('按主流程和各子流程的实际事件时间分别统计', () => { + const report = buildOaBoardDailyReport( + [ + { + boardKey: 'OPENING:XP001', + overallStatus: 'COMPLETED', + overallTimeoutFlag: false, + flowChips: [ + { + flowType: 'review', + processInstanceId: 'review-001', + status: 'COMPLETED', + result: 'AGREE', + createdAtSource: '2026-08-06T01:00:00.000Z', + finishedAtSource: '2026-08-06T02:00:00.000Z', + }, + { + flowType: 'order', + processInstanceId: 'order-001', + status: 'COMPLETED', + result: 'AGREE', + createdAtSource: '2026-08-06T02:00:00.000Z', + finishedAtSource: '2026-08-06T04:00:00.000Z', + }, + { + flowType: 'art', + processInstanceId: 'art-001', + status: 'COMPLETED', + result: 'AGREE', + createdAtSource: '2026-08-06T02:00:00.000Z', + finishedAtSource: '2026-08-06T05:00:00.000Z', + }, + { + flowType: 'pricing', + processInstanceId: 'pricing-001', + status: 'COMPLETED', + result: 'AGREE', + createdAtSource: '2026-08-06T05:00:00.000Z', + finishedAtSource: '2026-08-06T06:00:00.000Z', + }, + ], + }, + { + boardKey: 'OPENING:XP002', + overallStatus: 'RUNNING', + overallTimeoutFlag: true, + flowChips: [ + { + flowType: 'review', + processInstanceId: 'review-002', + status: 'RUNNING', + result: null, + createdAtSource: '2026-08-06T07:00:00.000Z', + finishedAtSource: null, + }, + ], + }, + { + boardKey: 'OPENING:XP003', + overallStatus: 'TERMINATED', + overallTimeoutFlag: false, + flowChips: [ + { + flowType: 'order', + processInstanceId: 'order-003', + status: 'COMPLETED', + result: 'REFUSE', + createdAtSource: '2026-08-06T01:00:00.000Z', + finishedAtSource: '2026-08-06T03:00:00.000Z', + }, + ], + }, + ] as OaBoardProjectListItemDTO[], + '2026-08-06', + ) + + expect(report).toMatchObject({ + reportDate: '2026-08-06', + newProjectCount: 2, + completedProjectCount: 1, + firstOrderCompletedCount: 1, + artCompletedCount: 1, + pricingCompletedCount: 1, + currentInProgressCount: 1, + currentOverallOvertimeCount: 1, + }) + }) + + it('仅授权荆川查看日报', () => { + expect(canViewOaBoardDailyReport({ name: '荆川' })).toBe(true) + expect(canViewOaBoardDailyReport({ name: '荆川 ' })).toBe(true) + expect(canViewOaBoardDailyReport({ name: '其他用户' })).toBe(false) + }) +}) diff --git a/furniture-3d-system/src/server/services/oa-board-daily-report-access.ts b/furniture-3d-system/src/server/services/oa-board-daily-report-access.ts new file mode 100644 index 00000000..9a1b4caf --- /dev/null +++ b/furniture-3d-system/src/server/services/oa-board-daily-report-access.ts @@ -0,0 +1,9 @@ +import 'server-only' + +import type { AuthUser } from '@/server/core/types' + +const OA_BOARD_DAILY_REPORT_VIEWER_NAMES = new Set(['荆川']) + +export function canViewOaBoardDailyReport(user: Pick | null | undefined) { + return OA_BOARD_DAILY_REPORT_VIEWER_NAMES.has(String(user?.name ?? '').trim()) +} diff --git a/furniture-3d-system/src/server/services/oa-board.service.ts b/furniture-3d-system/src/server/services/oa-board.service.ts index 2258f90d..0c010155 100644 --- a/furniture-3d-system/src/server/services/oa-board.service.ts +++ b/furniture-3d-system/src/server/services/oa-board.service.ts @@ -163,6 +163,17 @@ export interface OaBoardSummaryDTO { productTypes: string[] } +export interface OaBoardDailyReportDTO { + reportDate: string + newProjectCount: number + completedProjectCount: number + firstOrderCompletedCount: number + artCompletedCount: number + pricingCompletedCount: number + currentInProgressCount: number + currentOverallOvertimeCount: number +} + export interface OaBoardStageGraphFlowCountDTO { flowType: string flowLabel: string @@ -211,6 +222,117 @@ function parseDateSortValue(value: string | null | undefined) { return Number.isFinite(dateValue) ? dateValue : 0 } +function getShanghaiDateKey(value: Date | string) { + const date = typeof value === 'string' ? new Date(value) : value + if (!Number.isFinite(date.getTime())) return null + + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date) + const partValue = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? '' + const year = partValue('year') + const month = partValue('month') + const day = partValue('day') + + return year && month && day ? `${year}-${month}-${day}` : null +} + +function isValidDateKey(value: string) { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (!match) return false + + const [, yearText, monthText, dayText] = match + const year = Number(yearText) + const month = Number(monthText) + const day = Number(dayText) + const date = new Date(Date.UTC(year, month - 1, day)) + + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ) +} + +export function normalizeOaBoardDailyReportDate(value?: string, referenceDate = new Date()) { + const normalized = String(value ?? '').trim() + if (isValidDateKey(normalized)) return normalized + return getShanghaiDateKey(referenceDate) ?? '1970-01-01' +} + +function isSuccessfulCompletedFlow(flow: OaBoardFlowChipDTO) { + const status = flow.status.trim().toUpperCase() + const result = String(flow.result ?? '').trim().toUpperCase() + return status === 'COMPLETED' && result !== 'REFUSE' && result !== 'REFUSED' +} + +function latestFinishedDateKey(item: OaBoardProjectListItemDTO) { + const timestamps = item.flowChips + .map((flow) => (flow.finishedAtSource ? new Date(flow.finishedAtSource).getTime() : Number.NaN)) + .filter((value) => Number.isFinite(value)) + if (timestamps.length === 0) return null + + return getShanghaiDateKey(new Date(Math.max(...timestamps))) +} + +function countCompletedSubflows( + items: OaBoardProjectListItemDTO[], + reportDate: string, + flowType: string, +) { + const completedInstances = new Set() + + items.forEach((item) => { + item.flowChips.forEach((flow) => { + if ( + flow.flowType === flowType && + isSuccessfulCompletedFlow(flow) && + flow.finishedAtSource && + getShanghaiDateKey(flow.finishedAtSource) === reportDate + ) { + completedInstances.add(flow.processInstanceId || `${item.boardKey}:${flowType}`) + } + }) + }) + + return completedInstances.size +} + +export function buildOaBoardDailyReport( + items: OaBoardProjectListItemDTO[], + requestedDate?: string, +): OaBoardDailyReportDTO { + const reportDate = normalizeOaBoardDailyReportDate(requestedDate) + const newProjects = new Set() + const completedProjects = new Set() + + items.forEach((item) => { + const reviewFlow = item.flowChips.find((flow) => flow.flowType === 'review') + if (reviewFlow?.createdAtSource && getShanghaiDateKey(reviewFlow.createdAtSource) === reportDate) { + newProjects.add(item.boardKey) + } + + if (item.overallStatus === 'COMPLETED' && latestFinishedDateKey(item) === reportDate) { + completedProjects.add(item.boardKey) + } + }) + + return { + reportDate, + newProjectCount: newProjects.size, + completedProjectCount: completedProjects.size, + firstOrderCompletedCount: countCompletedSubflows(items, reportDate, 'order'), + artCompletedCount: countCompletedSubflows(items, reportDate, 'art'), + pricingCompletedCount: countCompletedSubflows(items, reportDate, 'pricing'), + currentInProgressCount: items.filter((item) => item.overallStatus === 'RUNNING').length, + currentOverallOvertimeCount: items.filter(hasOaBoardProjectOvertime).length, + } +} + function splitApprovalStages(value: string | null | undefined) { return String(value ?? '') .split(';') @@ -309,6 +431,7 @@ export interface OaBoardStageMatrixRowDTO { export interface OaBoardDashboardDTO { items: OaBoardProjectListItemDTO[] summary: OaBoardSummaryDTO + dailyReport?: OaBoardDailyReportDTO stageMatrix: OaBoardStageMatrixRowDTO[] stageGraph: OaBoardStageGraphDTO } @@ -406,8 +529,10 @@ export interface OaBoardProjectGraphInput { } export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoardDashboardDTO { + const items: OaBoardProjectListItemDTO[] = [] + return { - items: [], + items, summary: { totalProjects: 0, timeoutProjects: 0, @@ -415,8 +540,9 @@ export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoar completedProjects: 0, productTypes, }, + dailyReport: buildOaBoardDailyReport(items), stageMatrix: buildStageMatrixRows(), - stageGraph: buildStageGraph([]), + stageGraph: buildStageGraph(items), } } @@ -718,6 +844,7 @@ export function createMockOaBoardDashboard(): OaBoardDashboardDTO { completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length, productTypes, }, + dailyReport: buildOaBoardDailyReport(items), stageMatrix: buildStageMatrixRows(), stageGraph: buildStageGraph(items), } @@ -1228,7 +1355,10 @@ export function filterOaBoardProjectItems( } export class OaBoardService { - async getDashboard(filter: OaBoardProjectListFilter = {}): Promise { + async getDashboard( + filter: OaBoardProjectListFilter = {}, + requestedReportDate?: string, + ): Promise { const snapshotMaxAgeMinutesRaw = process.env.OA_BOARD_SNAPSHOT_MAX_AGE_MINUTES?.trim() const snapshotMaxAgeMinutes = snapshotMaxAgeMinutesRaw ? Number(snapshotMaxAgeMinutesRaw) : 60 const snapshotMaxAgeMs = @@ -1291,6 +1421,7 @@ export class OaBoardService { completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length, productTypes, }, + dailyReport: buildOaBoardDailyReport(allItems, requestedReportDate), stageMatrix: buildStageMatrixRows(), stageGraph: buildStageGraph(items), } From 5bd554f762f77414984b6f9b9f6c02226cfd67d6 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 11 Aug 2026 05:40:24 +0800 Subject: [PATCH 5/5] fix(bom): isolate oversized board packing --- .../bom-costing-scenario.service.test.ts | 73 +++++++++++++++++++ .../bom-costing-scenario.service.ts | 51 ++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts index 860b2840..f05b7a5a 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.test.ts @@ -363,6 +363,79 @@ describe('BomCostingScenarioService', () => { }) }) + it('单个超尺寸板件不会让同一 SKU 的其他板件全部退出批量裁切', async () => { + const bomLines = [ + line({ + id: 'fit-board', + category: 'BOARD', + partName: '可排版层板', + materialName: '橡胶木', + materialCode: 'RUBBERWOOD', + materialSpec: '明确标注板厚 18mm', + unit: '块', + quantity: 4, + lengthMm: 600, + widthMm: 600, + }), + line({ + id: 'oversized-board', + category: 'BOARD', + partName: '超尺寸背板', + materialName: '橡胶木', + materialCode: 'RUBBERWOOD', + materialSpec: '明确标注板厚 18mm', + unit: '块', + quantity: 1, + lengthMm: 2_500, + widthMm: 1_000, + }), + ] + mockBase({ bomLines }) + jest.mocked(bomCostingScenarioRepository.listSubstrateSheetCandidates).mockResolvedValue([{ + id: 'substrate-rubberwood-18', + code: 'RW-18', + name: '橡胶木指接板 18mm', + categoryName: '实木板', + systemName: '橡胶木', + environmentGrade: 'E0', + aliases: ['RW-18', '橡胶木', '橡胶木指接板'], + thicknessMm: 18, + sheetLengthMm: 2_440, + sheetWidthMm: 1_220, + fullSheetPrice: 200, + updatedAt: now, + }]) + jest.spyOn(bomCostingScenarioRepository, 'findReferenceCandidates').mockImplementation(async (input) => [ + candidate({ + id: `historical-${input.partName}`, + category: 'BOARD', + partName: input.partName, + materialName: '橡胶木', + unit: '块', + unitPrice: input.partName === '超尺寸背板' ? 180 : 50, + }), + ]) + const service = new BomCostingScenarioService() + jest.spyOn(service, 'getCaseDynamicCosting').mockResolvedValue({} as never) + + await service.calculate({ + caseId: 'case-1', + skuId: 'sku-1', + targetMaterialId: 'material-rubberwood', + }) + + const saved = jest.mocked(bomCostingScenarioRepository.saveScenarioPair).mock.calls[0]![0] + const sameLines = new Map(saved.sameMaterial.lines.map((item) => [item.sourceLineId, item])) + expect(sameLines.get('fit-board')).toMatchObject({ + calculationRule: 'board-sheet-maxrects/v2-batch', + priceSource: expect.stringContaining('批量裁切'), + }) + expect(sameLines.get('oversized-board')).toMatchObject({ + calculationRule: expect.not.stringContaining('board-sheet-maxrects'), + subtotal: 180, + }) + }) + it('小板件主成本采用同款批量排版,单件整板成本只保留在区间上限', async () => { const bomLines = [line({ id: 'small-board', diff --git a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts index 5c5734e9..57640d37 100644 --- a/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts +++ b/furniture-3d-system/src/server/services/bom-costing/bom-costing-scenario.service.ts @@ -505,11 +505,29 @@ function readNonNegativeEnvironmentNumber(name: string, fallback: number) { return Number.isFinite(numeric) && numeric >= 0 ? numeric : fallback } +function boardPartFitsSheet(input: { + lengthMm: number + widthMm: number + candidate: BomCostingSubstrateSheetCandidate + kerfMm: number + edgeMarginMm: number +}) { + const usableLength = input.candidate.sheetLengthMm - input.edgeMarginMm * 2 + input.kerfMm + const usableWidth = input.candidate.sheetWidthMm - input.edgeMarginMm * 2 + input.kerfMm + if (usableLength <= 0 || usableWidth <= 0) return false + return ( + (input.lengthMm + input.kerfMm <= usableLength && input.widthMm + input.kerfMm <= usableWidth) || + (input.widthMm + input.kerfMm <= usableLength && input.lengthMm + input.kerfMm <= usableWidth) + ) +} + function buildBoardSheetPriceQuotes(input: { lines: BomCostingLine[] candidates: BomCostingSubstrateSheetCandidate[] materialNamesForLine: (line: BomCostingLine) => string[] }) { + const kerfMm = readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_KERF_MM', 4) + const edgeMarginMm = readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 5) const assigned = input.lines.flatMap((line) => { if (line.category !== 'BOARD') return [] const lengthMm = positiveFinite(line.lengthMm) @@ -521,7 +539,34 @@ function buildBoardSheetPriceQuotes(input: { input.candidates, input.materialNamesForLine(line), ) - return selection ? [{ line, selection, candidate: selection.candidate, lengthMm, widthMm, quantity }] : [] + if (!selection) return [] + const fittingCandidates = selection.compatibleCandidates.filter((candidate) => boardPartFitsSheet({ + lengthMm, + widthMm, + candidate, + kerfMm, + edgeMarginMm, + })) + if (fittingCandidates.length === 0) return [] + const candidate = [...fittingCandidates].sort((left, right) => ( + Math.abs(left.fullSheetPrice - selection.candidate.fullSheetPrice) - + Math.abs(right.fullSheetPrice - selection.candidate.fullSheetPrice) || + Math.abs(left.thicknessMm - selection.candidate.thicknessMm) - + Math.abs(right.thicknessMm - selection.candidate.thicknessMm) || + right.updatedAt.getTime() - left.updatedAt.getTime() + ))[0]! + return [{ + line, + selection: { + ...selection, + candidate, + compatibleCandidates: fittingCandidates, + }, + candidate, + lengthMm, + widthMm, + quantity, + }] }) const groups = new Map() for (const entry of assigned) { @@ -538,12 +583,12 @@ function buildBoardSheetPriceQuotes(input: { sheetLengthMm: candidate.sheetLengthMm, sheetWidthMm: candidate.sheetWidthMm, fullSheetPrice: candidate.fullSheetPrice, - kerfMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_KERF_MM', 4), + kerfMm, // Five millimetres on each edge keeps a real trimming allowance while // still allowing two 600 mm parts on a common 1220 mm sheet with kerf. // The former 10 mm-per-edge default falsely doubled sheet counts for // this very common layout and surfaced the trim as excessive loss. - edgeMarginMm: readNonNegativeEnvironmentNumber('BOM_COSTING_BOARD_EDGE_MARGIN_MM', 5), + edgeMarginMm, }, group.map(({ line, lengthMm, widthMm, quantity }) => ({ lineId: line.id, lengthMm,