feat(oa-board): add monthly flow analysis

This commit is contained in:
jingchuan 2026-08-11 12:06:51 +08:00
parent b24328f4f8
commit 981df46e3f
7 changed files with 1013 additions and 55 deletions

View File

@ -123,7 +123,7 @@ async function main() {
)
const dashboard = await oaBoardService.getDashboard({
overallStatus: ['RUNNING', 'COMPLETED', 'REFUSED', 'TERMINATED'],
})
}, undefined, undefined, true)
const reviewOwnersByOpeningCode = await loadReviewOwnersByOpeningCode()
dashboard.items = dashboard.items.map((item) => {
const openingCode = item.formSummary.openingCode || item.openingCode || ''

View File

@ -33,6 +33,7 @@ export async function GET(request: NextRequest) {
const timeoutOnly = searchParams.get('timeoutOnly') === '1'
const timeoutStatus = searchParams.get('timeoutStatus')
const reportDate = searchParams.get('reportDate') ?? ''
const reportMonth = searchParams.get('reportMonth') ?? ''
const canViewDailyReport = canViewOaBoardDailyReport(ctx.user)
try {
@ -57,6 +58,7 @@ export async function GET(request: NextRequest) {
timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined,
},
canViewDailyReport ? reportDate || undefined : undefined,
canViewDailyReport ? reportMonth || undefined : undefined,
canViewDailyReport,
)
@ -64,6 +66,7 @@ export async function GET(request: NextRequest) {
{
...dashboard,
dailyReport: canViewDailyReport ? dashboard.dailyReport : undefined,
monthlyReport: canViewDailyReport ? dashboard.monthlyReport : undefined,
},
200,
ctx.requestId,
@ -75,6 +78,7 @@ export async function GET(request: NextRequest) {
{
...emptyDashboard,
dailyReport: canViewDailyReport ? emptyDashboard.dailyReport : undefined,
monthlyReport: canViewDailyReport ? emptyDashboard.monthlyReport : undefined,
notice: '当前看板表尚未完成迁移或同步,先返回空列表占位。',
},
200,

View File

@ -30,6 +30,7 @@ import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.co
import type {
OaBoardDashboardDTO,
OaBoardDailyReportDTO,
OaBoardMonthlyReportDTO,
OaBoardProjectListItemDTO,
OaBoardStageGraphDTO,
OaBoardStageGraphNodeDTO,
@ -65,6 +66,7 @@ type FilterOption = {
type OaBoardViewTab =
| 'summary'
| 'daily'
| 'monthly'
| 'detail'
| 'brand-planning'
| 'timeout-analysis'
@ -176,6 +178,7 @@ const DEFAULT_STATUS_SELECT_VALUE = '__default__'
function parseOaBoardViewTab(value: string | null): OaBoardViewTab {
if (
value === 'daily' ||
value === 'monthly' ||
value === 'detail' ||
value === 'brand-planning' ||
value === 'timeout-analysis' ||
@ -2981,6 +2984,247 @@ function DailyReportPanel({
)
}
function formatMonthlyDelta(value: number | null, unit: string) {
if (value === null) return '上月快照待积累'
if (value === 0) return '与上月持平'
return `较上月 ${value > 0 ? '+' : ''}${value}${unit}`
}
function MonthlyDimensionTable({
title,
rows,
}: {
title: string
rows: OaBoardMonthlyReportDTO['dimensions']['developmentPlan']
}) {
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">
<header className="border-b border-apple-separator px-4 py-3">
<h2 className="text-base font-apple-semibold tracking-apple-tight text-apple-label-primary">{title}</h2>
</header>
{rows.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-apple-label-secondary">暂无可统计数据</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-left text-xs">
<thead className="bg-[#fafbfc] text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className="px-4 py-3 font-apple-semibold">维度</th>
<th className="px-3 py-3 font-apple-semibold">新增</th>
<th className="px-3 py-3 font-apple-semibold">完成</th>
<th className="px-3 py-3 font-apple-semibold">月末在途</th>
<th className="px-3 py-3 font-apple-semibold">总流程超时</th>
<th className="px-3 py-3 font-apple-semibold">完成平均时效</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 10).map((row) => (
<tr key={row.value} className="border-b border-apple-separator last:border-b-0">
<td className="max-w-[210px] px-4 py-3 font-apple-medium text-apple-label-primary">
<span className="block truncate" title={row.value}>{row.value}</span>
</td>
<td className="px-3 py-3 text-apple-label-primary">{row.newProjectCount}</td>
<td className="px-3 py-3 text-apple-label-primary">{row.completedProjectCount}</td>
<td className="px-3 py-3 text-apple-label-primary">{row.monthEndInProgressCount}</td>
<td className="px-3 py-3">
<StatusBadge
label={`${row.monthEndOverallOvertimeCount} 条`}
tone={row.monthEndOverallOvertimeCount > 0 ? 'danger' : 'success'}
/>
</td>
<td className="px-3 py-3 text-apple-label-primary">
{row.averageCompletedDays === null ? '-' : `${row.averageCompletedDays} 天`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
function MonthlyReportPanel({
report,
onReportMonthChange,
}: {
report: OaBoardMonthlyReportDTO
onReportMonthChange: (value: string) => void
}) {
const metrics = [
{
label: '当月新增流程',
value: report.newProjectCount,
detail: formatMonthlyDelta(report.comparison.newProjectCount, ' 条'),
icon: CalendarDays,
accentClass: 'text-sky-700',
},
{
label: '当月完成流程',
value: report.completedProjectCount,
detail: formatMonthlyDelta(report.comparison.completedProjectCount, ' 条'),
icon: CheckCircle2,
accentClass: 'text-emerald-700',
},
{
label: '月末在途',
value: report.monthEndInProgressCount,
detail: formatMonthlyDelta(report.comparison.monthEndInProgressCount, ' 条'),
icon: Clock3,
accentClass: 'text-amber-700',
},
{
label: '月末总流程超时',
value: report.monthEndOverallOvertimeCount,
detail: formatMonthlyDelta(report.comparison.monthEndOverallOvertimeCount, ' 条'),
icon: AlertTriangle,
accentClass: 'text-rose-700',
},
]
const snapshotLabel =
report.source === 'DAILY_SNAPSHOT'
? `已留存 ${report.snapshotDays} 天快照,统计截止 ${report.lastSnapshotDate ?? '-'}`
: '当前数据回算,月度快照将在后续完整同步中开始留存'
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto pb-1">
<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">按月度快照汇总流程规模、产出、总时效与结构分布</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="month"
value={report.reportMonth}
onChange={(event) => onReportMonthChange(event.target.value)}
className="min-w-[112px] 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-4">
{metrics.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>
<div className={`border-t px-4 py-2 text-[11px] ${report.source === 'DAILY_SNAPSHOT' ? 'border-emerald-100 bg-emerald-50/60 text-emerald-800' : 'border-amber-100 bg-amber-50/70 text-amber-800'}`}>
数据口径:{snapshotLabel}
</div>
</section>
<section className="grid gap-3 lg:grid-cols-3">
<div className="border border-black/8 bg-white px-4 py-4 shadow-apple-xs">
<p className="text-xs text-apple-label-secondary">完成项目平均总时效</p>
<p className="mt-2 text-2xl font-apple-semibold text-apple-label-primary">
{report.completedDuration.averageDays === null ? '-' : `${report.completedDuration.averageDays} 天`}
</p>
<p className="mt-1 text-[11px] text-apple-label-tertiary">
{formatMonthlyDelta(report.comparison.averageCompletedDays, ' 天')},样本 {report.completedDuration.sampleSize} 条
</p>
</div>
<div className="border border-black/8 bg-white px-4 py-4 shadow-apple-xs">
<p className="text-xs text-apple-label-secondary">完成项目中位数时效</p>
<p className="mt-2 text-2xl font-apple-semibold text-apple-label-primary">
{report.completedDuration.medianDays === null ? '-' : `${report.completedDuration.medianDays} 天`}
</p>
<p className="mt-1 text-[11px] text-apple-label-tertiary">减少极端超长流程对判断的影响</p>
</div>
<div className="border border-black/8 bg-white px-4 py-4 shadow-apple-xs">
<p className="text-xs text-apple-label-secondary">完成项目 P90 总时效</p>
<p className="mt-2 text-2xl font-apple-semibold text-apple-label-primary">
{report.completedDuration.p90Days === null ? '-' : `${report.completedDuration.p90Days} 天`}
</p>
<p className="mt-1 text-[11px] text-apple-label-tertiary">用于观察较慢的 10% 流程</p>
</div>
</section>
<section className="overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">
<header className="flex flex-wrap items-start justify-between gap-2 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">按实际结束日期统计,同一子流程实例只计一次</p>
</div>
</header>
<div className="grid gap-px bg-apple-separator sm:grid-cols-3">
{[
['首单下单完成', report.firstOrderCompletedCount],
['美术设计完成', report.artCompletedCount],
['限价流程完成', report.pricingCompletedCount],
].map(([label, value]) => (
<div key={String(label)} className="bg-white px-5 py-4">
<p className="text-xs text-apple-label-secondary">{label}</p>
<p className="mt-2 text-2xl font-apple-semibold text-apple-label-primary">{value} 条</p>
</div>
))}
</div>
</section>
<div className="grid gap-3 xl:grid-cols-3">
<MonthlyDimensionTable title="按产品开发方案" rows={report.dimensions.developmentPlan} />
<MonthlyDimensionTable title="按需求部门" rows={report.dimensions.demandDepartment} />
<MonthlyDimensionTable title="按产品类目" rows={report.dimensions.productCategory} />
</div>
<section className="overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">
<header className="flex flex-wrap items-start justify-between gap-2 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">按月末最后一次快照的在途和阶段时效统计</p>
</div>
</header>
{report.stageBottlenecks.length === 0 ? (
<div className="px-5 py-10 text-center text-sm text-apple-label-secondary">月末没有在途阶段数据</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] text-left text-xs">
<thead className="bg-[#fafbfc] text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className="px-4 py-3 font-apple-semibold">审批阶段</th>
<th className="px-4 py-3 font-apple-semibold">月末在途</th>
<th className="px-4 py-3 font-apple-semibold">当前超时</th>
<th className="px-4 py-3 font-apple-semibold">超时率</th>
<th className="px-4 py-3 font-apple-semibold">最久停留</th>
</tr>
</thead>
<tbody>
{report.stageBottlenecks.map((stage) => (
<tr key={stage.stageName} className="border-b border-apple-separator last:border-b-0">
<td className="px-4 py-3 font-apple-medium text-apple-label-primary">{stage.stageName}</td>
<td className="px-4 py-3 text-apple-label-primary">{stage.monthEndInProgressCount}</td>
<td className="px-4 py-3"><StatusBadge label={`${stage.overdueCount} 条`} tone={stage.overdueCount > 0 ? 'danger' : 'success'} /></td>
<td className="px-4 py-3 text-apple-label-primary">
{stage.overdueRate === null ? '-' : `${(stage.overdueRate * 100).toFixed(0)}%`}
</td>
<td className="px-4 py-3 text-apple-label-primary">
{stage.longestStayDays === null ? '-' : `${stage.longestStayDays} 天`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}
export default function OaBoardClient({
dashboard,
loadErrorMessage,
@ -2988,8 +3232,9 @@ export default function OaBoardClient({
dashboard: OaBoardDashboardDTO
loadErrorMessage?: string | null
}) {
const { items: allItems, stageGraph, dailyReport } = dashboard
const { items: allItems, stageGraph, dailyReport, monthlyReport } = dashboard
const canViewDailyReport = Boolean(dailyReport)
const canViewMonthlyReport = Boolean(monthlyReport)
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
@ -3048,14 +3293,19 @@ export default function OaBoardClient({
const [filters, setFilters] = useState<FilterState>(appliedFilters)
const [activeTab, setActiveTab] = useState<OaBoardViewTab>(() => {
const requestedTab = parseOaBoardViewTab(searchParams.get('tab'))
return requestedTab === 'daily' && !canViewDailyReport ? 'summary' : requestedTab
if (requestedTab === 'daily' && !canViewDailyReport) return 'summary'
if (requestedTab === 'monthly' && !canViewMonthlyReport) return 'summary'
return requestedTab
})
const showingDailyReport = canViewDailyReport && activeTab === 'daily'
const showingMonthlyReport = canViewMonthlyReport && activeTab === 'monthly'
const activeTabLabel =
activeTab === 'summary'
? '新品进度汇总'
: showingDailyReport
? '新品流程日报'
: showingMonthlyReport
? '新品流程月度分析'
: activeTab === 'detail'
? '新品流程明细'
: activeTab === 'brand-planning'
@ -3085,6 +3335,15 @@ export default function OaBoardClient({
onClick: () => switchTab('daily'),
}]
: []),
...(canViewMonthlyReport
? [{
key: 'monthly' as const,
label: '新品流程月度分析',
active: showingMonthlyReport,
disabled: false,
onClick: () => switchTab('monthly'),
}]
: []),
{
key: 'detail',
label: '新品流程明细',
@ -3132,6 +3391,7 @@ export default function OaBoardClient({
const switchTab = (nextTab: OaBoardViewTab) => {
if (nextTab === 'daily' && !canViewDailyReport) return
if (nextTab === 'monthly' && !canViewMonthlyReport) return
if (nextTab === activeTab) return
const params = new URLSearchParams(searchParams.toString())
@ -3156,6 +3416,16 @@ export default function OaBoardClient({
})
}
const changeReportMonth = (nextMonth: string) => {
if (!monthlyReport || !nextMonth || nextMonth === monthlyReport.reportMonth) return
const params = preserveActiveTab(new URLSearchParams(searchParams.toString()))
params.set('reportMonth', nextMonth)
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`)
})
}
useEffect(() => {
setFilters(appliedFilters)
setCurrentPage(1)
@ -3582,7 +3852,7 @@ export default function OaBoardClient({
<>
<div
className={
activeTab === 'summary' || showingDailyReport
activeTab === 'summary' || showingDailyReport || showingMonthlyReport
? 'min-h-full bg-[#f5f7f8]'
: 'flex h-[calc(100vh-72px)] min-h-0 flex-col overflow-hidden bg-[#f5f7f8]'
}
@ -3609,6 +3879,8 @@ export default function OaBoardClient({
? `${overviewItems.length} 条流程`
: showingDailyReport && dailyReport
? `${dailyReport.reportDate} 日报`
: showingMonthlyReport && monthlyReport
? `${monthlyReport.reportMonth} 月度分析`
: activeTab === 'brand-planning'
? 'SKU 维度'
: `${items.length} 条命中`}
@ -3617,6 +3889,8 @@ export default function OaBoardClient({
{showingDailyReport && dailyReport ? (
<DailyReportPanel report={dailyReport} onReportDateChange={changeReportDate} />
) : showingMonthlyReport && monthlyReport ? (
<MonthlyReportPanel report={monthlyReport} onReportMonthChange={changeReportMonth} />
) : activeTab === 'summary' ? (
<>
<section className="shrink-0 overflow-hidden rounded-lg border border-black/8 bg-white shadow-apple-xs">

View File

@ -47,6 +47,7 @@ export default async function OaBoardPage({
const timeoutOnly = params.timeoutOnly === '1'
const timeoutStatus = readSingleParam(params.timeoutStatus)
const reportDate = readSingleParam(params.reportDate)
const reportMonth = readSingleParam(params.reportMonth)
const loadLiveDashboard = () =>
oaBoardService.getDashboard(
@ -73,6 +74,7 @@ export default async function OaBoardPage({
timeoutStatus: timeoutStatus === 'YES' || timeoutStatus === 'NO' ? timeoutStatus : undefined,
},
canViewDailyReport ? reportDate || undefined : undefined,
canViewDailyReport ? reportMonth || undefined : undefined,
canViewDailyReport,
)

View File

@ -1,5 +1,6 @@
import {
buildOaBoardDailyReport,
buildOaBoardMonthlyReport,
hasOaBoardProjectOvertime,
type OaBoardProjectListItemDTO,
} from '../oa-board.service'
@ -217,3 +218,174 @@ describe('OA 看板流程日报', () => {
])
})
})
describe('OA 看板月度分析', () => {
it('按完成流程统计总时效,并保留开发方案、需求部门和类目维度', () => {
const report = buildOaBoardMonthlyReport(
[
{
boardKey: 'OPENING:XP001',
overallStatus: 'COMPLETED',
overallTimeoutFlag: false,
overallActualHours: 12,
originatorDeptName: '商品企划部',
formSummary: {
developmentPlan: '常规开发',
categoryName: '客厅家具',
approvalStageDisplay: '运营上架',
},
flowChips: [
{
flowType: 'review',
processInstanceId: 'review-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-02T01:00:00.000Z',
finishedAtSource: '2026-08-20T01:00:00.000Z',
},
{
flowType: 'order',
processInstanceId: 'order-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-03T01:00:00.000Z',
finishedAtSource: '2026-08-18T01:00:00.000Z',
},
{
flowType: 'art',
processInstanceId: 'art-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-03T01:00:00.000Z',
finishedAtSource: '2026-08-19T01:00:00.000Z',
},
{
flowType: 'pricing',
processInstanceId: 'pricing-001',
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: '2026-08-03T01:00:00.000Z',
finishedAtSource: '2026-08-19T01:00:00.000Z',
},
],
},
{
boardKey: 'OPENING:XP002',
overallStatus: 'RUNNING',
overallTimeoutFlag: true,
overallActualHours: 26,
originatorDeptName: '运营部',
formSummary: {
developmentPlan: '重点开发',
categoryName: '餐厅家具',
approvalStageDisplay: '产品生产-首单下单',
},
flowChips: [
{
flowType: 'review',
processInstanceId: 'review-002',
status: 'RUNNING',
result: null,
createdAtSource: '2026-08-15T01:00:00.000Z',
finishedAtSource: null,
},
],
},
] as OaBoardProjectListItemDTO[],
'2026-08',
)
expect(report).toMatchObject({
reportMonth: '2026-08',
newProjectCount: 2,
completedProjectCount: 1,
firstOrderCompletedCount: 1,
artCompletedCount: 1,
pricingCompletedCount: 1,
monthEndInProgressCount: 1,
monthEndOverallOvertimeCount: 1,
completedDuration: {
sampleSize: 1,
averageDays: 12,
medianDays: 12,
p90Days: 12,
},
})
expect(report.dimensions.developmentPlan).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: '常规开发', newProjectCount: 1, completedProjectCount: 1 }),
expect.objectContaining({ value: '重点开发', monthEndInProgressCount: 1, monthEndOverallOvertimeCount: 1 }),
]),
)
expect(report.dimensions.demandDepartment).toEqual(
expect.arrayContaining([expect.objectContaining({ value: '商品企划部', completedProjectCount: 1 })]),
)
expect(report.dimensions.productCategory).toEqual(
expect.arrayContaining([expect.objectContaining({ value: '客厅家具', completedProjectCount: 1 })]),
)
})
it('以月末快照与上月同口径比较流程数量和完成总时效', () => {
const buildSnapshot = (
reportDate: string,
boardKey: string,
finishedAt: string,
overallActualHours: number,
) => ({
reportDate,
boardKey,
firstStageNames: ['运营上架'],
observedStageNames: ['运营上架'],
lastStageNames: ['运营上架'],
firstOverallStatus: 'COMPLETED',
lastOverallStatus: 'COMPLETED',
firstObservedAt: new Date(`${reportDate}T01:00:00.000Z`),
lastObservedAt: new Date(`${reportDate}T08:00:00.000Z`),
projectGraph: {
id: boardKey,
boardKey,
boardKeyType: 'OPENING',
primaryFlowType: 'review',
title: '新品开发立项评审流程',
overallStatus: 'COMPLETED',
overallActualHours,
overallTimeoutFlag: false,
metadata: { formSummary: { approvalStageDisplay: '运营上架' } },
flowInstances: [
{
flowType: 'review',
processName: '新品开发立项评审流程',
processInstanceId: `${boardKey}-review`,
status: 'COMPLETED',
result: 'AGREE',
createdAtSource: new Date(`${reportDate.slice(0, 7)}-02T01:00:00.000Z`),
finishedAtSource: new Date(finishedAt),
taskRecords: [],
},
],
},
}) as OaBoardDailyProjectSnapshot
const currentSnapshot = buildSnapshot('2026-08-31', 'OPENING:XP008', '2026-08-20T01:00:00.000Z', 12)
const previousSnapshot = buildSnapshot('2026-07-31', 'OPENING:XP007', '2026-07-20T01:00:00.000Z', 8)
const report = buildOaBoardMonthlyReport(
[],
'2026-08',
[currentSnapshot],
[previousSnapshot],
{ snapshotDays: 20, firstSnapshotDate: '2026-08-01', lastSnapshotDate: '2026-08-31' },
)
expect(report).toMatchObject({
source: 'DAILY_SNAPSHOT',
snapshotDays: 20,
hasMonthComparison: true,
completedDuration: { averageDays: 12 },
comparison: {
newProjectCount: 0,
completedProjectCount: 0,
averageCompletedDays: 4,
},
})
})
})

View File

@ -209,6 +209,13 @@ export interface OaBoardDailyProjectSnapshot {
projectGraph: OaBoardProjectGraphInput
}
export interface OaBoardDailyProjectSnapshotWindow {
snapshotDays: number
firstSnapshotDate: string | null
lastSnapshotDate: string | null
snapshots: OaBoardDailyProjectSnapshot[]
}
type NormalizedFieldSnapshot = {
fieldName: string
fieldValue: string | null
@ -493,8 +500,8 @@ function createSqlServerSourceConfig(): sql.config {
}
async function loadSqlServer() {
const module = await import('mssql')
return module.default
const mssqlModule = await import('mssql')
return mssqlModule.default
}
function getSqlServerSourceSchema() {
@ -3181,8 +3188,32 @@ function normalizeDailySnapshotReportDate(value: string | Date) {
return getShanghaiDateKey(value)
}
export async function queryOaBoardDailyProjectSnapshots(
reportDate: string,
function toDailyProjectSnapshot(row: DailyProjectSnapshotRow): OaBoardDailyProjectSnapshot | null {
if (!row.raw_snapshot) return null
try {
const projectGraph = normalizeProjectGraphSnapshot(JSON.parse(row.raw_snapshot))
if (!projectGraph) return null
return {
reportDate: normalizeDailySnapshotReportDate(row.report_date),
boardKey: row.board_key,
firstStageNames: splitStageDisplay(row.first_stage_display),
observedStageNames: parseJsonStringArray(row.observed_stage_names),
lastStageNames: splitStageDisplay(row.last_stage_display),
firstOverallStatus: normalizeText(row.first_overall_status),
lastOverallStatus: normalizeText(row.last_overall_status),
firstObservedAt: parseDateValue(row.first_observed_at),
lastObservedAt: parseDateValue(row.last_observed_at),
projectGraph,
}
} catch {
return null
}
}
async function queryOaBoardDailyProjectSnapshotRows(
startDate: string,
endDate: string,
): Promise<OaBoardDailyProjectSnapshot[]> {
const connectionConfig = createPlanningConnectionConfig()
const connection =
@ -3196,32 +3227,15 @@ export async function queryOaBoardDailyProjectSnapshots(
SELECT report_date, board_key, first_stage_display, observed_stage_names, last_stage_display,
first_overall_status, last_overall_status, first_observed_at, last_observed_at, raw_snapshot
FROM ${OA_BOARD_DAILY_PROJECT_SNAPSHOT_TABLE}
WHERE report_date = ?
ORDER BY last_observed_at DESC, board_key ASC
WHERE report_date BETWEEN ? AND ?
ORDER BY report_date ASC, last_observed_at DESC, board_key ASC
`,
[reportDate],
[startDate, endDate],
)
return rows.flatMap((row) => {
if (!row.raw_snapshot) return []
try {
const projectGraph = normalizeProjectGraphSnapshot(JSON.parse(row.raw_snapshot))
if (!projectGraph) return []
return [{
reportDate: normalizeDailySnapshotReportDate(row.report_date),
boardKey: row.board_key,
firstStageNames: splitStageDisplay(row.first_stage_display),
observedStageNames: parseJsonStringArray(row.observed_stage_names),
lastStageNames: splitStageDisplay(row.last_stage_display),
firstOverallStatus: normalizeText(row.first_overall_status),
lastOverallStatus: normalizeText(row.last_overall_status),
firstObservedAt: parseDateValue(row.first_observed_at),
lastObservedAt: parseDateValue(row.last_observed_at),
projectGraph,
}]
} catch {
return []
}
const snapshot = toDailyProjectSnapshot(row)
return snapshot ? [snapshot] : []
})
} catch (error) {
const mysqlError = error as { code?: string }
@ -3232,6 +3246,86 @@ export async function queryOaBoardDailyProjectSnapshots(
}
}
export async function queryOaBoardDailyProjectSnapshots(
reportDate: string,
): Promise<OaBoardDailyProjectSnapshot[]> {
return queryOaBoardDailyProjectSnapshotRows(reportDate, reportDate)
}
export async function queryOaBoardDailyProjectSnapshotWindow(
startDate: string,
endDate: string,
): Promise<OaBoardDailyProjectSnapshotWindow> {
const connectionConfig = createPlanningConnectionConfig()
const connection =
typeof connectionConfig === 'string'
? await mysql.createConnection(connectionConfig)
: await mysql.createConnection(connectionConfig)
try {
const [coverageRows] = await connection.query<
Array<RowDataPacket & { first_report_date: string | Date | null; last_report_date: string | Date | null; snapshot_days: number }>
>(
`
SELECT MIN(report_date) AS first_report_date,
MAX(report_date) AS last_report_date,
COUNT(DISTINCT report_date) AS snapshot_days
FROM ${OA_BOARD_DAILY_PROJECT_SNAPSHOT_TABLE}
WHERE report_date BETWEEN ? AND ?
`,
[startDate, endDate],
)
const coverage = coverageRows[0]
const lastSnapshotDate = coverage?.last_report_date
? normalizeDailySnapshotReportDate(coverage.last_report_date)
: null
if (!lastSnapshotDate) {
return {
snapshotDays: 0,
firstSnapshotDate: null,
lastSnapshotDate: null,
snapshots: [],
}
}
const [rows] = await connection.query<DailyProjectSnapshotRow[]>(
`
SELECT report_date, board_key, first_stage_display, observed_stage_names, last_stage_display,
first_overall_status, last_overall_status, first_observed_at, last_observed_at, raw_snapshot
FROM ${OA_BOARD_DAILY_PROJECT_SNAPSHOT_TABLE}
WHERE report_date = ?
ORDER BY last_observed_at DESC, board_key ASC
`,
[lastSnapshotDate],
)
return {
snapshotDays: Number(coverage.snapshot_days ?? 0),
firstSnapshotDate: coverage.first_report_date
? normalizeDailySnapshotReportDate(coverage.first_report_date)
: null,
lastSnapshotDate,
snapshots: rows.flatMap((row) => {
const snapshot = toDailyProjectSnapshot(row)
return snapshot ? [snapshot] : []
}),
}
} catch (error) {
const mysqlError = error as { code?: string }
if (mysqlError.code === 'ER_NO_SUCH_TABLE') {
return {
snapshotDays: 0,
firstSnapshotDate: null,
lastSnapshotDate: null,
snapshots: [],
}
}
throw error
} finally {
await connection.end()
}
}
export async function syncOaBoard(options: OaBoardSyncOptions = {}): Promise<OaBoardSyncResult> {
const startedAt = new Date()
const apply = options.apply === true

View File

@ -13,9 +13,11 @@ import {
import {
queryOaBoardPreview,
queryOaBoardDailyProjectSnapshots,
queryOaBoardDailyProjectSnapshotWindow,
queryOaBoardSnapshotLatestSyncedAt,
queryOaBoardSnapshotPreview,
type OaBoardDailyProjectSnapshot,
type OaBoardDailyProjectSnapshotWindow,
} from './oa-board-sync.service'
import {
getOaBoardTimelineMetricOvertimeDays,
@ -211,6 +213,63 @@ export interface OaBoardDailyRiskItemDTO {
riskLabel: string
}
export interface OaBoardMonthlyDurationDTO {
sampleSize: number
totalDays: number | null
averageDays: number | null
medianDays: number | null
p90Days: number | null
}
export interface OaBoardMonthlyDimensionStatDTO {
value: string
newProjectCount: number
completedProjectCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
averageCompletedDays: number | null
}
export interface OaBoardMonthlyStageBottleneckDTO {
stageName: string
monthEndInProgressCount: number
overdueCount: number
overdueRate: number | null
longestStayDays: number | null
}
export interface OaBoardMonthlyReportDTO {
reportMonth: string
source: 'DAILY_SNAPSHOT' | 'REALTIME_FALLBACK'
snapshotDays: number
firstSnapshotDate: string | null
lastSnapshotDate: string | null
snapshotObservedAt: string | null
hasMonthComparison: boolean
previousReportMonth: string
newProjectCount: number
completedProjectCount: number
firstOrderCompletedCount: number
artCompletedCount: number
pricingCompletedCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
completedDuration: OaBoardMonthlyDurationDTO
comparison: {
newProjectCount: number | null
completedProjectCount: number | null
monthEndInProgressCount: number | null
monthEndOverallOvertimeCount: number | null
averageCompletedDays: number | null
}
dimensions: {
developmentPlan: OaBoardMonthlyDimensionStatDTO[]
demandDepartment: OaBoardMonthlyDimensionStatDTO[]
productCategory: OaBoardMonthlyDimensionStatDTO[]
}
stageBottlenecks: OaBoardMonthlyStageBottleneckDTO[]
}
export interface OaBoardStageGraphFlowCountDTO {
flowType: string
flowLabel: string
@ -355,16 +414,16 @@ function getStageMetric(item: OaBoardProjectListItemDTO, stageName: string) {
const metricKey = OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName]
if (!metricKey) return null
const directMetric = item.timelineMetrics.find((metric) => metric.key === metricKey)
const directMetric = (item.timelineMetrics ?? []).find((metric) => metric.key === metricKey)
if (directMetric) return directMetric
return stageName.startsWith('产品生产-')
? item.timelineMetrics.find((metric) => metric.key === 'production') ?? null
? (item.timelineMetrics ?? []).find((metric) => metric.key === 'production') ?? null
: null
}
function getStageStartedAt(item: OaBoardProjectListItemDTO, stageName: string) {
const timestamps = item.nodeAssessments
const timestamps = (item.nodeAssessments ?? [])
.filter((assessment) => assessment.approvalStageName === stageName)
.map((assessment) => (assessment.startedAtSource ? new Date(assessment.startedAtSource).getTime() : Number.NaN))
.filter((value) => Number.isFinite(value))
@ -550,6 +609,347 @@ function buildDailyRiskItems(
.slice(0, 10)
}
function toSnapshotReportItem(snapshot: OaBoardDailyProjectSnapshot): OaBoardProjectListItemDTO {
const item = toProjectListItem(snapshot.projectGraph)
const lastStageDisplay = snapshot.lastStageNames.join(';') || '-'
return {
...item,
overallStatus: snapshot.lastOverallStatus ?? item.overallStatus,
formSummary: {
...item.formSummary,
approvalStageDisplay: lastStageDisplay,
totalFlowStatusLabel:
snapshot.lastOverallStatus === 'RUNNING'
? '进行中'
: snapshot.lastOverallStatus === 'COMPLETED'
? '已完成'
: item.formSummary.totalFlowStatusLabel,
},
}
}
function isDateKeyInRange(value: string | null | undefined, startDate: string, endDate: string) {
const dateKey = value ? getShanghaiDateKey(value) : null
return Boolean(dateKey && dateKey >= startDate && dateKey <= endDate)
}
function getMonthRange(reportMonth: string) {
const [yearText, monthText] = reportMonth.split('-')
const year = Number(yearText)
const month = Number(monthText)
const endDay = new Date(Date.UTC(year, month, 0)).getUTCDate()
return {
startDate: `${reportMonth}-01`,
endDate: `${reportMonth}-${String(endDay).padStart(2, '0')}`,
}
}
function getPreviousMonthKey(reportMonth: string) {
const [yearText, monthText] = reportMonth.split('-')
const date = new Date(Date.UTC(Number(yearText), Number(monthText) - 2, 1))
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`
}
function isValidMonthKey(value: string) {
return /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
}
export function normalizeOaBoardMonthlyReportMonth(value?: string, referenceDate = new Date()) {
const normalized = String(value ?? '').trim()
if (isValidMonthKey(normalized)) return normalized
return (getShanghaiDateKey(referenceDate) ?? '1970-01-01').slice(0, 7)
}
function countCompletedSubflowsInRange(
items: OaBoardProjectListItemDTO[],
startDate: string,
endDate: string,
flowType: string,
) {
const completedInstances = new Set<string>()
items.forEach((item) => {
item.flowChips.forEach((flow) => {
if (
flow.flowType === flowType &&
isSuccessfulCompletedFlow(flow) &&
isDateKeyInRange(flow.finishedAtSource, startDate, endDate)
) {
completedInstances.add(flow.processInstanceId || `${item.boardKey}:${flowType}`)
}
})
})
return completedInstances.size
}
function calculateMonthlyDuration(values: number[]): OaBoardMonthlyDurationDTO {
if (values.length === 0) {
return { sampleSize: 0, totalDays: null, averageDays: null, medianDays: null, p90Days: null }
}
const sorted = [...values].sort((left, right) => left - right)
const atPercentile = (percentile: number) => {
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * percentile) - 1))
return sorted[index]
}
const middle = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
const total = sorted.reduce((sum, value) => sum + value, 0)
return {
sampleSize: sorted.length,
totalDays: Number(total.toFixed(2)),
averageDays: Number((total / sorted.length).toFixed(2)),
medianDays: Number(median.toFixed(2)),
p90Days: Number(atPercentile(0.9).toFixed(2)),
}
}
function getLatestSnapshotByBoardKey(snapshots: OaBoardDailyProjectSnapshot[]) {
const latest = new Map<string, OaBoardDailyProjectSnapshot>()
snapshots.forEach((snapshot) => {
const existing = latest.get(snapshot.boardKey)
if (
!existing ||
snapshot.reportDate > existing.reportDate ||
(snapshot.reportDate === existing.reportDate &&
(snapshot.lastObservedAt?.getTime() ?? 0) > (existing.lastObservedAt?.getTime() ?? 0))
) {
latest.set(snapshot.boardKey, snapshot)
}
})
return latest
}
function buildMonthlyDimensionStats(
items: OaBoardProjectListItemDTO[],
newProjectKeys: Set<string>,
completedProjectKeys: Set<string>,
getValue: (item: OaBoardProjectListItemDTO) => string | null | undefined,
): OaBoardMonthlyDimensionStatDTO[] {
const groups = new Map<
string,
{
newProjectCount: number
completedProjectCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
completedDurationDays: number[]
}
>()
items.forEach((item) => {
const value = String(getValue(item) ?? '').trim() || '未填写'
const group = groups.get(value) ?? {
newProjectCount: 0,
completedProjectCount: 0,
monthEndInProgressCount: 0,
monthEndOverallOvertimeCount: 0,
completedDurationDays: [],
}
if (newProjectKeys.has(item.boardKey)) group.newProjectCount += 1
if (completedProjectKeys.has(item.boardKey)) {
group.completedProjectCount += 1
if (item.overallActualHours !== null) group.completedDurationDays.push(item.overallActualHours)
}
if (item.overallStatus === 'RUNNING') group.monthEndInProgressCount += 1
if (hasOaBoardProjectOvertime(item)) group.monthEndOverallOvertimeCount += 1
groups.set(value, group)
})
return Array.from(groups.entries())
.map(([value, group]) => ({
value,
newProjectCount: group.newProjectCount,
completedProjectCount: group.completedProjectCount,
monthEndInProgressCount: group.monthEndInProgressCount,
monthEndOverallOvertimeCount: group.monthEndOverallOvertimeCount,
averageCompletedDays:
group.completedDurationDays.length > 0
? Number(
(
group.completedDurationDays.reduce((sum, duration) => sum + duration, 0) /
group.completedDurationDays.length
).toFixed(2),
)
: null,
}))
.sort(
(left, right) =>
right.completedProjectCount - left.completedProjectCount ||
right.newProjectCount - left.newProjectCount ||
right.monthEndInProgressCount - left.monthEndInProgressCount ||
left.value.localeCompare(right.value, 'zh-CN'),
)
}
type OaBoardMonthlyAggregate = {
newProjectCount: number
completedProjectCount: number
firstOrderCompletedCount: number
artCompletedCount: number
pricingCompletedCount: number
monthEndInProgressCount: number
monthEndOverallOvertimeCount: number
completedDuration: OaBoardMonthlyDurationDTO
dimensions: OaBoardMonthlyReportDTO['dimensions']
stageBottlenecks: OaBoardMonthlyStageBottleneckDTO[]
}
function buildOaBoardMonthlyAggregate(
items: OaBoardProjectListItemDTO[],
startDate: string,
endDate: string,
endSnapshots?: OaBoardDailyProjectSnapshot[],
): OaBoardMonthlyAggregate {
const newProjectKeys = new Set<string>()
const completedProjectKeys = new Set<string>()
const completedDurations: number[] = []
items.forEach((item) => {
const reviewFlow = item.flowChips.find((flow) => flow.flowType === 'review')
if (reviewFlow && isDateKeyInRange(reviewFlow.createdAtSource, startDate, endDate)) {
newProjectKeys.add(item.boardKey)
}
if (
item.overallStatus === 'COMPLETED' &&
isDateKeyInRange(latestFinishedDateKey(item), startDate, endDate)
) {
completedProjectKeys.add(item.boardKey)
if (item.overallActualHours !== null) completedDurations.push(item.overallActualHours)
}
})
const stageBottlenecks = buildDailyStageStats(items, endSnapshots)
.filter((stage) => stage.inProgressCount > 0)
.map((stage) => ({
stageName: stage.stageName,
monthEndInProgressCount: stage.inProgressCount,
overdueCount: stage.overdueCount,
overdueRate: stage.overdueRate,
longestStayDays: stage.longestStayDays,
}))
.sort(
(left, right) =>
right.overdueCount - left.overdueCount ||
right.monthEndInProgressCount - left.monthEndInProgressCount ||
(right.longestStayDays ?? 0) - (left.longestStayDays ?? 0),
)
.slice(0, 8)
return {
newProjectCount: newProjectKeys.size,
completedProjectCount: completedProjectKeys.size,
firstOrderCompletedCount: countCompletedSubflowsInRange(items, startDate, endDate, 'order'),
artCompletedCount: countCompletedSubflowsInRange(items, startDate, endDate, 'art'),
pricingCompletedCount: countCompletedSubflowsInRange(items, startDate, endDate, 'pricing'),
monthEndInProgressCount: items.filter((item) => item.overallStatus === 'RUNNING').length,
monthEndOverallOvertimeCount: items.filter(hasOaBoardProjectOvertime).length,
completedDuration: calculateMonthlyDuration(completedDurations),
dimensions: {
developmentPlan: buildMonthlyDimensionStats(
items,
newProjectKeys,
completedProjectKeys,
(item) => item.formSummary.developmentPlan,
),
demandDepartment: buildMonthlyDimensionStats(
items,
newProjectKeys,
completedProjectKeys,
(item) => item.originatorDeptName,
),
productCategory: buildMonthlyDimensionStats(
items,
newProjectKeys,
completedProjectKeys,
(item) => item.formSummary.categoryName || item.categoryName || item.productCategory,
),
},
stageBottlenecks,
}
}
export function buildOaBoardMonthlyReport(
items: OaBoardProjectListItemDTO[],
requestedMonth?: string,
snapshots?: OaBoardDailyProjectSnapshot[],
previousMonthSnapshots?: OaBoardDailyProjectSnapshot[],
snapshotWindow?: Omit<OaBoardDailyProjectSnapshotWindow, 'snapshots'>,
): OaBoardMonthlyReportDTO {
const reportMonth = normalizeOaBoardMonthlyReportMonth(requestedMonth)
const { startDate, endDate } = getMonthRange(reportMonth)
const previousReportMonth = getPreviousMonthKey(reportMonth)
const previousRange = getMonthRange(previousReportMonth)
const hasSnapshot = Boolean(snapshots && snapshots.length > 0)
const snapshotDates = Array.from(new Set((snapshots ?? []).map((snapshot) => snapshot.reportDate))).sort()
const currentSnapshotByBoardKey = getLatestSnapshotByBoardKey(snapshots ?? [])
const currentItems = hasSnapshot
? Array.from(currentSnapshotByBoardKey.values()).map(toSnapshotReportItem)
: items
const lastSnapshotDate = snapshotWindow?.lastSnapshotDate ?? snapshotDates.at(-1) ?? null
const endSnapshots = lastSnapshotDate
? (snapshots ?? []).filter((snapshot) => snapshot.reportDate === lastSnapshotDate)
: undefined
const aggregate = buildOaBoardMonthlyAggregate(currentItems, startDate, endDate, endSnapshots)
const previousSnapshotDays = Array.from(
new Set((previousMonthSnapshots ?? []).map((snapshot) => snapshot.reportDate)),
)
const hasMonthComparison = hasSnapshot && previousSnapshotDays.length > 0
const previousItems = hasMonthComparison
? Array.from(getLatestSnapshotByBoardKey(previousMonthSnapshots ?? []).values()).map(toSnapshotReportItem)
: []
const previousLastDate = previousSnapshotDays.sort().at(-1) ?? null
const previousEndSnapshots = previousLastDate
? (previousMonthSnapshots ?? []).filter((snapshot) => snapshot.reportDate === previousLastDate)
: undefined
const previousAggregate = hasMonthComparison
? buildOaBoardMonthlyAggregate(
previousItems,
previousRange.startDate,
previousRange.endDate,
previousEndSnapshots,
)
: null
const lastObservedAt = (snapshots ?? [])
.map((snapshot) => snapshot.lastObservedAt)
.filter((value): value is Date => Boolean(value))
.sort((left, right) => right.getTime() - left.getTime())[0]
return {
reportMonth,
source: hasSnapshot ? 'DAILY_SNAPSHOT' : 'REALTIME_FALLBACK',
snapshotDays: snapshotWindow?.snapshotDays ?? snapshotDates.length,
firstSnapshotDate: snapshotWindow?.firstSnapshotDate ?? snapshotDates[0] ?? null,
lastSnapshotDate,
snapshotObservedAt: lastObservedAt?.toISOString() ?? null,
hasMonthComparison,
previousReportMonth,
...aggregate,
comparison: {
newProjectCount: previousAggregate ? aggregate.newProjectCount - previousAggregate.newProjectCount : null,
completedProjectCount: previousAggregate
? aggregate.completedProjectCount - previousAggregate.completedProjectCount
: null,
monthEndInProgressCount: previousAggregate
? aggregate.monthEndInProgressCount - previousAggregate.monthEndInProgressCount
: null,
monthEndOverallOvertimeCount: previousAggregate
? aggregate.monthEndOverallOvertimeCount - previousAggregate.monthEndOverallOvertimeCount
: null,
averageCompletedDays:
previousAggregate !== null &&
previousAggregate.completedDuration.averageDays !== null &&
aggregate.completedDuration.averageDays !== null
? Number(
(aggregate.completedDuration.averageDays - previousAggregate.completedDuration.averageDays).toFixed(2),
)
: null,
},
}
}
export function buildOaBoardDailyReport(
items: OaBoardProjectListItemDTO[],
requestedDate?: string,
@ -559,24 +959,7 @@ export function buildOaBoardDailyReport(
const reportDate = normalizeOaBoardDailyReportDate(requestedDate)
const hasSnapshot = Boolean(snapshots && snapshots.length > 0)
const reportItems = hasSnapshot
? snapshots!.map((snapshot) => {
const item = toProjectListItem(snapshot.projectGraph)
const lastStageDisplay = snapshot.lastStageNames.join(';') || '-'
return {
...item,
overallStatus: snapshot.lastOverallStatus ?? item.overallStatus,
formSummary: {
...item.formSummary,
approvalStageDisplay: lastStageDisplay,
totalFlowStatusLabel:
snapshot.lastOverallStatus === 'RUNNING'
? '进行中'
: snapshot.lastOverallStatus === 'COMPLETED'
? '已完成'
: item.formSummary.totalFlowStatusLabel,
},
}
})
? snapshots!.map(toSnapshotReportItem)
: items
const newProjects = new Set<string>()
const completedProjects = new Set<string>()
@ -714,6 +1097,7 @@ export interface OaBoardDashboardDTO {
items: OaBoardProjectListItemDTO[]
summary: OaBoardSummaryDTO
dailyReport?: OaBoardDailyReportDTO
monthlyReport?: OaBoardMonthlyReportDTO
stageMatrix: OaBoardStageMatrixRowDTO[]
stageGraph: OaBoardStageGraphDTO
}
@ -823,6 +1207,7 @@ export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoar
productTypes,
},
dailyReport: buildOaBoardDailyReport(items),
monthlyReport: buildOaBoardMonthlyReport(items),
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}
@ -1127,6 +1512,7 @@ export function createMockOaBoardDashboard(): OaBoardDashboardDTO {
productTypes,
},
dailyReport: buildOaBoardDailyReport(items),
monthlyReport: buildOaBoardMonthlyReport(items),
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}
@ -1640,7 +2026,8 @@ export class OaBoardService {
async getDashboard(
filter: OaBoardProjectListFilter = {},
requestedReportDate?: string,
includeDailyReport = true,
requestedReportMonth?: string,
includePrivateReports = false,
): Promise<OaBoardDashboardDTO> {
const snapshotMaxAgeMinutesRaw = process.env.OA_BOARD_SNAPSHOT_MAX_AGE_MINUTES?.trim()
const snapshotMaxAgeMinutes = snapshotMaxAgeMinutesRaw ? Number(snapshotMaxAgeMinutesRaw) : 60
@ -1695,13 +2082,29 @@ export class OaBoardService {
),
).sort((left, right) => left.localeCompare(right, 'zh-CN'))
const reportDate = normalizeOaBoardDailyReportDate(requestedReportDate)
const dailySnapshots = includeDailyReport
const reportMonth = normalizeOaBoardMonthlyReportMonth(requestedReportMonth)
const monthlyRange = getMonthRange(reportMonth)
const previousReportMonth = getPreviousMonthKey(reportMonth)
const previousMonthlyRange = getMonthRange(previousReportMonth)
const dailySnapshots = includePrivateReports
? await queryOaBoardDailyProjectSnapshots(reportDate).catch(() => [])
: []
const previousDailySnapshots =
includeDailyReport && dailySnapshots.length > 0
includePrivateReports && dailySnapshots.length > 0
? await queryOaBoardDailyProjectSnapshots(getPreviousDateKey(reportDate)).catch(() => [])
: []
const monthlySnapshotWindow = includePrivateReports
? await queryOaBoardDailyProjectSnapshotWindow(monthlyRange.startDate, monthlyRange.endDate).catch(
() => ({ snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }),
)
: { snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }
const previousMonthlySnapshotWindow =
includePrivateReports && monthlySnapshotWindow.snapshots.length > 0
? await queryOaBoardDailyProjectSnapshotWindow(
previousMonthlyRange.startDate,
previousMonthlyRange.endDate,
).catch(() => ({ snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }))
: { snapshotDays: 0, firstSnapshotDate: null, lastSnapshotDate: null, snapshots: [] }
return {
items,
@ -1712,9 +2115,18 @@ export class OaBoardService {
completedProjects: items.filter((item) => item.overallStatus === 'COMPLETED').length,
productTypes,
},
dailyReport: includeDailyReport
dailyReport: includePrivateReports
? buildOaBoardDailyReport(allItems, reportDate, dailySnapshots, previousDailySnapshots)
: undefined,
monthlyReport: includePrivateReports
? buildOaBoardMonthlyReport(
allItems,
reportMonth,
monthlySnapshotWindow.snapshots,
previousMonthlySnapshotWindow.snapshots,
monthlySnapshotWindow,
)
: undefined,
stageMatrix: buildStageMatrixRows(),
stageGraph: buildStageGraph(items),
}