feat(oa-board): add monthly flow analysis
This commit is contained in:
parent
b24328f4f8
commit
981df46e3f
@ -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 || ''
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user