merge: isolate oversized board packing
This commit is contained in:
commit
7b95e5af33
@ -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,
|
||||
|
||||
@ -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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<section className="overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-apple-separator px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-base font-apple-semibold tracking-apple-tight text-apple-label-primary">
|
||||
流程日报
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] text-apple-label-secondary">按 OA 流程创建和结束时间统计</p>
|
||||
</div>
|
||||
<label className="inline-flex h-8 items-center gap-2 rounded-apple-md border border-apple-separator bg-apple-bg-secondary px-2.5 text-xs text-apple-label-secondary">
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
<span>统计日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={report.reportDate}
|
||||
onChange={(event) => onReportDateChange(event.target.value)}
|
||||
className="min-w-[124px] bg-transparent text-xs text-apple-label-primary outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-2 px-3 py-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{completionMetrics.map((metric) => {
|
||||
const Icon = metric.icon
|
||||
return (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="flex h-[104px] min-w-0 flex-col justify-between rounded-md border border-black/8 bg-white px-3.5 py-3 shadow-apple-xs"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-xs text-apple-label-secondary">{metric.label}</p>
|
||||
<Icon className={`h-4 w-4 shrink-0 ${metric.accentClass}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[28px] font-apple-semibold leading-none text-apple-label-primary">
|
||||
{metric.value}
|
||||
</p>
|
||||
<p className="mt-1.5 truncate text-[11px] text-apple-label-tertiary">{metric.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="flex min-h-[132px] items-center justify-between rounded-lg border border-black/8 bg-white px-5 py-4 shadow-apple-xs">
|
||||
<div>
|
||||
<p className="text-sm font-apple-medium text-apple-label-primary">当前在途流程</p>
|
||||
<p className="mt-1 text-xs text-apple-label-secondary">截至本次 OA 数据同步仍在推进</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[32px] font-apple-semibold leading-none text-apple-label-primary">
|
||||
{report.currentInProgressCount}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-apple-label-tertiary">条流程</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[132px] items-center justify-between rounded-lg border border-amber-200 bg-[#fffdf6] px-5 py-4 shadow-apple-xs">
|
||||
<div>
|
||||
<p className="text-sm font-apple-medium text-apple-label-primary">当前总流程超时</p>
|
||||
<p className="mt-1 text-xs text-apple-label-secondary">仅按总实际时效与总目标时效判断</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[32px] font-apple-semibold leading-none text-amber-700">
|
||||
{report.currentOverallOvertimeCount}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-amber-700/70">条流程</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<FilterState>(appliedFilters)
|
||||
const [activeTab, setActiveTab] = useState<OaBoardViewTab>(() =>
|
||||
parseOaBoardViewTab(searchParams.get('tab')),
|
||||
)
|
||||
const [activeTab, setActiveTab] = useState<OaBoardViewTab>(() => {
|
||||
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({
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
activeTab === 'summary'
|
||||
activeTab === 'summary' || showingDailyReport
|
||||
? 'min-h-full bg-[#f5f7f8]'
|
||||
: 'flex h-[calc(100vh-72px)] min-h-0 flex-col overflow-hidden bg-[#f5f7f8]'
|
||||
}
|
||||
@ -3312,13 +3468,17 @@ export default function OaBoardClient({
|
||||
<LoaderCircle className={`h-3.5 w-3.5 ${isPending ? 'animate-spin' : ''}`} />
|
||||
{activeTab === 'summary'
|
||||
? `${overviewItems.length} 条流程`
|
||||
: showingDailyReport && dailyReport
|
||||
? `${dailyReport.reportDate} 日报`
|
||||
: activeTab === 'brand-planning'
|
||||
? 'SKU 维度'
|
||||
: `${items.length} 条命中`}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{activeTab === 'summary' ? (
|
||||
{showingDailyReport && dailyReport ? (
|
||||
<DailyReportPanel report={dailyReport} onReportDateChange={changeReportDate} />
|
||||
) : activeTab === 'summary' ? (
|
||||
<>
|
||||
<section className="shrink-0 overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">
|
||||
<div className="flex items-center justify-between border-b border-apple-separator px-4 py-3">
|
||||
|
||||
@ -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<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
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 (
|
||||
<OaBoardClient
|
||||
key={pageStateKey}
|
||||
dashboard={result.dashboard}
|
||||
dashboard={dashboard}
|
||||
loadErrorMessage={result.loadErrorMessage}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<string, typeof assigned>()
|
||||
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,
|
||||
|
||||
@ -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<AuthUser, 'name'> | null | undefined) {
|
||||
return OA_BOARD_DAILY_REPORT_VIEWER_NAMES.has(String(user?.name ?? '').trim())
|
||||
}
|
||||
@ -625,4 +625,9 @@ export const OA_BOARD_MANUAL_OVERALL_STATUS_OVERRIDES = {
|
||||
XP2026072300000345: 'TERMINATED',
|
||||
XP2026070300000255: 'TERMINATED',
|
||||
XP2026070400000261: 'TERMINATED',
|
||||
XP2026070100000242: 'TERMINATED',
|
||||
XP2026071700000329: 'TERMINATED',
|
||||
XP2026071800000332: 'TERMINATED',
|
||||
XP2026080100000354: 'TERMINATED',
|
||||
XP2026080300000367: 'TERMINATED',
|
||||
} as const
|
||||
|
||||
@ -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<string>()
|
||||
|
||||
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<string>()
|
||||
const completedProjects = new Set<string>()
|
||||
|
||||
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<OaBoardDashboardDTO> {
|
||||
async getDashboard(
|
||||
filter: OaBoardProjectListFilter = {},
|
||||
requestedReportDate?: string,
|
||||
): Promise<OaBoardDashboardDTO> {
|
||||
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),
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user